你无法取消 JavaScript 的 Promise(但有时其实可以)
- 原文:You can't cancel a JavaScript promise (except sometimes you can)
- 作者:Aaron Harper
- 原文发布:2026 年 4 月 7 日
你无法取消 JavaScript 里的 Promise。没有 .cancel() 方法、没有与 AbortController 的内建整合,也没有一种内建方式可以说「算了,别做了」。TC39 委员会曾于 2016 年审议是否为 Promise 增加取消能力,但该提案在激烈辩论后被撤回。问题的一部分在于:在任意代码执行到一半时强行取消,容易把资源留在脏状态(未关闭的句柄、写了一半的数据);因此真正的取消往往需要协作式清理,而这又会削弱人们指望从单个 .cancel() 方法里得到的那种简单性。
但你可以干一件更怪的事:返回一个永远不 resolve 的 Promise,对它 await,然后让垃圾回收收拾被挂起的函数。不抛异常、不靠 try/catch、也不需要特殊返回值——函数就是停在那儿了。
这正是 Inngest TypeScript SDK 用来打断异步工作流函数的方式。不过这个技巧本身是通用的,背后的 JavaScript 语义也值得单独搞明白。
为什么要在精确位置打断函数
有时你需要在别人写的 async 函数恰好某一行停住,而不要求对方代码做任何特殊配合:作者照常写 async/await,由你的运行时决定何时、何处打断。
我们遇到的具体场景是:在 serverless 上跑工作流,每次调用都有硬超时。一个工作流端到端可能有几十步、跑上几小时,但单次调用只能跑几秒到几分钟。运行时(在我们这儿就是 SDK 自己)必须能打断函数、保存进度、稍后再调一次从断点续跑——而且用户业务代码最好完全无感。
这就要求:不打 throw,也能打断某个 await。
用抛错来打断
实现「打断」时,第一反应往往是抛异常。想象一个 run:执行回调,然后抛一个特殊错误,阻止调用方继续往下走:
class InterruptError extends Error {}
async function run(callback) {
const result = await callback();
// Save the result somewhere, then interrupt
throw new InterruptError();
}
async function myWorkflow() {
const data = await run(() => fetchData());
// If run() throws, we never get here
await run(() => processData(data));
}
只要有人给这段代码包上 try/catch,事情就坏了:
async function myWorkflow() {
let data;
try {
data = await run(() => fetchData());
} catch {
console.log("Failed to fetch data, using default");
data = defaultData;
}
// This runs even when we wanted to interrupt,
// because the catch block swallowed InterruptError
await run(() => processData(data));
}
开发者本意只是:fetchData() 失败时用默认值。但因为 run 靠抛错来打断,catch 把打断信号也吞了。结果不但没打断,函数还落到 defaultData 上,继续跑不该跑的代码。用户代码里每一处 try/catch 都可能悄悄拆掉你的控制流。
用生成器来打断
生成器天生适合「可暂停、可恢复」:每次 yield 都会停住,是否再 .next() 完全由调用方决定。要打断?别再调 .next() 就行:
function* myWorkflow() {
let data;
try {
data = yield run(async () => fetchData());
} catch {
console.log("Failed to fetch data, using default");
data = defaultData;
}
yield run(async () => processData(data));
}
调用方用 .next() 驱动生成器。要打断就停手:
const gen = myWorkflow();
// Runs until the first yield
const first = gen.next();
// To interrupt: don't call gen.next() again.
// The catch block never runs. The generator is frozen mid-yield.
没有异常、也没有「误吞打断」。调用方掌控一切,因为 yield 本来就会把执行权交回去。
事实上,在 async/await 出现之前,生成器就是写「看起来像异步」代码的主流方式。像 co 这样的库会驱动 generator,把每个 yield 出去的 Promise resolve 掉,再通过 .next(value) 把结果喂回去。ES2017 引入 async/await 后,这套模式有了专用语法,但也换掉了「由调用方精细控制何时继续」这件事。
生成器的主要代价是人体工程学:用户要写 function* 而不是 async function,要写 yield 而不是 await。Effect 等库让生成器又火了一些,但对绝大多数 JS 开发者来说,这仍是偏冷门的写法。
生成器在并发上也不顺手。用 async/await,并行很自然:
const results = await Promise.all([
run(async () => fetchA()),
run(async () => fetchB()),
run(async () => fetchC()),
]);
但 yield 按定义是顺序的:每次 yield 都会停住并把控制权交回调用方,你没法「同时 yield 多个值」。只能 yield 一整组 Promise,再让 runner 识别这种约定并替你 Promise.all——等于在生成器之上再发明一层协议,用户要学新规矩,而不是直接用语言自带的并发原语。
那么问题来了:能不能既要生成器式的「可打断」,又让用户写普通的 async/await?
诀窍:永不 resolve 的 Promise
别抛错,你可以返回一个永远不 resolve 的 Promise。跑一下这段:
const start = Date.now();
process.on("exit", () => {
const elapsed = Math.round((Date.now() - start) / 1000);
console.log(`Exited after ${elapsed}s`);
});
async function interrupt() {
return new Promise(() => {});
}
async function main() {
console.log("Before interrupt");
await interrupt();
// Unreachable
console.log("After interrupt");
}
main();
你会看到类似输出:
Before interrupt
Exited after 0s
注意 After interrupt 不会打印。一旦碰到「打断」,进程可以干净退出、没有错误。很多人以为:Promise 永远不 resolve,程序就该一直挂住——在 Node 里未必如此。
原因在于:单靠 Promise 并不会撑住 Node 的事件循环。事件循环要的是活跃句柄:定时器、socket、I/O 监听等。一个未 settled 的 Promise 只是堆里的一个对象;若没有其他事可做,Node 会认为事件循环空了,于是直接退出。
要证明 Promise 真的是「挂在那儿」而不是「没来得及 resolve 进程就溜了」,可以加一个定时器把事件循环吊住:
async function main() {
setTimeout(() => {}, 2000);
console.log("Before interrupt");
await interrupt();
// Unreachable
console.log("After interrupt");
}
输出会变成:
Before interrupt
Exited after 2s
这次进程会跑满约 2 秒——setTimeout 让事件循环一直有活干。
串起来:按步执行、可恢复
「干净退出」本身没业务价值;我们需要的是:多次进入同一个函数,每步之后打断,下次调用再从断点继续。这就需要 memo:某步已经跑过,就直接返回缓存结果,别重跑。
从写工作流的人视角,大致是这样(简化版,接近 Inngest SDK 内部思路):
async function myWorkflow(step) {
console.log(" Workflow: top");
const data = await step.run("fetch", () => {
console.log(" Step: fetch");
return [1, 2, 3];
});
const processed = await step.run("process", () => {
console.log(" Step: process");
return data.map((n) => n * 2);
});
console.log(" Workflow: complete", processed);
}
运行时的职责是:反复调用 myWorkflow,每次调用只真正执行一个新 step:
async function main() {
// In-memory store of completed step results
const stepState = new Map();
// Keep entering the workflow function until it's done
let done = false;
let i = 0;
while (!done) {
console.log(`Run ${i}:`);
done = await execute(myWorkflow, stepState);
console.log("--------------------------------");
i++;
}
}
若 execute 写对了,你会看到类似:
Run 0:
Workflow: top
Step: fetch
--------------------------------
Run 1:
Workflow: top
Step: process
--------------------------------
Run 2:
Workflow: top
Workflow: complete [ 2, 4, 6 ]
--------------------------------
注意发生了什么:
Workflow: top打印了 3 次——每次调用都从函数顶部重新进入。- 每个
Step日志只打印 1 次——已 memo 的 step 会立刻返回;只有新 step 才真正执行。
因此 execute 要做的事是:
- 找到下一个尚未执行的
step.run; - 执行它;
- 把结果 memo 掉;
- 打断(挂起);
- 重复,直到工作流跑完。
下面是一份可跑的完整脚本:
async function execute(fn, stepState) {
let newStep = null;
// Run the user function in the background. It will hang at the new step
fn({
run: async (id, callback) => {
// If this step already ran, return the memoized result
if (stepState.has(id)) {
return stepState.get(id);
}
// This is a new step. Report it
newStep = { id, callback };
// Hang forever
return new Promise(() => {});
},
});
// Schedule a macrotask. All pending microtasks (the resolved awaits from
// memoized steps) will drain before this runs, giving the workflow function
// time to advance through already-completed steps and reach the next new one.
await new Promise((r) => setTimeout(r, 0));
if (newStep) {
// A new step was found. Execute it and save the result
const result = await newStep.callback();
stepState.set(newStep.id, result);
// Function is not done
return false;
}
// Function is done
return true;
}
// User-defined workflow function
async function myWorkflow(step) {
console.log(" Workflow: top");
const data = await step.run("fetch", () => {
console.log(" Step: fetch");
return [1, 2, 3];
});
const processed = await step.run("process", () => {
console.log(" Step: process");
return data.map((n) => n * 2);
});
console.log(" Workflow: complete", processed);
}
async function main() {
// In-memory store of completed step results
const stepState = new Map();
// Keep entering the workflow function until it's done
let done = false;
let i = 0;
while (!done) {
console.log(`Run ${i}:`);
done = await execute(myWorkflow, stepState);
console.log("--------------------------------");
i++;
}
}
main();
为什么要用内存里的 step 状态?
真实的 Inngest SDK 里,stepState 会持久化到数据库,这样结果能跨多次独立调用存活。这里用内存 Map 只为演示简单。
为什么要 setTimeout(0)?
我们需要工作流函数先顺着所有已 memo 的 step 一路 await 下去,再判断「有没有碰到新 step」。当 step.run 返回 memo 结果时,对应的 await 会在微任务里 resolve;微任务会先于宏任务跑完,于是函数会在一个紧循环里连续推进,每个 resolve 的 await 再把下一个微任务排队。这条链会在碰到新 step(永不 resolve 的 Promise 不再排队后续微任务)或函数整体结束时停下。用 setTimeout 排一个宏任务,就能保证这些微任务先 drain。Inngest SDK 实作更聪明,但用宏任务足够讲清概念。若你想更系统地理解事件循环、微任务与宏任务,Philip Roberts 的演讲 What the heck is the event loop anyway? 仍然是极佳入门。
等等,这不会内存泄漏吗?
如果一直在造「永远挂起」的 Promise,难道不会泄漏吗?在长生命周期进程里,被遗弃的 Promise 似乎会堆积。
不会——前提是没有任何引用指向它们。
JavaScript 的垃圾回收器并不关心 Promise 有没有 settled;它关心的是是否仍被引用。你创建了一个 Promise、在函数里 await 它,随后整条调用栈都不可达,GC 就会清掉一切:那个 Promise、函数的挂起状态,统统可以回收。
为了证明这一点,可以用 FinalizationRegistry 观察对象被回收的时机。把下面这段加进脚本:
// Log when a registered object is garbage collected
const registry = new FinalizationRegistry((value) => {
console.log(" GC", value);
});
// User-defined workflow function
async function myWorkflow(step) {
console.log(" Workflow: top");
const fetchP = step.run("fetch", () => {
console.log(" Step: fetch");
return [1, 2, 3];
});
registry.register(fetchP, "fetch");
const data = await fetchP;
const processP = step.run("process", () => {
console.log(" Step: process");
return data.map((n) => n * 2);
});
registry.register(processP, "process");
const processed = await processP;
console.log(" Workflow: complete", processed);
}
async function main() {
// In-memory store of completed step results
const stepState = new Map();
// Keep entering the workflow function until it's done
let done = false;
let i = 0;
while (!done) {
console.log(`Run ${i}:`);
done = await execute(myWorkflow, stepState);
console.log("--------------------------------");
i++;
}
// Force garbage collection
globalThis.gc();
}
用 Node 的 --expose-gc 跑脚本,你会看到类似:
Run 0:
Workflow: top
Step: fetch
--------------------------------
Run 1:
Workflow: top
Step: process
--------------------------------
Run 2:
Workflow: top
Workflow: complete [ 2, 4, 6 ]
--------------------------------
GC process
GC fetch
GC fetch
GC fetch
GC process
GC fetch 出现三次、GC process 两次,是因为每次重入 myWorkflow 都会对新的 Promise 对象调用 registry.register——即便 step 已 memo,step.run 仍是 async,每次调用都会返回全新的 Promise。Run 0 注册一个 fetch;Run 1 注册 fetch 与 process;Run 2 再各注册一遍。五个 Promise(包括那些永远挂着的)最终都会被回收。
隐患(The catch)
你在依赖垃圾回收,而 GC 的触发时机是非确定的:你得不到「挂起的函数何时被回收」的保证。对我们的场景来说可以接受:我们只需要知道它最终会被回收,现代引擎在这方面通常很可靠。
真正的「雷」是引用链:只要有任何东西还握着那个挂起 Promise、或挂起函数的闭包,GC 就动不了它们。这个模式只有在你有意切断所有引用时才成立。
结语
故意造「永不 resolve」的 Promise 听起来像异端,但它是一种合法的控制流工具。我们在生产环境的 Inngest TypeScript SDK 里就用它打断工作流函数、memo 各步结果,并在多次 serverless 调用之间恢复——同时让用户继续写普通的 async/await。
生成器给你干净的打断,但要强迫用户换一套语法;抛错让你保留 async/await,但 try/catch 会拆台。永不 resolve 的 Promise 则两边都占:原生语法 + 可靠的打断。有时候,让函数停下来的最好办法,就是让它没什么可等的。