用 Promise.race 做超时控制是前端常见的做法,但很多人在实现时只关注了功能,忽略了资源释放。如果业务 Promise 因为网络或其他原因一直挂起,超时后它并不会自动取消,导致内存泄漏。下面我会先说明为什么会有这个问题,然后给出几种可行的处理方式。
基本实现与潜在问题
使用 Promise.race 实现超时控制时,通常将业务 Promise 与一个定时器 Promise 进行竞赛。定时器 Promise 会在指定时间后 reject 一个超时错误,从而让 race 提前结束。但这种写法容易埋下隐患:如果业务 Promise 在超时后仍然处于 pending 状态,它内部的异步资源(如网络连接、定时器、监听器)并不会自动释放,导致内存泄漏。
上面的代码中,如果 timeoutPromise 先 reject,race 返回的 Promise 立即 reject,但业务 Promise 仍在执行。例如,一个 fetch 请求并没有因为超时而中断,请求完成后其 then/catch 回调依然会执行,但由于 race 已经结束,这些回调可能无法被正确消费,或者其引用的对象无法被回收。
内存泄漏的根源
Promise.race 返回的 Promise 一旦 settle,被淘汰的那个 Promise 并不会被取消,它依然会继续执行并持有引用。例如一个发起 HTTP 请求的 Promise,超时后请求仍在进行,其回调函数依然被事件循环调度。如果请求内部注册了全局事件监听或维持了大对象引用,这些资源便无法被垃圾回收。这是最常见的泄漏场景。
理解这一点很重要:race 只是竞争,不是取消。任何 Promise 一旦被创建,其内部的异步操作(如 setTimeout、fetch、事件监听)就会启动,除非你主动去停止它。所以在设计超时逻辑时,必须把“取消”作为与“超时”并列的需求来考虑。
清除定时器:最基础的防护
要避免泄漏,首先必须清除定时器。在定时器 Promise 中,使用 setTimeout 返回的句柄,并在 race 结束后通过 clearTimeout 清除。关键操作是在 then 和 catch 回调中获取定时器 ID 并调用 clearTimeout。但需要注意:如果业务 Promise 先完成,定时器仍在等待,clearTimeout 能及时销毁定时器本身,但定时器回调内的引用也会被释放。
function timeout(ms) {
let id;
const promise = new Promise((_, reject) => {
id = setTimeout(() => reject(new Error('Timeout')), ms);
});
return { promise, id };
}
const { promise: timeoutPromise, id: timerId } = timeout(5000);
const racePromise = Promise.race([fetchPromise, timeoutPromise]);
racePromise.then(result => {
clearTimeout(timerId);
// 处理结果
}).catch(err => {
clearTimeout(timerId);
// 处理错误
});注意,上面的代码中 clearTimeout 在 finally 中写更安全,因为 then 和 catch 都可能执行。但 better 是使用 .finally() 保证无论何种结果都清理。
使用 AbortController 主动取消
对于 fetch 或可取消的异步操作,推荐使用 AbortController 实现主动取消。将 AbortController 的 signal 传递给请求,当超时发生时,通过 controller.abort() 中断请求。这样不仅能释放网络资源,还能让请求 Promise 立即 reject,避免其继续挂起。需要注意的是,abort 后需确保 reject 错误不被重复捕获,并在 finally 中清除定时器。
function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
}上面代码中,如果 fetch 在超时前完成,finally 会清除定时器;如果超时先发生,controller.abort() 会导致 fetch Promise reject,同时 finally 也会清除定时器。注意 abort 后不要再手动 reject,否则会触发 unhandled rejection。
对于不支持 AbortController 的 API,如 XMLHttpRequest,可以调用 xhr.abort() 方法。但需要封装成 Promise,并在超时时调用 abort。
如何验证内存泄漏是否发生
要确认自己的超时逻辑是否有泄漏,可以在浏览器 DevTools 的 Memory 面板中录制堆快照,对比操作前后 Promise、定时器、AbortController 实例的数量。执行多次超时场景后,若这些对象数量持续增长且未被回收,则存在泄漏。另外,使用 Node.js 的 process.memoryUsage() 监控 rss 或 heapUsed,观察是否持续上升。
建议在开发阶段就加入这种检查,而不是等到线上出现内存增长才排查。对于持续运行的 Node.js 服务,可以用 --expose-gc 参数手动触发 GC 后对比内存快照。
实际开发中的注意事项
一个常见坑是忘记在 finally 中清除定时器。如果业务 Promise 先 resolve,但定时器尚未触发,若不清理,定时器回调仍然会执行,可能触发不必要的 reject 或状态变更。另一个坑是使用 Promise.race 包裹多个异步任务时,若某个任务内部持有全局引用(如 setInterval),即便超时清除定时器,该引用仍可能存活,需要额外设计取消逻辑。
对于 setInterval 这类重复任务,最好将其封装成可取消的形式,在超时或完成时调用 clearInterval。另外,如果代码中使用了第三方库的 Promise,查看其是否提供取消机制,例如 axios 的 CancelToken。
问:如果业务 Promise 是封装了一些无法取消的操作(比如 worker.postMessage),怎么办?
答:这种情况下,超时控制只能做到不让调用方等待,但无法回收后台资源。需要业务层面设计超时后忽略结果,并确保回调中不执行副作用,或者让后台任务自行判断是否已被取消(通过一个共享的 cancelled 标志)。
总之,Promise.race 本身不提供取消能力,必须手动配合定时器清理和主动 API 取消,才能做到既控制等待时间又不浪费内存。