Co、递归调用引发的内存泄漏

简介: 我们知道,同步的递归写法,如果在退出递归条件失效时,会快速因为栈溢出导致进程挂掉。而在某些场景下,我们会采用异步的递归写法来规避这个问题:

前言

我们知道,同步的递归写法,如果在退出递归条件失效时,会快速因为栈溢出导致进程挂掉。而在某些场景下,我们会采用异步的递归写法来规避这个问题:

async function recursive() {
  if( active ) return;
  // do something
  await recursive();
}

关键字 await 后面的函数调用可能会跨越多个 event loop,这样的写法下不会出现栈溢出的错误。然而这种写法其实也不是万无一失的,我们来看下面这个生产故障案例。

发现问题

客户接入 Node.js 性能平台 后,通过监控经常出现内存增长导致的 OOM,于是客户加上了一条告警规则:@heap_used / @heap_limit > 0.5,目的是在堆较小但是发生泄漏时能正常输出 heapsnapshot 文件用于分析。

经过授权,我们得以进入客户的项目,看到获取到的 heapsnapshot 文件,与此同时,可以通过进程趋势图看到内存飙高引发的一些“并发症”,比如 GC 耗时变久,降低了进程的处理效率:

GC

定位问题

借助这次顺利生成的堆快照(heapsnapshot)文件,大致能看出内存泄漏的地方在哪里,但想要完全找出来,还有点难度。

堆快照分析

第一个信息,内存泄漏报表:

report

可以看到,将近 1 个G的文件,当看到 (context) 这个字样的时候,表明的是它并不是一个普通的对象,而是函数执行期间所产生的上下文对象,比如闭包。函数执行完了,这个上下文对象并不一定就消失了。

另外这个上下文对象跟 co 模块有关,这说明 co 应该是调度了一个长时期执行的 Generator。否则这类上下文对象会随着执行结束,进入 GC 回收。

但这点信息完全无法得出任何结论。继续看。

尝试根据 @22621725 查看对象内容,尝试根据 @22621725 查看到 GC root 的引用。无果。

接下来比较有效的信息在对象簇视图上:

cluster

可以看到从 @22621725 开始,一个 context 引用又一个 context,中间穿插一个 Promise。熟悉 co 的同学会知道 co 会将非 Promise 的调用转化为一个 Promise,这个地方的 Promise 意味着一个新的 Generator 的调用。

这里的引用关系非常长,笔者展开 20 层之后,Percent 的占比还没有降低万分之一。这里线索中断了。

下一个有用的信息是类视图:

histogram

这个图里有不太常见的东西冒出来:scheduleUpdatingTask。

这个堆快照中有 390,285 个 scheduleUpdatingTask 对象,点击该类,查看详情:

sche

这个类在文件 function /home/xxx/app/schedule/updateDeviceInfo.js() / updateDeviceInfo.js 中。

目前能提供的线索就仅限这些了,接下来进入代码分析的阶段。

代码分析

经过客户授权,拿到了相关的代码,找到 app/schedule/updateDeviceInfo.js 文件中的 scheduleUpdatingTask

// 执行业务,成功之后稍作等待,继续
// 如果拿锁失败了,停止
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();
});

执行这段代码后,应用程序不会立即崩溃,而是内存会逐渐增长,跟出问题的客户项目表现得一摸一样。

当然我们猜想,是不是 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();

答案是内存仍然会持续增长。

解决问题

虽然这次的 heapsnapshot 在 Node.js 性能平台中的分析不是很顺畅,但我们还是找到了问题点。既然找到原因了,那么我们继续看一下该如何解决这个问题。

从上面的例子可以看出,在 co 或者 async functions 中使用递归调用,会导致内存回收被延迟,这种延迟会导致内存堆积,引起内存压力。这是不是意味着在这种场景下不能使用递归了。答案当然不是。

但我们需要对应用程序评估,这个递归会引起多长的引用链路。在本文这个例子中,在退出条件失效的情况下,相当于就是无限递归。

那有没有一种继续执行,但不引起上下文引用链路太长的方案?答案是有:

async function task() {
  while (true) {
    await sleep(2);
    console.log(process.memoryUsage());
  }
}

上文通过将递归调用换成 while (true) 循环后,就不再有上下文引用链路的问题。由于内部有 await 会引起 eventloop 的调度,所以 while (true) 并不会阻塞主线程。

题外话

普通函数的尾递归优化当前都还不是很好,更何况 Generator/Async Functions。

目录
相关文章
|
开发工具 git
Git查看/修改/添加远程仓库地址
Git查看/修改/添加远程仓库地址
1068 0
|
安全 Java 编译器
使用枚举实现“状态”转换限制
我是小假 期待与你的下一次相遇 ~
524 4
|
数据采集 监控 Oracle
GraalVM 24 正式发布阿里巴巴贡献重要特性 —— 支持 Java Agent 插桩
阿里巴巴是 GraalVM 全球顾问委员会的唯一中国代表,阿里云程序语言与编译器团队和可观测团队合作实现了 GraalVM 应用的无侵入可观测能力,并在 ARMS 平台上线了该功能。目前在 GraalVM 24 中发布的是支持 Java agent 的第一步,其余能力将在 GraalVM 的后续版本中陆续发布。
913 23
|
12月前
|
人工智能 Java 测试技术
单元测试覆盖率的自动控制技术
Jacoco是Java程序覆盖率工具,可以在pom.xml通过配置来自动控制程序的覆盖率
253 5
|
Java 编译器 测试技术
Java注解(Annotation)与元编程实践
本文深入讲解Java注解的原理与实战应用,涵盖内置注解、自定义注解、编译期与运行期处理机制,以及在依赖注入、Web框架和数据验证中的实际应用,助你掌握元编程核心技能。
|
Java 编译器 API
Java Lambda表达式与函数式编程入门
Lambda表达式是Java 8引入的重要特性,简化了函数式编程的实现方式。它通过简洁的语法替代传统的匿名内部类,使代码更清晰、易读。本文深入讲解Lambda表达式的基本语法、函数式接口、方法引用等核心概念,并结合集合操作、线程处理、事件回调等实战案例,帮助开发者掌握现代Java编程技巧。同时,还解析了面试中高频出现的相关问题,助你深入理解其原理与应用场景。
|
编解码 算法 索引
基于simulink的模拟锁相环和数字锁相环建模与对比仿真
本研究利用Simulink对模拟锁相环(PLL)和数字锁相环(DPLL)进行建模,通过对比两者的收敛曲线及锁定频率值,分析其性能差异。系统采用MATLAB2022a版本,详细介绍了PLL和DPLL的工作原理,涵盖鉴相器、滤波器及振荡器等关键组件的功能与数学描述。
|
12月前
|
监控 网络协议 Linux
Gobuster目录/文件扫描工具
Gobuster 是用 Go 语言编写的高效命令行扫描工具,支持目录/文件暴力扫描、子域名枚举和虚拟主机发现。其多线程机制使扫描速度快,资源占用低,适合远程或云服务器使用。它具备灵活性,支持自定义字典、代理、HTTP 认证等功能,适用于敏感目录查找、资产管理、渗透测试等场景。此外,Gobuster 可通过安装在 Kali 或 Linux 系统上,快速执行各类扫描任务,是安全测试和自动化脚本的理想选择。
891 0
|
自然语言处理 前端开发 算法
Java编译器优化秘籍:字节码背后的IR魔法与常见技巧
编译器将源代码转换为机器码的过程中,会经历多个中间表达形式(IR)的转换与优化。前端生成高级IR(HIR),后端将其转为低级IR(LIR)并进行机器相关优化。Java编译流程包括源码到字节码、再由即时编译器转换为内部HIR(如SSA图)、优化后生成LIR,最终编译为机器码。常见优化技术包括常量折叠、值编号、死代码消除、公共子表达式消除等,旨在提升程序性能与执行效率。
448 0
|
算法 Linux C++
C++框架设计中实现可扩展性的方法
在软件开发中,可扩展性至关重要,尤其对于C++这样的静态类型语言。本文探讨了在C++框架设计中实现可扩展性的方法:1) 模块化设计降低耦合;2) 使用继承和接口实现功能扩展;3) 通过插件机制动态添加功能;4) 利用模板和泛型提升代码复用;5) 遵循设计原则和最佳实践;6) 应用配置和策略模式以改变运行时行为;7) 使用工厂和抽象工厂模式创建可扩展的对象;8) 实现依赖注入增强灵活性。这些策略有助于构建适应变化、易于维护的C++框架。
1189 2