前章まで、私たちは「一台のマシンの中」を旅してきた。だがサーバーの真価は、外から来る大量の要求をどう捌くかにある。
1999年、エンジニアのDan Kegelが一つの問いを投げかけた。「1台のサーバーで、1万の同時接続(10,000 = 10K)を扱えるか?」——これがC10K問題だ。当時の常識的なサーバー設計では、これが恐ろしく難しかった。なぜか。その壁を、第7章のシステムコールの知識で解体していこう。
素朴な設計はなぜ破綻するのか — 歴史の必然
初期のサーバーの定番は「1接続 = 1プロセス(またはスレッド)」だった。新しい客(接続)が来るたびに、専属の店員(スレッド)を一人つける。分かりやすい。
/* 素朴なサーバー: 接続ごとにスレッドを生成 */
while (1) {
int client_fd = accept(listen_fd, NULL, NULL); /* 客が来るのを待つ */
pthread_create(&t, NULL, handle_client, &client_fd); /* 専属店員を1人つける */
}
接続が数十なら問題ない。だが1万になると破綻する。理由は二つ、いずれも物理的な限界だ。
- メモリの限界: 各スレッドはスタック(第5章)を持つ。既定で1接続あたり約1MB。1万接続なら 10GB がスタックだけで消える。
- コンテキストスイッチの限界: CPUは一度に1コアで1スレッドしか実行できない。1万スレッドを切り替える「文脈交代」のコストが、実際の仕事を圧迫する。
そもそも、Webサーバーの接続の大半は「待っている」だけだ。クライアントの次のデータを待つ、ディスクからの読み込みを待つ……。仕事をしていない接続のために1万人の店員を雇うのは、あまりに無駄だ。
厨房のアナロジー — 一人のホールスタッフが全席を回る
満席のレストランを想像してほしい。「1テーブル1店員」方式なら、20卓に20人の店員が要る。しかも大半の時間、店員は客が食べ終わるのをただ突っ立って待っている。人件費(メモリ)は爆発し、店員同士がすれ違うだけで厨房は大混雑(コンテキストスイッチ)だ。
熟練したホールスタッフは違う動きをする。一人で全テーブルを見回り、「3番が呼んでいる」「7番の皿が空いた」と、用事のあるテーブルだけに対応する。待っている間は他のテーブルを見る。手が空くことがない。
私が飲食店で学んだ最も重要な技術がこれだった。忙しい時間帯、担当エリアの全テーブルを頭に入れ、「今アクションが必要なのはどこか」を常に把握して動く。待ち時間をゼロにし、イベントが起きた席だけを処理する。これがまさに、C10Kを解決したイベント駆動の考え方そのものだ。
【1接続1スレッド】 【イベント駆動】
店員A → 客1(待ち…) ┌─ 客1
店員B → 客2(待ち…) │ 客2 1人の店員が
店員C → 客3(応対中) → 店員 ─┤ 客3 ←── 用事のある客だけ
店員D → 客4(待ち…) │ 客4 を選んで応対
...(1万人の店員) └─ ...(1万人の客を1人で)
ノンブロッキングI/O — 「待たない」という選択
イベント駆動を実現する第一歩は、ブロッキングをやめることだ。
第7章で見た read システムコールは、デフォルトではブロッキングする。データが来るまで、そのスレッドはカーネルの中で眠り、制御が返らない。店員が一つのテーブルに張り付いて動けない状態だ。
対してノンブロッキングI/Oでは、read はデータが無ければ「今は何もない(EAGAIN)」と即座に返る。店員は待たずに次のテーブルへ行ける。
#include <fcntl.h>
/* fd を「ノンブロッキング」に設定する */
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
/* これ以降 read はブロックせず即座に返る */
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
/* 「今はデータが無い」→ 待たずに他の接続を処理しに行く */
}
だが、これだけでは新たな問題が生まれる。1万の接続を、どうやって「用事があるか」確認するのか。全接続を1つずつ read して回る(ポーリング)と、大半は空振りで、CPUを無駄に焼き尽くす。「どの接続にイベントが起きたか」を、まとめて教えてくれる仕組みが要る。
select から epoll へ — 通知の進化
カーネルに「これらのfdのうち、準備ができたものを教えてくれ」と一括で問い合わせる仕組みが、順に進化してきた。
select / poll — 毎回全員に聞いて回る
初期の select/poll は、監視したい全fdのリストを毎回カーネルに渡す。カーネルは全fdを走査して、準備できたものを返す。問題は、接続が増えるほど毎回のコストが線形に増えることだ。
1万接続なら、イベントが1件起きるたびに1万件を調べ直す。しかも select には監視fd数の上限(1024)まであった。
epoll — 「変化があったものだけ」を受け取る
Linuxが2002年に導入した epoll が、C10Kの決定打となった。発想の転換はこうだ。
- 監視したいfdは、一度カーネルに登録しておく(毎回渡さない)
- カーネルは、イベントが起きたfdだけを内部リストに積む
- アプリは「準備できたものリスト」だけを受け取る
これにより、コストは接続総数 ではなく、実際にイベントが起きた数 に比例する。大半が待機中のWebサーバーでは だから、劇的に速い。
#include <sys/epoll.h>
/* (1) epoll インスタンスを作る = 「監視台帳」を用意 */
int epfd = epoll_create1(0);
/* (2) 監視したい fd を台帳に登録(一度だけ) */
struct epoll_event ev = { .events = EPOLLIN, .data.fd = listen_fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
/* (3) イベントループ: 「用事のある fd」だけを受け取る */
struct epoll_event events[1024];
while (1) {
int n = epoll_wait(epfd, events, 1024, -1); /* 準備できるまで眠る */
for (int i = 0; i < n; i++) { /* 起きた分だけループ */
int fd = events[i].data.fd;
if (fd == listen_fd) {
int client = accept(listen_fd, NULL, NULL);
/* 新しい接続も台帳に追加 */
struct epoll_event cev = { .events = EPOLLIN, .data.fd = client };
epoll_ctl(epfd, EPOLL_CTL_ADD, client, &cev);
} else {
handle_read(fd); /* データが来た接続だけ処理 */
}
}
}
このループが、熟練ホールスタッフの動きそのものだ。epoll_wait で「用事のある席」だけを受け取り、順に対応し、また巡回に戻る。1つのスレッドで、1万でも10万でも接続を捌ける。
各OSに同種の仕組みがある。BSD/macOSの kqueue、Windowsの IOCP だ。nginxが Apache(プロセス/スレッド型)を性能で圧倒したのも、この epoll ベースのイベント駆動アーキテクチャゆえだった。
イベントループは、あらゆる場所にいる
このイベント駆動モデルは、サーバー内部にとどまらない。あなたが日々使う技術の土台になっている。
- Node.js: シングルスレッドのイベントループ(libuv が epoll/kqueue/IOCP を抽象化)で高並行を実現
- Redis: 単一スレッドのイベントループで、毎秒数十万リクエストを捌く
- nginx: ワーカーごとのイベントループで C10K、さらには C10M(1000万接続)へ
「JavaScriptはシングルスレッドなのに、なぜ大量のリクエストを捌けるのか?」——その答えがこれだ。待たないから。I/O待ちの間に他の仕事を進め、準備できたイベントだけを処理する。第7章で見た「システムコールのコスト」を、epollは「必要な回数だけ」に絞り込んでいる。
Node.js のイベントループ(概念図)
┌──────────────────────────────────┐
│ ① epoll_wait で「準備できたI/O」を取得 │
│ │ │
│ ▼ │
│ ② 対応するコールバックを実行 │
│ (JSのあなたのコード) │
│ │ │
│ ▼ │
│ ③ タイマー・次のtickを処理 → ①へ戻る │
└──────────────────────────────────┘
重い同期処理を書くと、このループが止まり全接続が待たされる
(= 「イベントループをブロックするな」の意味)
まとめ — 「おまじない」の消し方
- C10Kは物理の壁:1接続1スレッドは、メモリ(スタック)とコンテキストスイッチで破綻する
- 接続の大半は待っている:待つだけの接続に専属スレッドを割くのは無駄。イベント駆動へ
- ノンブロッキングI/O:
readを待たせず即座に返させ、手が空いたら他を処理する - epollが決定打:fdを登録しておき、イベントが起きたものだけを で受け取る
- イベントループは至る所に:Node.js・Redis・nginxの高並行は、すべてこの仕組みの上にある
「なぜNode.jsはシングルスレッドで速いのか」「なぜイベントループをブロックしてはいけないのか」——その答えは、C10Kを乗り越えた設計思想にあった。
さて、大量の接続を捌けるようになった。だが、そのリクエストが求めるデータは、最終的にデータベースに問い合わせる。数億件のデータから一瞬で目的の1件を見つけ、しかも障害でも失わない——次章、B-TreeとWALの物理へ潜る。
「忙しさの正体は、仕事の量ではなく、待ち時間の使い方だ。優れたサーバーも優れた店員も、待っている間に次の仕事を進めている。」