ソケットもFDなら、1万本のFDをどう見張るか
第6章で、Unixではファイルもパイプもソケットも同じファイルディスクリプタ(FD)として扱うことを見ました。 Webサーバーにとって、クライアントとの接続1本はソケット1つ、つまりFD 1つです。 1万人が同時につないでくれば、プロセスは1万個のFDを抱えることになります。
問題は、その1万本のうち「いまデータが届いている」のはごく一部だという点です。 残りはクライアントが考え中だったり、回線の向こうで止まっていたりします。 データが来ていないFDをread()すると、プログラムは待たされます。 本章では、この「待ち」をOSがどう扱ってきたかを、selectからepoll・kqueue、そしてio_uringまで順に追います。 Node.jsが1スレッドで大量の接続をさばける理由の半分は、この章で説明がつきます。
「待つ」とは何か — ブロッキングの正体はS状態
データがまだ届いていないソケットに対してプロセスがread()を呼ぶと、カーネルはそのスレッドを実行キューから外し、 ソケットの待ち行列(wait queue)に登録します。第3章で見たプロセス状態でいえば、RからS(割り込み可能スリープ)に移ります。 データが届くと、NICの割り込みを受けたカーネルが待ち行列のスレッドを起こし、Rに戻します。 スレッドはそこで初めてデータをユーザー空間のバッファにコピーしてもらい、read()から戻ります。
つまり「ブロッキングI/O」とは、スレッドがS状態で寝ている時間のことです。寝ている間CPUは使いませんが、 そのスレッドは他の仕事もできません。1接続に1スレッドを割り当てる設計なら、1万接続で1万スレッドが寝ることになります。 第4章で見たとおり、スレッドにはスタック領域とコンテキストスイッチのコストがかかります。 「寝ているだけのスレッド」を何千本も抱えずに済む方法が、この章の主題です。
同期/非同期とブロッキング/ノンブロッキングは別の軸
I/Oの議論で混乱しやすいのが、「非同期」と「ノンブロッキング」を同じ意味で使ってしまうことです。 この2つは別の問いに答えています。
- ブロッキング/ノンブロッキング: 呼び出した関数が、準備ができるまでスレッドを寝かせるか、すぐに戻るか。
- 同期/非同期: データのコピーまで含めたI/Oの完了を、アプリが自分の呼び出しの中で見届けるか、カーネルが後から「終わった」と知らせるか。
| ブロッキング(呼び出しが待つ) | ノンブロッキング(すぐ戻る) | |
|---|---|---|
| 同期(完了をアプリが見届ける) | 普通のread()。データが届くまでS状態で寝る | O_NONBLOCK付きのread()。データがなければEAGAINで即座に戻る。select/epollで「読める」を待ってから読む方式もここに入る |
| 非同期(完了をカーネルが知らせる) | 完了通知を待つ呼び出し。io_uringで完了キューを待つio_uring_enterなど | POSIX aio、WindowsのIOCP、Linuxのio_uring。依頼だけして戻り、完了は後で受け取る |
この2軸を分けておくと、よくある混同を2つ解消できます。1つ目は、epollを「非同期I/O」と呼んでしまうことです。 epollが教えてくれるのは「このFDは今なら待たずに読める」という準備状態だけで、実際のread()とデータのコピーはアプリが自分で行います。 POSIXの定義に従えば、これは同期I/Oです。解説記事によってはI/O多重化を「非同期ブロッキング」に分類するものもあり、 表の置き場所が文献ごとにずれるのは、軸の定義が書き手によって違うためです。
2つ目は、Node.jsのfs.readFile()がカーネルレベルでも非同期だと思い込むことです。 JavaScriptから見れば非同期APIですが、Linuxの多くの環境ではlibuvのスレッドプールが裏で普通のブロッキングread()を呼んでいます。 APIの見た目とカーネル内部の動きは一致しません。この点は第9章で詳しく扱います。
UNPの5つのI/Oモデル
W. Richard Stevensの『UNIX Network Programming』(UNP)は、UnixのI/Oを5つのモデルに分類しました。 どのモデルも、入力処理を「データが届くのを待つ段階」と「カーネルからユーザー空間へコピーする段階」の2段階に分けて考えます。
| モデル | 待つ段階 | コピーする段階 | 代表的なAPI |
|---|---|---|---|
| ブロッキングI/O | 寝て待つ | 寝て待つ | read() / recvfrom() |
| ノンブロッキングI/O | EAGAINを受けて何度も聞き直す(ポーリング) | 寝て待つ | O_NONBLOCK + read() |
| I/O多重化 | 多数のFDをまとめて1回で待つ | 寝て待つ | select / poll / epoll |
| シグナル駆動I/O | シグナルで「読める」を知らされる | 寝て待つ | SIGIO |
| 非同期I/O | 待たない | 待たない(完了を通知される) | POSIX aio(aio_read) |
表の「コピーする段階」の列を見ると、上の4つはどれも最後にアプリが寝て待っています。 UNPが「完全に非同期」と呼ぶのは最後の非同期I/Oだけです。前の4つは、待ち方の工夫が違うだけで、どれも同期I/Oに分類されます。
ブロッキングI/OとI/O多重化を並べて見る
実務で重要なのは1つ目と3つ目の違いです。ブロッキングI/Oは1回の呼び出しで1本のFDしか待てません。 I/O多重化は、1回の待ちで多数のFDを見張り、準備ができたものだけを順に読みます。
sequenceDiagram participant App as アプリのスレッド participant K as カーネル App->>K: read(fd 5) Note over App: S状態で寝る<br/>他の接続は放置 Note over K: データ到着を待つ K->>K: データ到着<br/>ユーザー空間へコピー K-->>App: 読んだバイト数を返す
sequenceDiagram participant App as アプリのスレッド participant K as カーネル App->>K: epoll_wait(epfd) Note over App: 1回の待ちで<br/>登録済みの全FDを見張る K-->>App: fd 5 と fd 9 が読める App->>K: read(fd 5) K-->>App: データ(すぐ戻る) App->>K: read(fd 9) K-->>App: データ(すぐ戻る) App->>K: 再び epoll_wait(epfd)
I/O多重化では、スレッドが寝る場所がepoll_waitの1か所に集まります。 「どれかが読めるようになるまで待つ → 読めるものを全部処理する → また待つ」というループは、後の章で見るイベントループの骨格そのものです。
select → poll → epoll / kqueueの進化
I/O多重化のAPIは、接続数が増えるにつれて作り直されてきました。
selectは1983年の4.2BSDで登場しました。監視したいFDをビットの集合(fd_set)で渡し、 カーネルは準備ができたFDのビットだけを残して返します。この方式には2つの限界がありました。 1つはFD_SETSIZE(通常1024)を超える番号のFDを扱えないこと。もう1つは、呼ぶたびに全FDの集合をカーネルへ渡し直し、 カーネルもアプリも全ビットを走査するので、コストが監視するFDの数nに比例するO(n)になることです。
pollはFDを構造体の配列で渡すことで上限をなくしましたが、毎回配列を丸ごと渡して全要素を調べるO(n)は変わりません。 1万接続のうち準備ができているのが10本でも、毎回1万本を調べることになります。
Linux 2.5.44(2002年)で入ったepollは、登録と待機を分けました。epoll_ctlで監視対象を一度だけ登録しておき、epoll_waitは準備ができたFDのリストだけを返します。 カーネルはFDごとにコールバックを仕込んでおき、データが届いた時点でそのFDを「準備済みリスト」に積むので、待機のコストは準備済みのFD数にしか比例しません。 BSD系ではFreeBSD 4.1(2000年)のkqueueが同じ発想で作られ、macOSもこれを使っています。
| select | poll | epoll | kqueue | |
|---|---|---|---|---|
| 監視できるFD | FD_SETSIZE(通常1024)まで | 上限なし | 上限なし(1 FDあたり約160バイト) | 上限なし |
| 1回の待ちのコスト | O(n):全FDを走査 | O(n):全FDを走査 | 準備済みのFD数に比例 | 準備済みのイベント数に比例 |
| APIの形 | 毎回ビット集合を渡し直す | 毎回配列を渡し直す | epoll_ctlで登録、epoll_waitで待つ | kevent 1つで登録と待機 |
| 通知方式 | レベルトリガのみ | レベルトリガのみ | LT(既定)とET | レベル型とEV_CLEAR(エッジ型) |
| OS | ほぼ全OS(4.2BSD, 1983) | POSIX系 | Linux(2.5.44, 2002) | FreeBSD 4.1(2000)、macOSなどBSD系 |
レベルトリガとエッジトリガ
epollには通知の仕方が2つあります。レベルトリガ(LT、既定)は「まだ読めるデータが残っている限り、epoll_waitのたびに知らせる」方式です。 エッジトリガ(ET、EPOLLET)は「読めない状態から読める状態に変わった瞬間に1回だけ知らせる」方式です。
ETはシステムコールの回数を減らせますが、通知を受けたらEAGAINが返るまで読み切る責任がアプリ側に移ります。 途中でやめると、残ったデータについて二度と通知が来ず、その接続が止まったように見えます。 複数スレッドで同じepollを待つときは、1つのイベントを1つのスレッドだけに渡すEPOLLONESHOTも使われます。
readiness型とcompletion型
epollとkqueueは、どちらも「このFDは今なら読める」という準備状態(readiness)を知らせます。 読むのはアプリです。これに対して、「読み終わった。データはここに置いた」という完了(completion)を知らせる方式があります。
completion型の代表がWindowsのIOCP(I/O Completion Port)で、1994年のWindows NT 3.5から使えます。 アプリはバッファを渡して読み込みを依頼し、すぐに戻ります。カーネルがデータをバッファへ書き終えると、完了ポートに通知が積まれます。 WindowsではソケットもファイルもIOCPで扱うのが標準的な高性能I/Oの方法で、Node.jsのlibuvもWindowsではIOCPを使っています(第9章)。
io_uring — 共有リングバッファで完了を受け取る
Linuxでcompletion型のI/Oを本格的に使えるようにしたのが、2019年のLinux 5.1でJens Axboeが導入したio_uringです。 アプリとカーネルが2本のリングバッファをメモリ上で共有し、システムコールをほとんど呼ばずにI/Oを依頼・回収します。
graph LR App[アプリ] -->|SQE を書き込む<br/>例: fd 5 を読め| SQ["送信キュー SQ<br/>共有リングバッファ"] SQ -->|io_uring_enter<br/>またはSQPOLLスレッド| K[カーネル] K -->|I/Oを実行して<br/>CQE を書き込む| CQ["完了キュー CQ<br/>共有リングバッファ"] CQ -->|結果を読む<br/>システムコール不要| App style SQ fill:#8b5cf6,stroke:#6d28d9,color:#fff style CQ fill:#3b82f6,stroke:#1d4ed8,color:#fff
アプリは送信キュー(SQ)に「fd 5からこのバッファへ4KiB読め」といったエントリ(SQE)を書き、io_uring_enterで一括してカーネルに渡します。 カーネルはI/Oを実行し、終わったものから完了キュー(CQ)に結果(CQE)を書きます。 何十件もの依頼をシステムコール1回で渡せるので、第2章で見たシステムコールの直接コストと間接コストをまとめて減らせます。 SQPOLLモードでは、カーネル側のスレッドがSQを監視し続けるので、依頼のためのシステムコールすら不要になります。
Axboeの資料では、io_uringは約170万IOPSを出し、従来のLinux aioの約60.8万IOPSを大きく上回っています。 ファイルI/Oに対して実用的な非同期APIがなかったLinuxにとって、io_uringは長年の空白を埋めるものでした。
性能とセキュリティの綱引き
一方でio_uringは、カーネルの中に大きな新しい攻撃面を作りました。Googleは、2022年に同社のバグバウンティへ報告されたカーネルのエクスプロイトのうち約60%がio_uringに関係していたと公表し、 ChromeOS、Android、自社の本番サーバーでio_uringを無効にしました。Dockerの既定のseccompプロファイルもio_uring関連のシステムコールをブロックしているため、 コンテナの中ではio_uringを使うつもりのプログラムが黙ってepollなどに切り替わることがあります。
カーネル側も対策を進めています。2026年4月のLinux 7.0では、cBPFでio_uringのオペコード単位にフィルタをかける仕組みが入りました。 「io_uringを全部止める」か「全部許す」かの二択ではなく、使ってよい操作だけを許可できる方向に進んでいます。 2026年時点のio_uringは、速いけれども運用する組織のセキュリティ方針しだいで有効にするかどうかが分かれる技術です。
C10K問題とnginx
1999年、Dan Kegelは「1台のサーバーで1万(10K)の同時接続をさばくにはどうすればよいか」をC10K問題としてまとめました。 当時主流だったApacheのprefork方式は1接続を1プロセスで受けるため、1万接続なら1万プロセスが必要です。 1プロセスあたり1MB以上のメモリを使うので、1万接続で10GBを超えます。
この問題への答えとして広まったのが、2004年10月に0.1.0が公開されたnginxです。 nginxはCPUコア数ほどの少数のワーカープロセスを立て、各ワーカーがepollやkqueueで数千〜数万の接続を1スレッドで見張ります。 『The Architecture of Open Source Applications』のnginx章によれば、アイドルなkeep-alive接続1本あたりのメモリは約550バイトです。 1MBと550バイトでは2,000倍近い差があります。
| Apache prefork | nginx | |
|---|---|---|
| 接続の受け方 | 1接続 = 1プロセス | 1ワーカーが多数の接続をepoll/kqueueで見張る |
| 待ち方 | 各プロセスがread()で寝る(ブロッキングI/O) | epoll_waitの1か所で寝る(I/O多重化) |
| 接続あたりのメモリ | 1MB以上 | アイドル接続で約550バイト |
| 1万接続のとき | プロセス1万個、メモリ10GB超 | ワーカー数個、接続分のメモリは数MB |
Apacheも後にevent MPMを用意し、keep-alive接続の待機を専用スレッドにまとめるようになりました。 2026年10月のW3Techsの集計でも、WebサーバーのシェアはCloudflare 33.0%、Nginx 30.1%、Apache 21.2%で、 イベント駆動のサーバーが上位を占めています。
TypeScriptで確かめる — 1スレッドで多数接続を受けるエコーサーバー
Node.jsのnetモジュールで、受け取ったデータをそのまま返すエコーサーバーを書いてみます。 接続ごとにスレッドを作るコードはどこにもありません。それでも同時に何百本も接続を受けられます。
// echo-server.ts — 受け取ったバイト列をそのまま送り返す
import net from "node:net";
let active = 0;
const server = net.createServer((socket) => {
active++;
console.log(`connected: active=${active}`);
// データが届いたときだけ呼ばれる。待っている間は何も実行されない
socket.on("data", (chunk) => socket.write(chunk));
socket.on("close", () => {
active--;
});
socket.on("error", () => socket.destroy());
});
server.listen(7000, () => {
console.log(`pid=${process.pid} listening on :7000`);
});別のターミナルから、1,000本の接続を同時に張るクライアントを動かします。
// client.ts — 1,000本の接続を同時に張って1行ずつ送る
import net from "node:net";
for (let i = 0; i < 1000; i++) {
const s = net.connect(7000, "127.0.0.1");
s.on("connect", () => s.write(`hello from ${i}\n`));
s.on("data", () => {}); // エコーを受け取るだけ
}# 先にFD上限を上げておく(macOSの既定は256で、EMFILEになりやすい)
ulimit -n 4096
npx tsx echo-server.ts &
npx tsx client.ts &
# Linux: サーバーのスレッド数を数える
ls /proc/$(pgrep -f echo-server)/task | wc -l
# macOS: スレッド一覧を出す
ps -M $(pgrep -f echo-server)サーバーのログにはactive=1000まで表示されますが、スレッド数は接続数と関係なく一定で、V8やlibuvが使う数本のままです。 Linuxなら、Node.jsの内部ではlibuvが1,000個のソケットFDをepollに登録し、メインスレッドがepoll_waitの1か所で寝ています。 macOSなら同じことをkqueueで行っています。ulimit -nを上げ忘れるとEMFILEが出るのは、第6章で見たプロセスごとのFD上限に当たったためです。 システムコールの実際の並びは、第12章でstraceを使って確認します。
まとめ — 「待ち」を1か所に集める
ブロッキングI/Oでは、データを待つスレッドがS状態で寝ます。1接続1スレッドの設計では、寝ているだけのスレッドが接続数ぶん増えていきます。 I/O多重化はその待ちを1か所に集め、epollとkqueueは登録と待機を分けて、接続が何万本あっても準備済みのものだけを返せるようにしました。 さらにio_uringとIOCPは、準備状態ではなく完了を知らせるcompletion型です。 そして「同期/非同期」と「ブロッキング/ノンブロッキング」は別の軸として考えると、APIの分類で迷わなくなります。
ただし、I/O多重化だけでは「1スレッドでどうやって複数の処理を進めるか」というプログラムの書き方までは決まりません。 次章(第8章)では、マルチプロセス、マルチスレッド、イベントループ、goroutineのような軽量スレッド、async/awaitを並べ、 PayPalやCloudflare、Discordの事例を通して、どのモデルをいつ選ぶかを考えます。
理解度チェック
epollについての説明として最も適切なものはどれですか?
キーボード: 1〜4 で選択、Enter で回答