Node.js 異步打快照的探索
在 Node.js 中,內存快照是分析內存問題的主要手段,通過內存快照我們可以進行內存優化或解決內存泄露的問題。獲取內存快照的方式雖然簡單,但是也存在一些問題。
1. 獲取內存快照的過程是阻塞式的,這樣意味著在這期間,目的線程是無法處理其他工作的,而這個過程通常非常耗時,這個耗時一來取決于當前使用的內存大小,二來取決于 V8 的實現(之前也有同學優化了這部分),所以線上操作打快照需要非常謹慎。
2. 獲取內存快照期間需要消耗更多的內存,尤其是第一次的時候內存通常會成倍的增長,這樣很容易導致 OOM。
之前在做 Node.js APM 時,每次幫助用戶排查內存泄露問題時獲取快照都是比較麻煩的事情,一來擔心影響阻塞用戶服務導致無法處理請求,二來擔心把用戶服務打掛了。本文嘗試通過異步的方式獲取快照來解決問題 1,從而避免獲取快照過程目的線程無法工作的問題。
異步獲取快照的原理是通過在目的線程中 fork 一個子進程,然后目的線程可以繼續執行,因為子進程“復制”了父進程的內存,所以可以在子進程中”慢慢地“獲取目的線程的內存快照而不影響目的線程,Redis 的 aof 和 rdb 也用到了類似的方式。我們知道獲取內存快照的過程就是把內存的信息記錄到一個文件中(或其他地方),如果消耗的內存越大,則處理的過程越久,所以如果在目的進程/線程做肯定是存在一定的影響的,而通過 fork 方式主要是利用了 fork 只復制頁表不需要復制物理內存來達到快速復制內存信息的目的,又因為進程的內存是隔離的,所以雖然 fork 只復制了頁表,但是頁表對應的物理內存也是各個進程獨立的,在 fork 后,父子進程都會使用同一份物理內存,當某個進程進行寫操作時,操作系統再通過 COW(寫時復制)技術分配一塊新的內存給該內存并修改進程的頁表信息,這也是異步方式帶來的一點副作用,即可能需要分配更多的系統內存和性能損耗,但是在獲取快照的過程中應用的寫操作不多的話理論上影響也不會很大。
了解了原理后,接著看一下實現。
void TakeSnapshotByFork(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
String::Utf8Value filename(isolate, args[0]);
high_resolution_clock::time_point fork_t1 = high_resolution_clock::now();
pid_t pid = fork();
switch (pid) {
case -1:
perror("fork");
exit(EXIT_FAILURE);
case 0: {
FILE* fp = fopen(*filename, "w");
if (fp == NULL) {
perror("fopen");
exit(EXIT_FAILURE);
}
high_resolution_clock::time_point take_snapshot_t1 = high_resolution_clock::now();
const v8::HeapSnapshot* const snapshot = isolate->GetHeapProfiler()->TakeHeapSnapshot();
FileOutputStream stream(fp);
snapshot->Serialize(&stream, v8::HeapSnapshot::kJSON);
high_resolution_clock::time_point take_snapshot_t2 = high_resolution_clock::now();
duration<double, std::milli> time_span = take_snapshot_t2 - take_snapshot_t1;
std::cout << "taking snapshot cost " << time_span.count() << " milliseconds."<<std::endl;
std::cout <<"take snapshot done !\n"<<std::endl;
fclose(fp);
const_cast<v8::HeapSnapshot*>(snapshot)->Delete();
exit(EXIT_SUCCESS);
}
default:
high_resolution_clock::time_point fork_t2 = high_resolution_clock::now();
duration<double, std::milli> time_span = fork_t2 - fork_t1;
std::cout << "fork cost " << time_span.count() << " milliseconds."<<std::endl;
break;
}
}
實現上并不復雜,只是把獲取快照的代碼移到了 fork 出來的子進程中,我大概寫了初步的實現并驗證了一下可行性。下面是測試例子。
const addon = require('..')
class MainClass {}
const obj = new MainClass();
for (let i = 0; i < 100000000; i++) {
obj[i] = i
}
console.log('rss ', process.memoryUsage().rss / 1024 / 1024 / 1024)
setInterval(() => {
obj
}, 10000)
setTimeout(() => {
const t1 = Date.now();
addon.takeSnapshotByFork(`./${process.pid}.heapsnapshot`)
console.log('addon.takeSnapshotByFork cost ', Date.now()-t1, 'ms')
}, 1000);
輸出如下:
rss 1.7279319763183594
fork cost 9.38548 milliseconds.
addon.takeSnapshotByFork cost 9 ms
taking snapshot cost 2413.08 milliseconds.
take snapshot done !
可以看到內存 rss 消耗了 1 G 多,fork 耗時 9 ms,獲取快照的過程消耗了 2s,但是這 2 s 期間,目的線程是可以執行其他代碼的,當 for 循環改成 100000 時輸出如下。
rss 0.029743194580078125
fork cost 0.655211 milliseconds.
addon.takeSnapshotByFork cost 0 ms
taking snapshot cost 283.35 milliseconds.
take snapshot done !
可以看到內存大小不一樣時,fork 的耗時是不一樣的。下面是操作系統 fork 時復制頁表的大致過程。
int copy_page_tables(struct task_struct * tsk)
{
int i;
pgd_t *old_pgd;
pgd_t *new_pgd;
// 分配一頁
new_pgd = pgd_alloc();
if (!new_pgd)
return -ENOMEM;
// 設置進程的cr3字段,即最高級頁目錄表首地址
SET_PAGE_DIR(tsk, new_pgd);
// 取得當前進程的最高級頁目錄表首地址
old_pgd = pgd_offset(current, 0);
// 復制每一項
for (i = 0 ; i < PTRS_PER_PGD ; i++) {
int errno = copy_one_pgd(old_pgd, new_pgd);
if (errno) {
free_page_tables(tsk);
invalidate();
return errno;
}
old_pgd++;
new_pgd++;
}
invalidate();
return 0;
}
內存越大,所需要的頁表越多,上面的 for 循環過程就越久,但是相比復制整個物理內存來說,還是快很多。
以上是針對 Node.js 阻塞式獲取快照痛點的一些方案探索,初步來看是可行的,但是還沒有完全驗證,有興趣的可以參考: