基礎●●●○○

イベントループ

Event Loop

1本のループがイベントを順に処理する並行モデル。少数スレッドで大量のIO待ちを捌く定石。

概要

イベントループ(event loop)は、1本のループがイベント(何かが起きたという通知)を一つずつ取り出して順に処理し続ける、並行処理のためのモデルです。レストランのホールを一人の店員が回している様子を思い浮かべてください。注文を取り、料理を運び、会計をこなす — けれど一つのテーブルの料理が焼き上がるのをその場で突っ立って待ったりはせず、待ちが発生する仕事は厨房に任せ、自分は「できたよ」の合図が来た順に次々とさばいていきます。この「待たずに、完了の合図が来たものから処理する」働き方が、イベントループの核心です。

このモデルが力を発揮するのは、並行と並列でいう「I/Oバウンド」な処理 — ネットワークやディスクの応答待ちが大半を占める処理です。Webサーバは、大量の接続それぞれについて「リクエストを受け、データベースやほかのサーバの応答を待ち、結果を返す」ことをしますが、その時間の多くは実は「待っているだけ」です。イベントループは、この待ち時間に別の仕事を進めることで、ごく少数のスレッドで膨大な数の接続を同時にさばきます。

nginx、Node.js、Redis といった、高い同時接続性能で知られるソフトウェアはいずれもこのモデルを土台にしています。「スレッドを増やして捌く」のではなく「一本のループで捌く」— 直感に反するこの発想が、なぜ生まれ、なぜ強いのかを見ていきます。

なぜ生まれたか

イベントループが登場する前の素朴なサーバは、接続が来るたびに新しいスレッド(あるいはプロセス)を一つ立てて、その接続専用に割り当てる「1接続1スレッド」モデルでした。考え方は単純で、少ない接続数なら十分に機能します。しかし接続数が増えると、このモデルは破綻します。スレッドはそれ自体がメモリ(スタック領域として1本あたり数百KB〜数MB)を消費し、OSが実行するスレッドを切り替えるスケジューラのコンテキストスイッチ(切り替え)にもCPU時間がかかるからです。しかもそれらのスレッドの大半は、I/Oの応答をただ待って眠っているだけ — 何もしていないのに資源だけを食い潰します。

この問題は「C10K問題」として知られるようになりました。1万(10K)の同時接続(Client)を1台のサーバで捌けるか、という1999年に提起された問いです。1接続1スレッドでは、1万本のスレッドがメモリと切り替えコストで先に音を上げてしまいます。

そこで生まれたのが発想の逆転です。「待つためにスレッドを一本占有する」のをやめ、「待つべきものはOSに登録だけして即座に手放し、完了の通知が来たものを1本のループで順に処理する」ことにしたのです。待っているスレッドがなくなれば、スレッド数は接続数と切り離され、数本のスレッドで数万の接続を抱えられます。これがイベントループの生まれた動機です。

詳細

ループの構造 — キューから取り出して、また待つ

イベントループの中身は、驚くほど単純な繰り返しです。「イベントキュー(処理待ちの出来事が並ぶ列)を見る → 何か入っていれば先頭を取り出して処理する → 処理が終わったらまたキューを見る」。これを延々と回し続けるだけです。キューが空なら、新しいイベントが届くまで静かに待機(ブロック)します。ポイントは、一つ一つの処理は短く、すぐ終わることを前提としている点です。各処理は「時間のかかる待ち」を自分では抱え込まず、待ちが必要なら後述のOSに預けて即座にループへ制御を返します。

ノンブロッキングIOとOSの完了通知

この仕組みを支えているのが、ノンブロッキングI/Oです。通常の(ブロッキングな)読み込みは「データが届くまでその場で待つ」ため、呼んだスレッドが眠ってしまいます。ノンブロッキングI/Oでは、読み込みを要求しても「まだ来てないよ」とすぐ返ってくるので、スレッドは待たずに次へ進めます。とはいえ、無数の接続を一つずつ「もう来た?」と問い合わせて回るのは非効率です。そこでOSが用意したのが、「たくさんの接続をまとめて見張っておき、どれかに動きがあったらまとめて教える」多重化の仕組みで、Linuxの epoll、BSD系の kqueue がその代表です。

この「イベントが起きたらまとめて知らせる」という考え方は、ハードウェアの割り込み — CPUが周辺機器の完了を待ち続ける代わりに、完了したら機器側からCPUに合図を送る仕組み — の系譜を、ソフトウェアの世界で受け継いだものと言えます。ポーリング(こちらから繰り返し問い合わせる)ではなく、完了イベントの通知を受けて動く。この転換が、少ないスレッドで大量の待ちをさばく効率の源です。

スレッドプールモデルとの対比

素朴なスレッドモデルとイベントループの違いを、一枚の図で対比してみましょう。

1接続1スレッド接続ごとにスレッドが眠って待つスレッド1IO待ちで休眠スレッド2IO待ちで休眠スレッド3IO待ちで休眠スレッド4IO待ちで休眠接続の数だけスレッドが増え、メモリと切り替えコストが破綻イベントループ待ちはOSに預け、完了を1本で処理イベントキュー取り出すイベントループ(1本)取り出して処理し、また次へ数本のスレッドで数万接続をさばく
1接続1スレッドは待ちのために多数のスレッドが眠るが、イベントループは1本のループと1つのキューで待ちをさばく

左では、接続の数だけスレッドが増え、その大半が「眠って待つ」だけで資源を占有します。右では、待ちはすべてOSに預けられ、完了したイベントがキューに並び、たった1本のループがそれを順にさばきます。スレッドの数は接続の数から切り離されます。

JavaScriptのイベントループ

もっとも身近なイベントループは、おそらくJavaScriptのものです。JavaScriptはコードを実行する場所(コールスタック)が1本しかないシングルスレッドの言語で、setTimeoutfetch のような時間のかかる処理は、ブラウザや Node.js の裏方に委ねられます。裏方の仕事が完了するとコールバック(結果を受け取る関数)がタスクキューに積まれ、イベントループが「コールスタックが空になった」タイミングでキューの先頭から取り出して実行します。

ここから導かれる鉄則が「ループを止めるな」です。コールスタックは1本しかないので、そこで重い処理を延々と走らせると、その間キューのイベントは一つも処理されず、画面のクリックにも次のリクエストにも反応できなくなります。次のシミュレータで、コードが書いた順ではなく、待ちの完了順に実行される様子を一手ずつ追ってみてください。0ミリ秒のタイマーですら、スクリプト本体が終わるまで後回しにされることが体感できます。

⚡ 体験: イベントループをステップ実行するステップ 0 / 8
1console.log("A");
2setTimeout(() => console.log("B"), 0);
3fetch("/data").then(() => console.log("C"));
4console.log("D");
コールスタック
(空)
Web API(裏方)
(何もしていない)
タスクキュー
(空)
コンソール出力(まだ何も出力されていません)

「次へ」を押すと、このコードが1ステップずつどう実行されるかを追えます。

シングルスレッドの強みと弱点

イベントループが1本のスレッドで回ることには、思わぬ恩恵があります。同じデータに複数のスレッドが同時に触れる状況が原理的に生じないため、排他制御(ロック)や競合状態の悩みがほとんど消えるのです。マルチスレッドで頭を悩ませるデッドロックやロストアップデートと無縁でいられるのは、大きな設計上の単純さです。

その裏返しが弱点です。CPUバウンドな処理 — 重い計算そのものが時間を食う処理 — をループ上で走らせると、その間ループ全体が停止し、抱えているすべての接続が固まります。イベントループは「待ちが多い仕事」には無類の強さを見せますが、「計算が重い仕事」は苦手で、そうした処理は別スレッド(Node.js のワーカースレッドなど)や別プロセスに逃がす必要があります。得意分野と不得意分野がはっきりしているモデルだと理解しておくのが大切です。

実例 — nginx・Node.js・Redis

このモデルの威力は、実在のソフトウェアが証明しています。サーバとしての nginx は、イベント駆動アーキテクチャによって、プロセスをプロセスごとに増やす旧来の Apache の方式に比べ、はるかに少ない資源で大量の同時接続をさばくことで広まりました。Node.js は、このモデルをサーバサイドのアプリ開発に持ち込み、I/O主体のWebアプリを少ないスレッドで効率よく動かせるようにしました。インメモリデータストアの Redis も、基本的にシングルスレッドのイベントループで動作し、そのおかげでロック不要のシンプルさと高速さを両立しています。いずれも「待ちの多い仕事を、少数のループで捌く」という一点で共通しており、イベントループが現代のインフラを支える定石であることを物語っています。