【翻译】你无法取消 JavaScript 的 Promise(但有时其实可以)

3 阅读7分钟

你无法取消 JavaScript 的 Promise(但有时其实可以)


Inngest 博文头图:You can't cancel a JavaScript promise

你无法取消 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 要做的事是:

  1. 找到下一个尚未执行的 step.run
  2. 执行它;
  3. 把结果 memo 掉;
  4. 打断(挂起);
  5. 重复,直到工作流跑完。

下面是一份可跑的完整脚本:

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 注册 fetchprocess;Run 2 再各注册一遍。五个 Promise(包括那些永远挂着的)最终都会被回收。

隐患(The catch)

你在依赖垃圾回收,而 GC 的触发时机是非确定的:你得不到「挂起的函数何时被回收」的保证。对我们的场景来说可以接受:我们只需要知道它最终会被回收,现代引擎在这方面通常很可靠。

真正的「雷」是引用链:只要有任何东西还握着那个挂起 Promise、或挂起函数的闭包,GC 就动不了它们。这个模式只有在你有意切断所有引用时才成立。

结语

故意造「永不 resolve」的 Promise 听起来像异端,但它是一种合法的控制流工具。我们在生产环境的 Inngest TypeScript SDK 里就用它打断工作流函数、memo 各步结果,并在多次 serverless 调用之间恢复——同时让用户继续写普通的 async/await

生成器给你干净的打断,但要强迫用户换一套语法;抛错让你保留 async/await,但 try/catch 会拆台。永不 resolve 的 Promise 则两边都占:原生语法 + 可靠的打断。有时候,让函数停下来的最好办法,就是让它没什么可等的。


Inngest