NodeJS 15.0.0 版本增加了一个很有意思的新特性 AbortController, 主要是用来撤销某些正在运行的 Promise.

AbortController 最早是浏览器支持的一个 API, 主要是用来终止 Web 请求. 扩展到 NodeJS 中就是用来撤销一些异步 IO 操作或者 timer, 提高资源利用率, 相比之下服务端是集中化的资源, 更需要此 API.

基本用法⌗

AbortController 是一个全局类, 可以直接使用.

const ac = new AbortController()

ac.signal.addEventListener('abort', () => console.log('Aborted!'), {
  once: true,
})

ac.abort()

console.log(ac.signal.aborted) // Prints true

abortController.signal 是一个 EventTarget 用来通知 abort 事件, 同时我们也可以使用 ac.signal.aborted 来判断一个 controller 是否已经取消, 调用 abortController.abort() 方法会触发 abort 事件, 并且 signal 状态也会切换.

在实际使用时, 都是将 signal 作为额外参数传入支持此功能的 API 方法中, 例如下面是一个撤销 timer 的例子:

// promise 版的 timer API, 支持 abortController
const timersPromises = require('timers/promises')

const ac = new AbortController()
const signal = ac.signal

const main = async () => {
  // 2 秒后撤销
  setTimeout(() => ac.abort(), 2000)
  try {
    console.time('test')
    // 开启一个 10s 的 timeout, 将 signal 传入
    await timersPromises.setTimeout(10000, true, { signal })
  } catch (err) {
    console.timeLog('test', err)
  } finally {
    console.timeEnd('test') // 退出时打印耗时
  }
}

main()
// ❯ node main.js
// test: 2.005s AbortError: The operation was aborted
//     at Timeout.cancelListenerHandler (node:timers/promises:30:12)
//     at EventTarget.[nodejs.internal.kHybridDispatch] (node:internal/event_target:459:20)
//     at EventTarget.dispatchEvent (node:internal/event_target:407:26)
//     at abortSignal (node:internal/abort_controller:81:10)
//     at AbortController.abort (node:internal/abort_controller:94:13)
//     at Timeout._onTimeout (/Users/cong/zcong/test/js-repl/ac.js:7:23)
//     at listOnTimeout (node:internal/timers:557:17)
//     at processTimers (node:internal/timers:500:7) {
//   code: 'ABORT_ERR'
// }
// test: 2.014s

上面代码会在 2s 时结束, timersPromises.setTimeout 会 throw 一个 AbortError: The operation was aborted 错误.

API 现状⌗

目前只有部分 API 支持此功能, 可以在 nodejs/node 官方仓库搜索此特性相关提交.

稍微列举一下:

  1. fs/fsPromises 部分 API
  2. timers/promises
  3. http/http2 client
  4. child_process
  5. stream
  6. dgram

实现原理⌗

AbortController 源码可以看 abort_controller.js, 也可以看这个实现 mysticatea/abort-controller. 实现非常简单而且上面也大概提到了, 所以这里不在赘述. 着重介绍标准库如何集成支持 AbortController.

1. timersPromises.setTimeout⌗

function setTimeout(after, value, options = {}) {
  const { signal, ref = true } = options
  // 1. 校验 signal 合法性
  try {
    validateAbortSignal(signal, 'options.signal')
  } catch (err) {
    return PromiseReject(err)
  }
  // 2. 如果 signal 已经撤销, 直接 throw
  if (signal?.aborted) {
    return PromiseReject(new AbortError())
  }
  let oncancel
  const ret = new Promise((resolve, reject) => {
    // 3. 执行真正逻辑
    const timeout = new Timeout(resolve, after, args, false, true)
    if (!ref) timeout.unref()
    insert(timeout, timeout._idleTimeout)
    // 4. 如果有 signal, 将 clearTimeout 绑定在 abort 事件上
    if (signal) {
      oncancel = FunctionPrototypeBind(
        cancelListenerHandler,
        timeout,
        clearTimeout,
        reject
      )
      signal.addEventListener('abort', oncancel)
    }
  })

  // 5. 如果有 signal, 清理 EventListener
  return oncancel !== undefined
    ? PromisePrototypeFinally(ret, () =>
        signal.removeEventListener('abort', oncancel)
      )
    : ret
}

上面代码删减了部分不相关代码, 总结下来实现需要 5 步:

  1. 校验 signal 是否合法
  2. 执行操作前, 检查 signal 是否已经是 aborted 状态, 如果是直接 throw AbortError
  3. 执行真正操作(异步)
  4. 将终止方法绑定在 abort 事件上面, abort 触发时也会 throw AbortError
  5. 清理 EventListener

2. fsPromises.readFile⌗

async function readFileHandle(filehandle, options) {
  const signal = options?.signal

  // 1. 如果 signal 已经撤销, 直接 throw
  if (signal?.aborted) {
    throw lazyDOMException('The operation was aborted', 'AbortError')
  }
  const statFields = await binding.fstat(filehandle.fd, false, kUsePromises)
  // 2. 再次检查 signal 状态
  if (signal?.aborted) {
    throw lazyDOMException('The operation was aborted', 'AbortError')
  }
  do {
    // 3. chunk read 前检查 signal 状态
    if (signal?.aborted) {
      throw lazyDOMException('The operation was aborted', 'AbortError')
    }
    const { bytesRead, buffer } = await read(filehandle, buf, 0, buf.length, -1)
  } while (!endOfFile)

  const result = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)

  return options.encoding ? result.toString(options.encoding) : result
}

上面代码删减了不相关代码, fsPromises.readFile 核心通过 readFileHandle 方法实现, 总结一下就是带缓冲的 read, 每次异步 read chunk 直到文件读完, 最后将内容返回. 因此更加简单, 总结下来只需要两步:

  1. 执行操作前, 检查 signal 是否已经是 aborted 状态, 如果是直接 throw AbortError
  2. 同步循环每次操作前检查 signal 是否已经是 aborted 状态

整个总结下来, 其实实现原理都和上面两种方式一样, 也就是: 异步监听事件, 同步循环在循环间检查状态.

对比 Go 语言⌗

熟悉 go 语言的朋友看到这个肯定会想到 context, context 的一个很大的作用就是在不同 Goroutine 之间同步取消信号来减少资源浪费. 本文所讲的 AbortController 相当于 context.WithCancel, 当然 go 语言的 context 功能更加复杂, 是一个树形结构, 但是在用法上思想其实是相通的.

// 同步在 for 循环间检查是否已取消
for {
  if ctx.Err() != nil {
    // 取消了
    return
  }
  // 逻辑代码
}

// 同步 2, 使用 select default
for {
  select {
    case <- ctx.Done():
      // 取消了
      return
    default:
      // 逻辑代码
  }
}

// 并发
select {
  case <- ctx.Done():
    // 取消了
    return
  case <- otherCh:
    // 逻辑代码
}

总结⌗

服务端资源是中心化, 所以我们在写代码时应该考虑资源利用情况, AbortController 填补了 NodeJS 在这方面的不足. 想象一下在实现网关时我们会有很多并发聚合 http 调用, 一个请求出错时就可以撤销其他的请求了.

对比 go 语言, context 已经非常成熟, 基本已经是 io 操作函数的第一个参数标配了, 期待 AbortController 在 NodeJS 社区广泛支持的这一天早点到来.