TT Lab
はじめる
学ぶ 学習パス コース

遅くなったのは一つではなく全部だった

刻むのか移すのか、そしてそれぞれの代償

TT Labで続きを見る

一言でいうと

ループを長く握る計算の扱い方は2つしかありません。細かく刻んで、その合間に順番を譲るか、別のスレッドへ丸ごと移すかです。どちらもタダではなく、コストを測っておけば「どこに置くか」は好みではなく計算になります。

なぜ必要なのか

塞いでいるコードを見つけたからといって、問題が終わるわけではありません。その計算は依然として必要で、どこかで行わなければなりません。「ワーカーに送れ」というのはよくある助言ですが、そのまま従って元に戻すケースが多くあります。2つのことを見落とすからです。

1つは、メモリを共有しないことです。ワーカースレッドは、このリクエストのオブジェクトをそのまま見ることはできません。値をコピーして送り、コピーして受け取るだけなので、いま持っている接続やキャッシュに触る必要がある仕事は、そもそも移せません。大きなオブジェクトを渡すと、コピー自体が新たなコストになります。

もう1つは、立ち上げるのに時間がかかることです。ラボイメージのNode 22.11.0で空の仕事1つを測ると、ワーカーを1つ立ち上げて答えを受け取るまでに22msから31msかかりました。5msの計算を移すと5倍の損です。そのため実際のサービスは、ワーカーをあらかじめ何個か立ち上げておいて再利用します。その設計が必要になる理由が、この数字です。

刻む方法は逆の性質を持ちます。同じスレッドに残るので状態をそのまま見られ、コピーもありません。その代わり総時間が少し増え、さらに重要なことに、1つの断片の時間が遅延の下限になります。

どう動くのか

刻む方法は「分ける」ことが半分で、「譲る」ことが残りの半分です。ループを断片に分けるだけで間に何もしなければ、ループから見ると1回で回したのと同じです。譲るのは1行です。

for (const { from, to } of chunks(total, sliceSize)) {
  result = hashRange(from, to, result);          // 앞 조각의 값을 이어받는다
  await new Promise((resolve) => setImmediate(resolve));   // 여기서 차례를 넘긴다
}

setImmediateはチェックフェーズで呼ばれるので、この1行の間に、待機中だったタイマーと完了した入出力のコールバックが処理されます。Node 22.11.0で4000万回回る計算を1回で回すとループが290ms止まり、40個の断片に分けると8msに下がりました。総時間は1.03倍に増えました。譲るたびにループを1周させるコストです。

ここで設計のつまみが1つ出てきます。断片1つを回している間は誰も割り込めないので、目標の遅延を決めれば断片のサイズが決まります。 上の実測では断片1つが7.9msで、測った遅延の最大は8msでした。50ms以内に応答したいなら、断片1つが50msを超えないように設定すればよいのです。「適当に分ける」ではなく、計算で決められます。

移す方法は別の形です。worker_threadsのWorkerはファイルを1つ受け取り、新しいスレッドでそのモジュールを実行し、workerDataで値を渡し、postMessageで結果を受け取ります。計算がそちらで動いている間、こちらのループは完全に自由です。同じ4000万回の計算をワーカーに送ると、こちらの遅延の最大は9msでした。

const worker = new Worker(new URL("./hash-worker.mjs", import.meta.url),
                          { workerData: { from: 0, to: total } });
worker.once("message", (result) => { /* 복사되어 건너온 값 */ });

1つ、落とし穴があります。ワーカーは親プロセスの実行引数を引き継ぎます。そのため、node --input-type=module -e "..."で動かしたプログラムの中でファイルベースのワーカーを立ち上げると、ERR_INPUT_TYPE_NOT_ALLOWEDで落ちます。ワーカーを使うプログラムはファイルに置き、そのまま実行するほうが安全です。

現場での姿

判断ルールは思ったより単純にまとまります。1回あたり1ms以内なら、そのままにします。測るコストのほうが、直すコストより大きいからです。それより重くて、いまのリクエストの状態を見る必要があるなら、移せないので刻みます。状態に無関係な純粋な計算で、立ち上げコストを払うに値するほど重いなら、移します。「重ければ」の境界は立ち上げコストから決まるので、22msかかるマシンでは50ms付近が妥当で、別のマシンでは別の値になります。ルールは測定に付随するものであり、暗記する定数ではありません。

現場でよく見る失敗は、刻んだふりをしただけのコードです。ループをforからmapに変えたり、関数をasyncで宣言したりして刻んだと思い込むものですが、どちらもループに順番を返しません。async関数の中でも、awaitに出会うまでは同期のまま最後まで回ります。譲ったかどうかを確認する方法は1つです。計算が動いている間に、短いタイマーが何回鳴ったかを数えればよいのです。

次のラボですること

同じ計算を3つの方法で動かします。一度に、断片に分けて、そしてワーカースレッドで。3つとも答えが同じでなければならず(刻んでも答えは変わりません)、遅延の分布は大きく違わなければなりません。

そのあと、それぞれのコストを計算します。刻むことによる総時間の増加と断片1つの時間、ワーカーの立ち上げコスト。最後に、それらの数字をplace(job)というルールに固めて、次の人が同じ判断をできるようにします。