经过授权,我们得以进入客户的项目,看到获取到的 heapsnapshot 文件,与此同时,可以通过进程趋势图看到内存飙高引发的一些“并发症”,比如 GC 耗时变久,降低了进程的处理效率:
可以看到,将近 1 个G的文件,当看到 (context) 这个字样的时候,表明的是它并不是一个普通的对象,而是函数执行期间所产生的上下文对象,比如闭包。函数执行完了,这个上下文对象并不一定就消失了。
另外这个上下文对象跟 co 模块有关,这说明 co 应该是调度了一个长时期执行的 Generator。否则这类上下文对象会随着执行结束,进入 GC 回收。
但这点信息完全无法得出任何结论。继续看。
尝试根据 @22621725 查看对象内容,尝试根据 @22621725 查看到 GC root 的引用。无果。
接下来比较有效的信息在对象簇视图上:
可以看到从 @22621725 开始,一个 context 引用又一个 context,中间穿插一个 Promise。熟悉 co 的同学会知道 co 会将非 Promise 的调用转化为一个 Promise,这个地方的 Promise 意味着一个新的 Generator 的调用。
这里的引用关系非常长,笔者展开 20 层之后,Percent 的占比还没有降低万分之一。这里线索中断了。
下一个有用的信息是类视图:
这个图里有不太常见的东西冒出来:scheduleUpdatingTask。
这个堆快照中有 390,285 个 scheduleUpdatingTask 对象,点击该类,查看详情:
这个类在文件 function /home/xxx/app/schedule/updateDeviceInfo.js() / updateDeviceInfo.js 中。
目前能提供的线索就仅限这些了,接下来进入代码分析的阶段。
// 执行业务,成功之后稍作等待,继续// 如果拿锁失败了,停止const scheduleUpdatingTask = function* (ctx) { if (!taskActive) return; try { yield doSomething(ctx); } catch (e) { // 需要捕获业务异常,即使挂了,下一次schedule也能正常跑 ctx.logger.error(e); } yield scheduleUpdatingTask(ctx);};
在整个项目中,唯一能找到对 scheduleUpdatingTask 反复调用的,就只有它自身对自身的调用,也就是通常所说的递归调用。
当然,完全说是递归调用也不是很符合实际情况。因为如果真的是递归调用的话,栈首先就溢出了。
栈没有溢出的原因在于 Co/Generator 体系中,yield 关键字的前后执行实际上是跨多个 eventloop 过程的。
虽然没有栈溢出,但 Generator 执行之后所附属的 context 对象要在整个 generator 执行完成之后才会销毁。因此这个地方的递归就导致 context 引用 context 的过程,于是内存就无法得到回收。
在这段代码中,很明显的是
if (!taskActive) return;
这个终止条件失效了。
根据这段代码反推之前的表现,完全符合现象。为了确认这个问题,笔者写了一段代码来尝试重现该问题:
const co = require('co');function sleep(ms) { return new Promise((resolve) => { setTimeout(() => { resolve(); }, ms); });}function* task() { yield sleep(2); console.log(process.memoryUsage()); yield task();}co(function* () { yield task();});
执行这段代码后,应用程序不会立即崩溃,而是内存会逐渐增长,跟 hpmweb 表现得一摸一样。
当然我们猜想,是不是 async functions 不会导致这个问题:
function sleep(ms) { return new Promise((resolve) => { setTimeout(() => { resolve(); }, ms); });}async function task() { await sleep(2); console.log(process.memoryUsage()); await task();}task();
答案是内存仍然会持续增长。