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

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-12 03:48 ·2 浏览 ·0 回复

从一个内存告警说起

前阵子帮朋友排查一个 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 引擎中支持不一,且解决不了异步递归的堆内存问题。写异步代码时,多想想对象图:谁引用了谁,什么时候能释放。栈溢出会立刻报错,而堆内存增长往往悄无声息,等发现时已经晚了。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-246.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~