⭐ 推荐:社区规则条款 V1.0

聊一聊Promise finally与尾调用对内存的影响

dp32323
dp32323 正式会员正式会员
发布于 2026-07-16 13:38 ·102 浏览 ·0 回复
内容摘要

排查 Node 服务内存增长时发现,用 Promise 链实现的递归重试配合 .finally() 清理会因每次调用创建新 promise、闭包引用被挂到反应队列且在原 promise 未 settle 时不释放,同时 V8 默认不支持尾调用优化,导致异步递归不断在堆上累积 promise 与微任务,从而造成内存持续升高。

从一个内存告警说起

前阵子帮朋友排查一个 Node 服务的内存缓慢增长问题。服务本身不算复杂,但跑上几个小时 RSS 就会涨到几个 GB。最后定位到的代码并不神秘:一段用 Promise 链写的递归重试逻辑,外加几个 .finally() 做清理。乍看之下没问题,但把 Promise finally 和尾调用的内存模型放在一起看,就能解释为什么堆里会堆积那么多“本该死掉”的对象。

这篇文章不打算复述 API 文档,而是想从内存视角聊聊:Promise.prototype.finally 到底保留了什么,尾调用优化在 JavaScript 里又为什么常常靠不住。

Promise finally 不只是“最后执行”

finally 的语义很简单:无论 promise 是 fulfilled 还是 rejected,回调都会执行;如果回调返回一个 thenable,则等待它;否则原样传递之前的值或原因。但很多人忽略了一点:每次 .finally() 都会创建一个新的 promise,并把回调闭包挂到这个新 promise 的反应队列上。

function withCleanup(p) {
  const bigData = new Array(1e6).fill('x');
  return p.finally(() => {
    // 闭包引用了 bigData
    console.log(bigData.length);
  });
}

只要 p 还没有 settle,bigData 就不会被回收。如果 p 是一个永远不会 resolve 的 promise,或者是一个被遗忘的 pending promise,这个闭包就会一直留在堆里。更隐蔽的是,如果 finally 回调返回了另一个 promise,链会被继续拉长,中间所有的 promise 对象和闭包都会互相引用,直到整条链结束。

所以 finally 本身不是内存泄漏,但它是一个“引用保持器”。你往里面放什么,它就替你留什么。

尾调用优化:听起来很美,但 V8 不买账

尾调用优化(TCO)的本意是:如果函数的最后一步是调用另一个函数,且不需要保留当前栈帧,引擎可以复用栈帧,从而把递归变成循环,避免栈溢出。在 ES6 严格模式下,规范是要求支持 PTC 的,但现实很骨感——V8(Chrome、Node.js)默认不开启,只有 JavaScriptCore(Safari)等少数引擎实现了。

这意味着,在 Node 里写尾递归,栈该涨还是涨。但 Promise 异步递归的问题不在栈上,而在堆上。每次递归调用 .then() 或 .finally(),都会在堆上创建一个新的 promise 对象和一个微任务。栈帧确实很快弹出了,但堆上的对象却像滚雪球一样越滚越大。

function loop(n) {
  if (n === 0) return Promise.resolve();
  return loop(n - 1).finally(() => {});
}

注意这里 return loop(n - 1).finally(...) 并不是尾调用,因为最后一步是 .finally() 方法调用,而不是 loop 函数调用。即使写成 return loop(n - 1),外层递归返回的是 promise,但每次递归仍然创建了一个新的 promise。如果 n 是十万,堆上就会同时存在十万个 promise 对象,内存峰值非常可观。

当 finally 遇到尾调用:一个常见的误区

有人可能会想:既然尾调用能复用栈帧,那我用尾递归写异步循环,再配合 finally 做清理,岂不是既安全又优雅?问题在于,尾调用优化解决的是同步调用栈,而 Promise 的工作机制完全建立在堆和微任务队列上。

即使引擎支持 PTC,loop(n - 1) 被优化成循环,finally 仍然会为每一轮创建一个新的 promise。更糟糕的是,如果 finally 回调里引用了外部变量,这些变量会被每一轮的闭包捕获,直到对应的 promise settle 才能释放。如果递归深度很大,内存会一直上涨,而不是像栈那样保持恒定。

一个更实际的坑是:在 async/await 里写递归,虽然 await 会挂起函数、释放调用栈,但每次 await 都会创建一个新的 promise,并且函数的局部变量会被保存在堆上的闭包环境中。如果你在 finally 块里引用了大对象,这个闭包会一直活到整个异步函数结束。

实战中的几个建议

第一,能用循环就别用异步递归。把递归改写成 while 循环加 await,内存曲线会平坦很多。第二,finally 里尽量只做轻量清理,不要捕获大对象;如果必须引用,用完手动置空。第三,不要依赖尾调用优化来写递归,尤其是在 Node 环境里。第四,对于可能长期 pending 的 promise,考虑加上超时或 AbortController,避免闭包无限期保留。

小结

Promise.finally 是一个方便的清理工具,但它会创建新的 promise 并保留回调闭包;尾调用优化在 JavaScript 引擎中支持不一,且解决不了异步递归的堆内存问题。写异步代码时,多想想对象图:谁引用了谁,什么时候能释放。栈溢出会立刻报错,而堆内存增长往往悄无声息,等发现时已经晚了。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-246.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~