「待ち」を集めたあと、処理をどう進めるか

第7章では、epollやkqueueを使えば1スレッドで何万本もの接続を見張れることを見ました。 ただ、見張れることと、それぞれの接続の処理を進められることは別の話です。 リクエストを読み、データベースに問い合わせ、結果を整形して返すまでの途中で、処理は何度もI/Oを待ちます。 待っている間に「この接続はどこまで進んだか」をどこかに覚えておかなければなりません。

本章では、この「待っている間の状態をどこに置くか」という観点で5つの並行処理モデルを比べます。 プロセスとスレッドは状態をカーネルに持たせ、イベントループはアプリのコールバックやクロージャに、軽量スレッドは言語ランタイムに持たせます。 最後に事例とベンチマークの読み方を確認し、実際に選ぶときの判断の流れをまとめます。

5つのモデルを並べる

モデル接続あたりのメモリCPUバウンドへの強さ書きやすさ代表例
マルチプロセス大きい(Apache preforkで1プロセス1MB以上)強い。プロセス数ぶんコアを使える書きやすい。普通の同期コードで、プロセス同士は分離されているApache prefork、PHP-FPM、Pumaのクラスタモード
マルチスレッド中くらい(スタックは既定8MiBの仮想領域で、実メモリは触った分だけ)強い。スレッド数ぶん並列に動く同期コードで書けるが、共有メモリのロックと競合に注意が要るJavaのServletコンテナ、スレッドプール型のサーバー
イベントループ小さい(nginxのアイドル接続で約550バイト)弱い。1つの重い計算が全接続を止めるコールバックやPromiseで書く。待ちの間の状態を自分で持つnginx、Node.js、Redis
軽量スレッド小さい(goroutineの初期スタック2KB、Erlangプロセス約327ワード)ランタイムが複数のOSスレッドに割り振るので並列に動く同期コードの見た目のまま書けるGoのgoroutine、Erlang/Elixir、Java Virtual Threads
async/await小さい(タスクの状態を保存するオブジェクトのみ)ランタイムしだい。単一スレッドなら弱い同期に近い見た目。ただしasyncが呼び出し元へ伝染するPython asyncio、Rust tokio、JavaScriptのasync/await

表の「接続あたりのメモリ」の数値は、それぞれ公式資料や実測で確認できる代表値です。単位がばらばらなのは、モデルごとに何がメモリを使うかが違うためで、 同じ条件で横並びに測った値ではありません。以下、モデルごとに中身を見ていきます。

マルチプロセス — forkして分ける

一番古典的なのは、マスタープロセスがlistenソケットを開いたままfork()で複数のワーカーを作るやり方です。 第3章で見たとおり、forkした子プロセスは親のFDを引き継ぐので、すべてのワーカーが同じlistenソケットからaccept()できます。

graph TD
  M["マスタープロセス<br/>listenソケットを開く"] -->|fork| W1[ワーカー1]
  M -->|fork| W2[ワーカー2]
  M -->|fork| W3[ワーカー3]
  C1[接続A] --> W1
  C2[接続B] --> W2
  C3[接続C] --> W3
  W1 -.->|1接続ずつ同期処理| W1

  style M fill:#8b5cf6,stroke:#6d28d9,color:#fff
preforkモデル。ワーカープロセスがそれぞれ1接続ずつ同期的に処理する。1つが落ちても他は影響を受けない

利点は分離の強さです。1つのワーカーがメモリを壊したりクラッシュしたりしても、他のワーカーは影響を受けません。 PHP-FPMがいまも広く使われているのは、リクエストごとに状態が消えるPHPの実行モデルとこの分離がよく合うからです。 欠点はメモリで、1万接続を受けるには1万プロセスが必要になります。これが第7章で見たC10K問題でした。 RubyのPumaのクラスタモードのように、プロセスを数個に抑えて各プロセスの中でスレッドを使う混成型も一般的です。

マルチスレッド — アドレス空間を共有して並べる

スレッドは、第4章で見たclone()のCLONE_VMによってアドレス空間を共有する実行単位です。 プロセスより軽く、メモリ上のキャッシュや接続プールをスレッド同士で共有できます。 多くのJavaサーバーは、あらかじめ数百本のスレッドを作っておくスレッドプールにリクエストを割り当てます。

1接続1スレッドでもコードは同期的に書けますが、共有メモリを触る箇所にはロックが要り、競合やデッドロックを避ける設計が必要です。 スタックは既定で8MiBの仮想領域を確保しますが、実メモリは触ったページの分しか使いません。 1万スレッドでVSZが約80GiBになってもRSSは約80MiB程度に収まります。 それでもコンテキストスイッチ1回に約1.2〜2.2µsかかり、数万スレッドがS状態とR状態を行き来すると、スケジューラの負荷が無視できなくなります。

イベントループ — 1スレッドで順番に片づける

イベントループは、第7章のI/O多重化をそのままプログラムの構造にしたものです。 1本のスレッドがepoll_waitで待ち、準備ができたイベントに対応するコールバックを順に実行し、また待ちに戻ります。

graph LR
  W["epoll_wait<br/>ここでだけ寝る"] --> E["準備できたイベントの<br/>一覧を受け取る"]
  E --> C1["接続Aのコールバック"]
  C1 --> C2["接続Bのコールバック"]
  C2 --> C3["タイマーのコールバック"]
  C3 --> W

  style W fill:#3b82f6,stroke:#1d4ed8,color:#fff
イベントループの基本形。コールバックはすべて同じスレッドで順番に実行されるので、1つが長く走ると他はすべて待たされる

接続ごとの状態は、スレッドのスタックの代わりに、コールバックが閉じ込めたクロージャやPromiseに入ります。 そのため接続あたりのメモリはとても小さく、ロックも要りません。代わりに、どれか1つのコールバックがCPUを長く使うと、 そのあいだ他のすべての接続が止まります。イベントループの弱点はこの一点に集まっており、第10章でその対策を扱います。

Node・Deno・Bunはどれもepollの上にいる

JavaScriptのサーバーランタイムは3つありますが、OSから見た仕組みはほぼ同じです。 Linuxでstraceをかけると、Node.js(libuv)、Deno(Rustのtokio系)、Bunのいずれもepoll_waitでイベントを待っています。 Bunは2026年8月20日の1.4で実装言語をZigからRustに書き換えましたが、epollの上でイベントループを回す構造は変わりません。 ランタイム間の速度差は、イベントループのモデルではなく、HTTPパーサーやJavaScriptエンジン、バインディングの作りから生まれます。

Redisが単一スレッドで速い理由

Redisはコマンドの実行を主に1本のスレッドで行いますが、1台で大量のリクエストをさばきます。 理由は、イベントループが苦手な条件をもともと避けているからです。データはすべてメモリ上にあるのでディスクI/Oを待たず、 1つのコマンドの処理はハッシュの参照のように短く終わります。1スレッドなのでデータ構造にロックをかける必要もありません。 ネットワークの待ちはepollにまかせ、CPUはコマンドの実行だけに使います。 逆に、巨大なキーを一度に消す、全キーを走査するといった長いコマンドを流すと、その間ほかの全クライアントが待たされます。 Node.jsで同期的な重い処理を書いたときと同じ現象です。

軽量スレッド — 同期コードの見た目でイベントループの効率を

イベントループは効率がよい反面、処理をコールバックに分割する必要がありました。 軽量スレッドは、この分割を言語ランタイムが代わりに行うモデルです。プログラマは1接続1スレッドのつもりで同期的なコードを書き、 I/Oで待つ箇所に来るとランタイムがその軽量スレッドを止めて、OSスレッドを別の軽量スレッドに明け渡します。

graph TD
  subgraph G["goroutine 数万個"]
    g1((g1))
    g2((g2))
    g3((g3))
    g4((g4))
  end
  subgraph R["Goランタイムのスケジューラ"]
    P1["P1 論理プロセッサ"]
    P2["P2 論理プロセッサ"]
  end
  g1 --> P1
  g2 --> P1
  g3 --> P2
  g4 --> P2
  P1 --> M1["OSスレッド M1"]
  P2 --> M2["OSスレッド M2"]
  M1 --> K1[CPUコア1]
  M2 --> K2[CPUコア2]
GoのM:Nスケジューリング。多数のgoroutine(G)を少数の論理プロセッサ(P)経由でOSスレッド(M)に載せる。I/O待ちのgoroutineは外され、裏ではネットワークポーラーがepollで待つ

Goのgoroutineは初期スタックが2KBで、必要に応じて伸びます。goroutineの切り替えは約170nsで、OSスレッドのコンテキストスイッチ(約1.2〜2.2µs)より1桁速いという測定があります。 ネットワークI/Oで待つgoroutineは、裏でランタイムのネットワークポーラーがepoll(macOSではkqueue)に登録して待ちます。 見た目は同期コードでも、OSから見ればNode.jsと同じくI/O多重化が動いています。

Erlang(と、その上で動くElixir)のプロセスは1つあたり約327ワードで作られ、数百万個を同時に動かす設計です。 プロセス同士はメモリを共有せずメッセージだけでやり取りするので、1つがクラッシュしても周囲に波及しません。

JavaのVirtual Threads(JEP 444、Java 21で正式導入)は、JEP自身が「速くするためではなく、スケールさせるため(scale, not speed)」と書いています。 1スレッドの処理が速くなるわけではなく、ブロッキングで待つスレッドを大量に持てるようになるという意味です。 Virtual Threadsは安価なので、従来のスレッドのようにプールせず、タスクごとに作って捨てることが推奨されています。 初期の実装には、synchronizedブロックの中で待つとOSスレッドを手放せない「pinning」の問題がありましたが、JEP 491で解消されました。

async/await — 状態機械をコンパイラに作らせる

async/awaitは、イベントループの上で同期コードに近い書き方をするための構文です。 コンパイラや処理系が、awaitの前後で関数を分割し、途中の変数をオブジェクトに保存する状態機械を作ります。 JavaScriptのasync関数も、Pythonのasyncioも、Rustのtokioも、仕組みとしてはこの延長にあります。

// 見た目は上から下へ進む同期コード
async function handle(id: string) {
  const user = await db.findUser(id);      // ここで一度イベントループへ戻る
  const orders = await db.findOrders(id);  // 戻ってきたら user を覚えたまま続きを実行
  return { user, orders };
}

// 互いに依存しない待ちは並べて待つ
async function handleFast(id: string) {
  const [user, orders] = await Promise.all([db.findUser(id), db.findOrders(id)]);
  return { user, orders };
}

軽量スレッドとの違いは、どこで止まるかがコードに見えることです。awaitと書いた場所でだけ処理が中断し、それ以外の場所では他のタスクに割り込まれません。 ロックが要りにくくなる利点がある一方、awaitを使う関数は自分もasyncにする必要があり、それが呼び出し元へ次々と伝染します。 またNode.jsのasync/awaitは1本のスレッドの上で動くので、awaitの間にある同期的な重い計算はイベントループを止めます。 asyncと書いてもCPUの仕事は並列になりません。

事例 — 移行の数字をどう読むか

事例移行の内容公表された効果読むときの注意
PayPal(2013年)JavaのアプリをNode.jsで作り直し秒間リクエスト数が2倍、平均応答時間が35%短縮Nodeは1コア、Javaは5コアでの比較。言語の速さというより、I/O待ちの多い処理にイベントループが合った例
LinkedIn(モバイル)モバイル向けバックエンドをNode.jsへサーバー30台が3台に移行元は同期ブロッキングのRails(Mongrel)。比較相手が1プロセス1リクエストのモデルだった
NetflixWebのUI層をNode.jsへTTI(操作可能になるまでの時間)70%短縮UI層のアーキテクチャ全体を見直した効果で、ランタイム単体の比較ではない
Cloudflare PingoraNGINXをRust製の自社プロキシに置き換えCPU約70%減、メモリ67%減、接続再利用率87.1%→99.92%、1日あたり434年分のハンドシェイク時間を節約プロセスごとに接続プールが分かれるNGINXから、スレッド間で接続プールを共有できる設計に変えた効果が大きい
Discord(Read States)GoのサービスをRustで書き直しGCによる2分ごとのレイテンシスパイクが消えたGoが遅かったのではなく、GCの挙動が要件に合わなかった

PayPalとLinkedInの数字はよく「Node.jsは速い」という文脈で引用されます。しかしLinkedInの比較相手は、1プロセスが1リクエストずつ同期的に処理するRailsの構成でした。 30台が3台になった効果の多くは、第7章で見た「1接続1プロセス」から「I/O多重化」へ移ったことによるもので、JavaScriptという言語の速さの効果とは分けて考える必要があります。

CloudflareのPingoraは、イベント駆動同士の比較である点で興味深い事例です。NGINXはワーカープロセスごとに接続プールを持つため、 あるワーカーが開いたオリジンへの接続を別のワーカーが再利用できません。Pingoraはマルチスレッドのランタイムでプールを共有し、接続再利用率を99.92%まで上げました。 モデルの違いが、メモリやCPUだけでなくTCPとTLSのハンドシェイク回数にまで効いてくることが分かります。

Discordの事例は、軽量スレッドのモデルそのものではなく、それを支えるランタイムのGCが問題になった例です。 大量のキャッシュを抱えたサービスで、Goのランタイムが少なくとも2分ごとにGCを走らせ、そのたびにレイテンシが跳ねていました。 GCを持たないRustに移したことでスパイクは消えました。

どれを選ぶか — 判断の流れ

最後に、モデルを選ぶときの考え方を流れ図にまとめます。最初に問うべきは、処理時間の大半がI/O待ちかCPU計算かです。

graph TD
  A{"処理時間の大半は?"} -->|I/O待ち| B{"同時接続は<br/>数千以上?"}
  A -->|CPU計算| C["コア数ぶん並列に動かす<br/>マルチプロセスかスレッド"]
  B -->|いいえ| D["どのモデルでも足りる<br/>チームが慣れたものを選ぶ"]
  B -->|はい| E{"チームの主言語は?"}
  E -->|TypeScript| F["Node.js / Deno / Bun<br/>イベントループ"]
  E -->|Go / Java / Elixir| G["goroutine / Virtual Threads<br/>Erlangプロセス"]
  E -->|Rust / Python| H["tokio / asyncio<br/>async/await"]
  F --> I{"重い計算も<br/>混ざる?"}
  I -->|はい| J["worker_threadsや<br/>別サービスに逃がす"]
  I -->|いいえ| K["そのまま1スレッドで"]

  style A fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style F fill:#3b82f6,stroke:#1d4ed8,color:#fff
並行処理モデルを選ぶときの判断の流れ。I/OバウンドかCPUバウンドかを先に決め、接続数とチームの言語で絞る

CPUバウンドな処理、たとえば画像の変換や大量データの集計が中心なら、イベントループやasync/awaitを選んでも並列にはなりません。 コア数ぶんのプロセスやスレッドで並列に動かすのが基本です。 I/Oバウンドで同時接続が数百程度なら、どのモデルでも性能の差はあまり出ないので、チームが慣れていて障害時に調査しやすいものを選ぶのが合理的です。 同時接続が数千を超える場合は、イベントループか軽量スレッドが候補になり、どちらを選ぶかはチームの言語でほぼ決まります。 Node.jsを選んだ場合でも重い計算が混ざるなら、その部分はworker_threadsや別サービスに分けます。この方法は第10章で扱います。

まとめ — モデルの違いは「状態をどこに置くか」

マルチプロセスとマルチスレッドは、待っている接続の状態をOSスレッドのスタックに置き、カーネルのスケジューラに切り替えをまかせます。 イベントループとasync/awaitは状態をアプリのオブジェクトに置き、1本のスレッドで順に処理します。 軽量スレッドはその中間で、同期コードの見た目のまま、言語ランタイムがOSスレッドへの割り振りを担います。 どのモデルでも、ネットワークI/Oの最下層ではepollやkqueueが動いています。 事例の数字を読むときは、何と何を比べたのかまで確認してください。

次章(第9章)では、イベントループの代表であるNode.jsの内部に入ります。 「Node.jsはシングルスレッド」という説明がどこまで正しいのか、V8・バインディング・libuvの3層構造と、イベントループの各フェーズ、スレッドプールの実態を見ていきます。

理解度チェック

問題 0 / 40%
Q1

LinkedInのモバイル向けバックエンドがNode.jsへの移行でサーバー30台から3台になった事例を読むときの注意点として、最も適切なものはどれですか?

キーボード: 1〜4 で選択、Enter で回答