可重启异步任务 createRestartableTask
代码实现
function createRestartableTask(task, maxRestarts = 1) {
let restartCount = 0
let version = 0
let resolveOuter, rejectOuter
const promise = new Promise((resolve, reject) => {
resolveOuter = resolve
rejectOuter = reject
})
async function run() {
const currentVersion = ++version
try {
const result = await task()
// 只有当前最新一轮,才可以提交结果。
if (currentVersion === version) {
resolveOuter(result)
}
} catch (error) {
if (currentVersion === version) {
rejectOuter(error)
}
}
}
function restart() {
restartCount++
if (restartCount > maxRestarts) {
rejectOuter(new Error('Restart limit'))
return
}
run()
}
run()
return { promise, restart }
}
笔记记录
可重启异步任务 createRestartableTask
要解决什么问题
一个任务还没完成时,允许主动重新执行,并忽略旧轮次晚到的结果。调用方始终等待同一个外层 promise,通过 restart() 启动新一轮。
核心是一个版本号:每轮执行前递增 version,并把当时的值保存在局部变量 currentVersion 中。任务完成后,只有版本仍然相等的那一轮,才会调用外层 Promise 的 resolve 或 reject。
这里保留原实现的行为,只统一变量命名与排版。它屏蔽旧结果,不会取消旧任务。
什么时候执行
- 调用
createRestartableTask(task)时立即执行第一轮task()。 - 调用
restart()时先累计重启次数;未超限则立即执行新一轮,无需等待旧轮次结束。 - 读取返回对象的
promise、调用.then()或await promise,都不会启动新任务。 - 最新一轮成功或失败,会尝试结束外层 Promise;失败不会自动触发重试。
maxRestarts 表示首次执行之外,允许额外启动几轮。约定它是非负整数,源码没有额外校验参数:默认 1 时,最多执行 task() 两次。
看一遍时序
假设第一轮较慢,在它完成前调用一次 restart():
| 时刻 | 发生的事情 | 局部版本 / 当前版本 | 对外层 Promise 的影响 |
|---|---|---|---|
| 1 | 创建任务,第一轮开始 | 1 / 1 | 等待结果 |
| 2 | 调用 restart(),第二轮开始 | 2 / 2 | 继续等待 |
| 3 | 第一轮成功或失败 | 1 / 2 | 版本不匹配,忽略 |
| 4 | 第二轮成功 | 2 / 2 | 以第二轮结果完成 |
即使第二轮先完成,第一轮随后到达,也无法覆盖它的结果。若第二轮失败,外层 Promise 会失败,不会回头采用第一轮的成功结果。
使用示例
let calls = 0
const { promise, restart } = createRestartableTask(() => {
const round = ++calls
const delay = round === 1 ? 100 : 20
return new Promise(resolve => {
setTimeout(() => resolve(`第 ${round} 轮结果`), delay)
})
})
promise.then(console.log).catch(console.error)
restart()
// 第二轮先完成:输出「第 2 轮结果」
// 第一轮仍会完成,但它的结果不会交给调用方。每一轮都需要执行一次新的操作,所以传入的是 task 函数。若函数每次只是返回同一个已经创建的 Promise,并不会重新发起底层操作。
代码里各自负责什么
restartCount:记录调用restart()的次数,首次执行不计入。version:记录最近启动的轮次,所有轮次共享这个变量。currentVersion:某一次run()独有的版本快照,不会随新轮次改变。resolveOuter/rejectOuter:让内部不同轮次能够尝试结束同一个外层 Promise。try / catch:同时处理task()同步抛错和返回的 Promise 拒绝;旧轮次的失败同样会被捕获并忽略。
run() 虽然是异步函数,调用它时仍会先递增版本号并调用 task(),遇到 await 后才等待结果。restart() 不返回这一轮的 Promise,调用方继续等待最初返回的 promise。
超过重启上限会怎样
默认最多允许重启一次。如果外层 Promise 仍在等待,第二次调用 restart() 会用 Error('Restart limit') 将其拒绝,并且不会启动第三轮。
此时已启动的任务依然运行。即使最新一轮随后调用了 resolveOuter,也不能改变外层 Promise 已经拒绝的结果。
maxRestarts = 0 时仍会执行第一轮,但不允许额外重启。
需要记住的边界
屏蔽结果不等于取消任务
版本号只能控制哪个结果被接收。旧请求、计时器或其他操作仍可能继续,已经发生的副作用也不会撤销。如果需要真正取消,必须让底层任务配合,例如接收并响应 AbortSignal。
Promise 已结束后,不能重新变成等待状态
外层只有一个 Promise,一旦成功或失败,结果就固定了。
原实现没有结束状态检查。因此,如果还没用完重启次数,在外层 Promise 结束后调用 restart(),仍会执行新的 task();只是新结果无法再通过同一个 Promise 交给调用方。若此时已经超限,再调用 rejectOuter 也不会改变已有结果。
如果希望结束后不再启动任务,可以增加 settled 标记:在接受最新结果、接受最新错误或超限时置为 true,并在 restart() 入口直接返回。这里没有把这项改动混入参考源码。
重启可能让多轮同时运行
新一轮不会等待旧一轮结束,版本号也不是并发限制器。这里约束的是最多启动多少轮,不是同时运行多少个任务。
和 retry 有什么区别
| 对比 | 当前实现 | 失败重试 retry |
|---|---|---|
| 新一轮的触发 | 调用方主动调用 restart() | 当前轮失败后按策略触发 |
| 是否等待旧轮结束 | 不等待 | 通常等待失败后再重试 |
| 旧结果处理 | 通过版本号过滤 | 通常串行执行,没有旧轮次竞争 |
| 当前轮失败 | 外层 Promise 直接拒绝 | 可能继续下一轮 |
现有的可配置异步重试器可以用来对照这两种控制方式。
面试时怎么讲
我用一个外层 Promise 暴露最终结果,用递增版本号识别最新执行轮次。每次重启都会调用任务函数并记录新版本,旧轮次完成时因为版本不匹配而被忽略。它支持手动重启和次数上限,但不取消旧任务,也不能让已结束的 Promise 重新等待;如果不希望结束后继续执行任务,还需要显式维护结束状态。