基礎●●●○○

イベントループ

Event Loop ・ いべんとるーぷ

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

✦ 30秒で試す — 航海店員1人のレストランが、1万人の客をさばける理由画面に表示される順番は?予想してみる

概要

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

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

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

なぜ生まれたか

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

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

そこで生まれたのが発想の逆転です。「待つためにスレッドを一本占有する」のをやめ、「待つべきものはOSに登録だけして即座に手放し、完了の通知が来たものを1本のループで順に処理する」ことにしたのです。待っているスレッドがなくなれば、スレッド数は接続数と切り離され、数本のスレッドで数万の接続を抱えられます。OS の側でも、Linux の epoll(2002年)や BSD の kqueue(2000年)といった「大量の接続をまとめて見張る」仕組みが整い、このモデルを現実のものにしました。

詳細

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

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

ノンブロッキングI/OとOSの完了通知

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

ネットワークOS epollイベントループネットワークOS epollイベントループ接続1〜1万を見張り対象に登録どれか準備できるまで待つ接続42にデータが届く接続7にデータが届く準備できたのは 42 と 742 のコールバックを実行7 のコールバックを実行また待つ

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

スレッドモデルとの対比

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

1接続1スレッド接続ごとにスレッドが眠って待つスレッド1I/O待ちで休眠スレッド2I/O待ちで休眠スレッド3I/O待ちで休眠スレッド4I/O待ちで休眠…接続の数だけ続く1万接続なら1万スレッド。スタックのメモリと切り替えのコストで先に破綻するイベントループ待ちはOSに預け、準備できたものだけを1本で処理OS(epoll / kqueue)1万の接続をまとめて見張る準備できた接続を通知イベントキュー1つずつ取り出すイベントループ(1本)短い処理を実行して、すぐ次へ新しい待ちはOSに登録するだけ(破線)数本のスレッドで数万の接続をさばく
1接続1スレッドは待ちのために多数のスレッドが眠る。イベントループは待ちをOSに預け、準備できた通知だけをキューで受け取って1本で処理する

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

JavaScriptのイベントループ — タスクとマイクロタスク

もっとも身近なイベントループは、おそらくJavaScriptのものです。JavaScript はコードを実行する場所であるコールスタック(呼び出し中の関数が積み重なる場所)が1本しかないシングルスレッドの言語で、setTimeout や fetch のような時間のかかる処理は、ブラウザや Node.js の裏方(Web API)に預けられます。裏方の仕事が終わると、コールバック(結果を受け取る関数)がキューに積まれ、コールスタックが空いたときにイベントループがそれを取り出して実行します。

ここで大事なのは、キューが2種類あることです。タイマーの満了、通信の応答、クリックなどは「タスク」としてタスクキューに並びます。一方、Promise の then や await の続きは「マイクロタスク」としてマイクロタスクキューに並びます。イベントループの規則は「タスクを1つ実行したら、次のタスクへ進む前に、マイクロタスクキューを空になるまで全部実行する」です。スクリプトの本体もそれ自体が1つのタスクなので、setTimeout(…, 0) より後に書いた Promise.resolve().then(…) のほうが先に動きます。fetch(…).then(…) の場合は、応答が届いたこと自体がタスクとして処理され、その中で Promise が解決して、then のコールバックがマイクロタスクに入ります。

ループの規則: スタックが空 → マイクロタスクを全部 → タスクを1つ → 画面を描く → くり返しコールスタック1本だけ。上に積み、上から降ろすsetTimeout(…)スクリプト全体裏方(Web API)タイマー・fetch・クリックの受付通信の待ちは OS が見張る時間のかかる仕事を預ける終わったらタスクキュータイマー・I/O の完了・クリックマイクロタスクキューPromise の then・await の続きイベントループ積むPromise.then のコールバックは裏方を通らず、直接マイクロタスクキューへ
JavaScriptの実行環境 — 1本のコールスタック、裏方のWeb API、タスクキューとマイクロタスクキュー。ループはマイクロタスクを優先して空にする

ここから導かれる鉄則が「ループを止めるな」です。コールスタックは1本しかないので、そこで重い処理を延々と走らせると、その間キューのイベントは一つも処理されず、画面の描き直しも、クリックへの反応も、次のリクエストの処理もできなくなります。

下の部品では、コードの行を並べ替えたり足したりしてから実行できます。まず出力の順番を予想し、それが書いた順ではなくキューの規則で決まることを確かめてください。重い計算を入れて実行し、その最中に「画面のボタンを押す」を押すと、スピナーが止まりクリックが待たされる様子も見られます。

⚡ 体験: コードを並べて実行し、出力の順番を決める規則を見る
  1. 1console.log('A')同期
  2. 2setTimeout(() => console.log('B'), 0)タスク
  3. 3Promise.resolve().then(() => console.log('C'))マイクロ
  4. 4console.log('D')同期
行を足す
コールスタック(1本だけ)空イベントループ① スタックが空になったら② マイクロタスクを全部③ タスクを1つブラウザ・OS が待つ(Web API)タイマー・通信(OS の epoll などが見張る)画面(UI)—マイクロタスクPromise← 先頭から取り出すタスクタイマー・I/O・クリック← 先頭から取り出す出力メインスレッド画面の更新時間 →(見やすいように引き延ばしています)
同期の処理マイクロタスク(then)タスク(タイマー)通信の応答重い計算・固まる
出力の順…押したクリック0 回待たされたクリック…

左のコードは上から順に1本のスタックで動きます。出力の順番を予想してから「実行」を押してください。行は ↑↓ で並べ替え、下のボタンで足せます。

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

イベントループが1本のスレッドで回ることには、思わぬ恩恵があります。同じデータに複数のスレッドが同時に触れる状況が原理的に生じないため、排他制御(ロック)や競合状態の悩みがほとんど消えるのです。マルチスレッドで頭を悩ませるデッドロックやロストアップデートと無縁でいられるのは、大きな設計上の単純さです(ただし await をまたぐと、その間に別のコールバックが割り込むので、「読んでから書く」処理の途中で状態が変わりうる点には注意が要ります)。

その裏返しが弱点です。CPUバウンドな処理 — 重い計算そのものが時間を食う処理 — をループ上で走らせると、その間ループ全体が停止し、抱えているすべての接続が固まります。イベントループは「待ちが多い仕事」には無類の強さを見せますが、「計算が重い仕事」は苦手で、そうした処理は別のスレッド(ブラウザの Web Worker、Node.js の worker_threads)や別のプロセスに逃がす必要があります。また Node.js では、ファイル操作や DNS の名前解決のように OS が多重化の仕組みを用意していない処理は、裏で小さなスレッドプール(libuv)に任せてからループに結果を返しています。得意分野と不得意分野がはっきりしているモデルだと理解しておくのが大切です。

実例 — nginx・Node.js・Redis

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

試問

JavaScript のコードの行を並べ、実行できる部品です。setTimeout(…, 100)(100 後に実行する予約)などの行を下のボタンで足し、↑↓で並べ替え、×で消せます。「実行」を押すと、1本しかないコールスタック(いま実行中の処理の置き場)で上から順に動き、予約した処理がいつ実行されるかが時間の帯に出ます。

✦ 命題挑戦中

setTimeout(…, 100) で仕掛けたタイマーを、仕掛けてから 300 以上たってから鳴らせ

  • setTimeout(…, 100) の行を入れる
  • 実行して、そのタイマーが仕掛けてから 300 以上たって鳴る(書いた時間の3倍以上)
⚡ 体験: 100 のタイマーを、3倍以上遅らせる
  1. 1console.log('A')同期
  2. 2setTimeout(() => console.log('B'), 0)タスク
  3. 3Promise.resolve().then(() => console.log('C'))マイクロ
  4. 4console.log('D')同期
行を足す
コールスタック(1本だけ)空イベントループ① スタックが空になったら② マイクロタスクを全部③ タスクを1つブラウザ・OS が待つ(Web API)タイマー・通信(OS の epoll などが見張る)画面(UI)—マイクロタスクPromise← 先頭から取り出すタスクタイマー・I/O・クリック← 先頭から取り出す出力メインスレッド画面の更新時間 →(見やすいように引き延ばしています)
同期の処理マイクロタスク(then)タスク(タイマー)通信の応答重い計算・固まる
出力の順…押したクリック0 回待たされたクリック…

左のコードは上から順に1本のスタックで動きます。出力の順番を予想してから「実行」を押してください。行は ↑↓ で並べ替え、下のボタンで足せます。