在 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 阻塞式获取快照痛点的一些方案探索,初步来看是可行的,但是还没有完全验证,有兴趣的可以参考: