created_at
updated_at
tags
toc
headings

AbortSignal.any(), AbortSignal.timeout(), そして addEventListener() の Signal

Intro

最近 AbortSignal.any() が提案され、急速に実装が進んでいる。

すでに定義されている AbortSignal.timeout()addEventListener() への Signal なども含め、非同期処理の中断を実装する際の API はかなり整備されてきた。

これら API のモチベーションと設計を中心にまとめる。

Abort 後のリソース解放

AbortSignal によって、非同期処理のキャンセルが可能になった。例として、 Server 上での Fetch のタイムアウトの例を考えよう。

app.get("/entries", async (req, res) => {
  const perRequestController = new AbortController()
  const perRequestSignal = perRequestController.signal

  // 1s でタイムアウト
  const timeoutId = setTimeout(() => {
    perRequestController.abort()
  }, 1000)

  const entries = await fetch("https://api.example/entries", { signal: perRequestSignal })
  clearTimeout(timeoutId)

  /* ~~~~~ */

  res.send(/*response*/)
})

ここで perRequestController はあくまで Request のハンドラに閉じているため、 Response を返したら全て消える。

次に、この Server プロセスが SIGINT 時に、連動して実行中の Fetch を止めたい場合を考えてみよう。

const rootController = new AbortController()
const rootSignal = rootController.signal

process.on("SIGINT", () => {
  rootController.abort()
})

app.get("/entries", async (req, res) => {
  const perRequestController = new AbortController()
  const perRequestSignal = perRequestController.signal

  // 1s でタイムアウト
  const timeoutId = setTimeout(() => {
    perRequestController.abort()
  }, 1000)

  // SIGINT と連動
  rootSignal.addEventListener("abort", () => {
    perRequestController.abort()
  })

  const entries = await fetch("https://api.example/entries", { signal: perRequestSignal })
  clearTimeout(timeoutId)

  /* ~~~~~ */
  res.send(/*response*/)
})

さて、この実装には問題がある。

Request の処理が終わっても rootController のハンドラはクリーンアップされないため、このコードでは Request ごとにハンドラが追加され続けることになる。そこに perRequestController の参照も残り続けるため、メモリーリークが発生する。

rootController.once() にしても SIGINT が発火しない限り残るので意味はない。正しくは Response が正常に返された後に rootController (と、本来なら setTimeout) のハンドラを削除する必要がある。

しかし、このミスは非常に頻繁に発生し、特に AbortSignal を連携する場面では、親が子の参照を保持することによるメモリーリークは珍しいことではないようだ。

実際、 Node.js でもtimer の中でこの問題が発生しており、これを修正すると同時に、このようなバグを防ぐために maxListeners というスレッショルドを実装するという Issue が、 Microsoft の Benjamin によって立てられた。

AbortController.prototype.follow(signal)

スレッショルドを実装したところで、根本的な問題は解決しない。そこで、標準の API でこの問題を解決するための方法へと議論が発展した。

理想的には、以下のようなコードで Abort を連携することで、メモリーリークを防ぐことができるとされる。

Issue で提示されたコードを、先ほどのサンプルに合わせて書くと以下のようになる。

function follow(perRequestController, rootSignal) {
  // 1. 子がすでに Abort していた場合はリターン
  if (perRequestController.signal.aborted) {
    return
  }

  // 2. 親がすでに Abort していた場合は子を Abort してリターン
  if (rootSignal.aborted) {
    return perRequestController.abort()
  }

  // 3. 親と子の連携
  // 3.1. remove 用にハンドラの参照を残す
  const onAbort = () => {
    perRequestController.abort()
  }

  // 3.2. 親が Abort したら子も Abort
  // once にすることで、Abort 時は自動でハンドラを削除
  rootSignal.addEventListener("abort", onAbort, { once: true })

  // 3.3. 子が Abort したら親からハンドラを削除
  perRequestController.signal.addEventListener("abort", () => {
    rootSignal.removeEventListener("abort", onAbort)
  })
}

書いてみればそのままだが、徹底するのは難しいタイプのコードだ。 API として提供する価値はあるだろう。

このころは、 AbortController.prototype.follow(signal) という名前が付けられていた。

AbortSignal in addEventListener

この議論と同じ頃、並行して addEventListenerAbortSignal を渡せるようにする提案が、同じく Benjamin から上がった。

読んだ通り AbortSignalabort したら、 EventListener を削除するという提案だ。

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

eventTarget.addEventListener("foo", (e) => {
  // signal が abort したらこのハンドラは削除される
}, { signal })

この提案は有用と認められ、 Node で試しながら DOM にバックポートされた。

結果、 EventEmitter でも EventTarget でも使える API になり、 Node および全メジャーブラウザで実装されている。

これを使うと、先ほどの follow は以下のように書き直せる。

function follow(perRequestController, rootSignal) {
  // 1. 子がすでに Abort してたらリターン
  if (perRequestController.signal.aborted) {
    return
  }

  // 2. 親がすでに Abort していたら子を Abort してリターン
  if (rootSignal.aborted) {
    return perRequestController.abort()
  }

  // 3. 親と子の連携
  // 3.1. 親が Abort したら、子も Abort
  rootSignal.addEventListener("abort", () => {
    perRequestController.abort()
  }, {
    // once にすることで、 Abort 時は自動でハンドラを削除
    once: true
    // 子が Abort したら親からハンドラを削除
    signal: perRequestController.signal
  })
}

さて、これで良さそうだが、これも実は問題を半分しか解決してない。

このコードでは、親か子のどちらかが Abort する場合はクリーンアップできるが、全てがうまくいってしまった場合(最初の例で言えば、 Timeout も SIGINT もない場合)はクリーンアップされない。

正常処理時のクリーンアップは、 Signal だけをみても不可能なので、結局ユーザランドで気をつけて実装するしかない。もし follow() 側でやるなら、 rootSignal.addEventListener() が Weak な参照を持つでもない限り不可能なのだ。

そこで、「本当に必要なものは何か」を整理した結果、ユーザランドでは難しい「Signal の連結」を行う API の必要性が浮き彫りになった。

しかし、このあたりで一旦議論が止まり、並行して別の議論が進むことになる。

AbortSignal.timeout()

ところで、ずっと気になっているであろうタイムアウトの処理の方をもう少し見てみよう。

app.get("/entries", (req, res) => {
  const perRequestController = new AbortController()
  const perRequestSignal = perRequestController.signal

  // 1s でタイムアウト
  const timeoutId = setTimeout(() => {
    perRequestController.abort()
  }, 1000)

  const entries = await fetch("https://api.example/entries", { signal: perRequestSignal })
  clearTimeout(timeoutId)

  /* ~~~~~ */

  res.send(/*response*/)
})

この fetch() のタイムアウトは、かなり頻出処理でありながら、毎回書くのは非常に面倒だ。本来なら fetch(url, {timeout: 1000}) などと書きたいところで、そのような要望は定期的にあった。

しかし、 fetch() だけタイムアウトできても汎用的にはならないため(というか、それもあって fetch() 策定中に AbortSignal が生まれた)、より汎用的なのはタイムアウト用の AbortSignal を生成することだ。

そこで提案されたのが AbortSignal.timeout() だ。

これを使うと、以下のようにかなりすっきりと実装できる。

app.get("/entries", (req, res) => {
  // 1s でタイムアウト
  const timeoutSignal = AbortSignal.timeout(1000)

  const entries = await fetch("https://api.example/entries", { signal: timeoutSignal })

  /* ~~~~~ */

  res.send(/*response*/)
})

さて、これを踏まえて先ほどの SIGINT との連携を見てみよう。

// SIGINT と連動
rootController.on("abort", () => {
  perRequestController.abort()
})

直接 timeoutSignal を作っているため、perRequestController 相当のものがなくなっている。これでは、 SIGINT とタイムアウトが連携できない。

実は、この AbortSignal.timeout() の策定の時点で、前述の「Signal の連結」を行う API の構想が進みつつあったのだ。

AbortSginal.any()

結局必要なのは、「Signal の連結」を行う API だったが、その API をどうデザインするかの段階で多少議論が止まっていた。

それが今年になって急速に進み、提案されたのが AbortSignal.any() だ。

これは、 Signal の配列を渡すと、連結された Signal が返る API であるため、先のサンプルは以下のように書き換えられる。

const rootController = new AbortController()
const rootSignal = rootController.signal

process.on("SIGINT", () => {
  rootController.abort()
})


app.get("/entries", (req, res) => {
  const perRequestController = new AbortController()
  const perRequestSignal = perRequestController.signal

  // 1s でタイムアウト
  setTimeout(() => {
    perRequestController.abort()
  }, 1000)

  // SIGINT と連結した Signal を生成
  const combinedSignal = AbortSignal.any([ rootSignal, perRequestSignal ])

  const entries = await fetch("https://api.example/entries", { signal: combinedSignal })

  /* ~~~~~ */

  res.send(/*response*/)
})

combinedSignal は、SIGINT とタイムアウトどちらが発生しても fetch() を Abort できる。

しかし、 Request のハンドラから rootController の参照が消え、ハンドラのクリーンアップについて気にする必要がなくなった。

そして、これを AbortSignal.timeout() にするとこうなる。

const rootController = new AbortController()
const rootSignal = rootController.signal

process.on("SIGINT", () => {
  rootController.abort()
})


app.get("/entries", (req, res) => {
  // 1s でタイムアウト
  const timeoutSignal = AbortSignal.timeout(1000)

  // SIGINT と連結した Signal を生成
  const combinedSignal = AbortSignal.any([ rootSignal, timeoutSignal ])

  const entries = await fetch("https://api.example/entries", { signal: combinedSignal })

  /* ~~~~~ */

  res.send(/*response*/)
})

perRequestController も消えていることがわかる。

Status

Chrome は M116 で Ship のアナウンスが出ている。

Safari も実装済。

Firefox は Positive だが実装はまだのようだ。

Abort Handling Practice

今回紹介しただけでも、かなり重要な API がいくつか追加されていることがわかる。

あと、今回は解説しなかったが AbortSignal.throwIfAborted() もある。

  • AbortSignal in addEventListener
  • AbortSignal.timeout()
  • AbortSignal.any()
  • AbortSignal.throwIfAborted()

まず基本的な使い方として、 Signal を安全に連結する方法が手に入ったため、 AbortSignal.timeout() のように、AbortSignal を返す API を実装するのは、非常に理にかなったものになる。例えば、先ほどの SIGINT の処理を、以下のように提供するイメージだ。

function processSIGINT() {
  const controller = new AbortController()
  const signal = controller.signal

  process.once("SIGINT", () => {
    controller.abort()
  }, { signal })

  return signal
}

また、フロントエンドで「タイムアウトかユーザのキャンセル操作で止めたい」といった場面は、以下のような書き方ができる。

function cancelSignal($button, msec) {
  const timeoutSignal = AbortSignal.timeout(msec)

  const controller = new AbortController()
  const userSignal = controller.signal

  // タイムアウトかボタンクリックでキャンセル
  const combinedSignal = AbortSignal.any([ timoutSignal, userSignal ])

  $button.addEventListener("click", () => {
    controller.abort()
  }, { signal: combinedSignal }) // どっちでもリスナーを消す

  return combinedSignal;
}

async function main() {
  const $button = $(".button")
  const signal = cancelSignal($button, 1000)

  const res = await fetch(url, { signal })
  // ...
}

例外処理

本来は AbortSignal.Timeout()AbortError ではなく TimeoutError になることを踏まえた、 fetch() 中断時の例外処理周りの話もしようと思ったが、 Chrome と Safari が仕様に反して AbortError を上げるバグがあるため、それについては今回割愛する。

Outro

AbortSignal 周りはかなり様々な API が急速に整備されつつある。一方で、まだユーザランドではノウハウの共有が進んでいないようにも思う。

メモリーリークしない適切な実装のためにも、こうした API をうまく取り入れていけると良いだろう。

DEMO

動作するデモを以下に用意した。

Resources