イベントループを止めるな
node.jsで動くサーバーが、何らかの重い処理を走らせている際、他のリクエストを受け付けなくなってしまうことがある(実際、大きなCSVファイルを処理している間、当該podの応答が止まってしまう事象が弊社製品であるHERP Hireのバックエンドで観測されていた)。
app.post("/load-csv", async (c) => {
// 重い処理
// このループが実行されている間、サーバーが応答しない!
for await (const row of streamHugeCSV(c)){
const obj = parseRow(row);
logger.info(`Processing ${obj.id}`)
processRow(obj);
}
return c.text("done");
}
これは重い処理がthe Event Loopをブロックしてしまうことによって発生する問題である。the Event Loopはnode.jsにおける非同期IOの中枢であり、これを回す余地を与えなければ、アプリケーションが応答しなくなってしまう。
for await
という字面からはあまり影響を受けなさそうに見えるが、async iteratorであったとしてもこの問題は発生する。
setImmediate()
を処理の中に挿入することで、イベントループに優先権を渡すことができ、この問題を回避できる。
import { setImmediate } from 'timers/promises';
await setImmediate();
// await new Promise<void>((resolve) => setImmediate(resolve)); と等価
for await (const row of streamHugeCSV()){
const obj = parseRow(row);
logger.info(`Processing ${obj.id}`)
processRow(obj);
// 処理を譲る
await setImmediate();
}
ちなみに、HaskellではControl.Concurrent.yield がsetImmediate()
に相当する。Haskell (GHC)の場合、ほとんど全ての入出力やメモリアロケーションのタイミングで優先権を譲る(Haskell製のプログラムはほぼ常時アロケーションしている)ので、明示的にyieldを呼び出す必要がある場面はほとんどない。
サンプルコード
イベントループをブロックすると他の処理が走らなくなること、そしてsetImmediateを挿入すると解決することを検証してみよう。
import { setImmediate } from "timers/promises";
async function* busystream(){
const t0 = performance.now();
// 3秒経過するまでループを回し続ける
for(let i=0;performance.now() - t0 < 3000;i++){
yield i;
// await setImmediate();
}
}
const t0 = performance.now();
setTimeout(() => console.log("took", Math.floor(performance.now() - t0)), 500);
for await (const i of busystream()){
}
このコードは3秒間forループを回し続けるため、setTimeoutが500msに設定されているにもかかわらずtook 3000が表示されるが、setImmediateを入れればtook 500になる。
Alternative Solution
Worker threadsを使って、CPU-intensiveな処理をより低いレイヤーで分離する方法もあるが、JavaScriptのソースコードを切り出す必要があるので取り回しが悪い。普段使いには適さず、音声処理など特殊な用途に限られるだろう。
まとめ
Webサーバーのようなアプリケーションにおいて長時間かかる処理をしたい場合、要所にsetImmediateを挿入し、他の処理と協働できるようにすることを忘れないようにしたい。
See also
PR
株式会社HERPでは、他のチームと協働しながら改善のループを回せる人材を募集しています。
Discussion