以空操作替换队列回调实现Promise取消存在哪些问题?
我有一个存储Promise任务的("micro-task"?)队列,可通过如下方式调度任务:
const task = schedule(queue, _ => { return /* valuable stuff */; }).then(value => { /* do stuff with value */ });
假设后续我不需要执行该任务,直接调用如下方法即可:
task.cancel()
我了解到ES6标准中Promise原生不支持取消,但为何不能通过如下自定义schedule方法实现取消效果:
function schedule(queue, fn) { let index; const promise = new Promise((resolve, reject) => { index = queue.push(() => { try { resolve(fn()); } catch(error) { reject(error); } }); }); promise.cancel = () => { queue[index] = _ => {}; // just replace that callback with a no-op } return promise; }
该实现除了队列执行过程中索引变动、无法准确定位对应任务的小问题外,还存在什么缺陷?是否会因Promise永久处于pending状态引发内存泄漏?当前实现中已无resolver函数的外部引用,Promise自身是否持有resolver函数的引用?如果持有,是否使用弱引用?如果没有外部持有Promise引用,仅等待then()/finally()回调触发时,Promise如何避免被垃圾回收?
更新:队列执行逻辑补充
我基于Array实现了包含schedule和run方法的Queue类:
class Queue extends Array { schedule(fn) { let index; const promise = new Promise((resolve, reject) => { index = this.push((...args) => { try { resolve(fn(...args)); } catch(error) { reject(error); } }); }); promise.cancel = () => { this[index] = _ => {}; // just remove that callback } return promise; } run() { console.log("run", arguments); while(this.length > 0) this.shift()(...arguments); } }
可按如下方式使用:
const queue = new Queue()
const task = queue.schedule(_ => { return /* valuable stuff */; }).then(value => { /* do stuff with value */ });
队列会在特定时机被触发执行,触发逻辑如下:
function go() { if(queue.length > 0) setTimeout(function() { queue.run(...arguments); } }
我观察到:任务执行过程中新增的队列项会在同一轮run中执行,但then()回调中新增的队列项只会在下一轮run中执行,本次暂不展开微任务队列定义相关问题,避免问题失焦。
该实现除索引错位问题外,还存在以下本质缺陷:
取消语义缺失
替换为空函数后,原Promise将永久处于pending状态,所有挂载在该Promise上的then/catch/finally回调永远不会触发。所有依赖Promise settled状态执行的清理逻辑(如隐藏加载态、释放占用资源、超时兜底逻辑)会直接失效。符合预期的取消逻辑应当在调用cancel方法时,主动reject一个代表取消状态的错误,或返回明确的取消标记,让下游逻辑感知任务状态,而非静默挂死。存在确定性的内存泄漏风险
针对Promise引用持有问题,ECMAScript标准有明确规则:- pending状态的Promise实例会强引用自身的resolve、reject函数,不存在弱引用实现。只要Promise未进入fulfilled或rejected状态,两个resolver函数就不会被垃圾回收,函数闭包捕获的所有上下文(包括传入的任务函数fn、整条Promise回调链上的所有函数)都会被持续持有。
- 只要pending状态Promise的resolver函数被可访问的对象持有(该实现中是队列存储的任务回调持有resolve引用),即使没有外部变量直接持有Promise实例,GC也不会回收该Promise及其关联资源。
- 调用cancel替换空函数后,虽然队列对原resolver的引用被断开,但如果Promise实例本身被外部变量持有(如示例中赋值给
task变量),该Promise和整条回调链会永久驻留内存,永远不会被释放,因为不存在任何触发其状态变更的路径。如果是全局生命周期队列上的任务被取消,泄漏会持续到进程退出。
链式调用失效
cancel方法仅挂载在schedule返回的原始Promise上,而then/catch/finally调用会返回全新的Promise实例,不会携带cancel方法。示例中queue.schedule(...).then(...)拿到的task实际是then返回的新Promise,直接调用task.cancel()会抛出方法不存在的错误,属于实现层面的硬伤。无效任务残留带来的额外开销
当前cancel逻辑仅将任务替换为空函数,队列执行run方法时仍会取出空函数执行,并未真正从队列中移除无效任务。大量取消任务的场景下,队列会堆积大量无意义的空函数,拉长run方法的同步执行耗时。更合理的实现是直接标记已取消任务,run执行时跳过已取消项,或直接从队列中移除对应任务。
关于观察到的执行顺序差异:任务执行过程中同步新增的队列项会在同一轮run执行,是因为run方法的while循环每次都会实时读取数组长度,同步执行shift操作,同步新增的队列项会被立刻遍历到;而then的回调属于微任务,会在当前同步代码执行完毕、当前setTimeout宏任务结束后才会执行,此时while循环已经退出,因此新增的队列项只能等待下一轮run触发。
内容的提问来源于stack exchange,提问作者Gunther Schadow

