I/O モデル 基礎
サーバは数千の並行接続を処理する必要がある。準備されたデータについてカーネルにどう尋ねるかが、 単一最大のアーキテクチャ選択。5 セクションで全体像を組み立てる:ブロッキング vs 非ブロッキング、基礎的な区別;select / poll / epoll、対話的比較付きの多重化の進化;io_uring —— 現代の共有リングバッファ置き換え; 高スループットデータ移動のためのゼロコピートリック (sendfile / splice / DMA-buf);最後にクイックリファレンス。
ブロッキング vs ノンブロッキング —— 基礎的選択
あらゆる I/O syscall はブロッキング(作業完了時に戻る)またはノンブロッキング (即座に戻る、作業の状態付き)のいずれか。この選択がサーバアーキテクチャ全体に波及する。
ソケット上のブロッキング read(fd, buf, n):データが利用可能なら、 コピーして戻る。なければ、データが到着するか接続が閉じるまで呼び出しスレッドをスリープさせる。 アプリケーションの視点では呼び出しは「ただ動く」;スレッドは見えずにサスペンドされ、 I/O 完了時に再開する。プログラミングは単純 —— しかしサスペンドされたスレッドはカーネル リソースであり、待っている間同じスレッドで別の接続を処理できない。
同じソケット上のノンブロッキング読み(fd に O_NONBLOCK をセット、 または fcntl 経由):データが利用可能なら、コピーして戻る。 なければ、errno = EAGAIN で即座に戻る。アプリケーションはその後何をするか決めなければならない —— 後で再試行、select/epoll/io_uring で準備をポーリング、または諦める。
なぜこれがアーキテクチャを決めるか
ブロッキング I/O では、自然な並行プリミティブは「リクエストあたり 1 スレッド」: 各接続のハンドラスレッドが自身の読みでブロックする。単純だが重い —— カーネルスレッド(process-thread primer)はマシンあたり数千で頭打ち。
ノンブロッキング I/O では、自然なプリミティブはイベントループ: 1 スレッド(または小さなプール)が準備された fd を巡回し、各々に少し作業をし、 その後カーネルに次にどの fd が準備されているか尋ねる。これが単一プロセスが 10 万+ 接続を処理できるようにするもの。
ふりをするコスト
多くの高レベルランタイムはノンブロッキング I/O をブロッキングに見える API の背後に隠す。 Go goroutine は conn.Read でブロックするが、ランタイムは背後で それをノンブロッキング + epoll でのパークに翻訳する。Java 仮想スレッド(Loom)も同じ。 Rust async / await も同様。プログラマは同期的に見えるコードを書く;ランタイムは 多くのそのような「ブロッキング」呼び出しを epoll または io_uring に裏付けされた 小さなカーネルスレッドプールに多重化する。
この透明性のコスト:async 等価物のないあらゆるブロッキング呼び出し(多くのランタイムでの ファイルシステム呼び出し、サードパーティの C ライブラリ)が基底のカーネルスレッドを固定する。 10 OS スレッドの goroutine プールに 1 つが遅い open() でブロックしていれば、 I/O 容量の 10% を失った。Go は回避策を使う(syscall 用の別個のプール); JVM の Loom も。
3 つの中間モデル
- マルチプロセス(Apache prefork)—— リクエストあたり 1 プロセスを fork。 隔離、しかしリクエストあたりのコストが巨大。
- スレッドプール + キュー —— 有界スレッドがリクエストキューから取得。 リソース使用が有界、しかし 1 リクエストがスレッドを停滞させると head-of-line ブロッキング。
- Reactor パターン —— シングルスレッドがイベントループを実行し、 イベントをハンドラにディスパッチ。select/epoll ベースのイベントループの現代版。 Node.js、nginx、Twisted が使う。
要点。「ブロッキング I/O:作業完了までスレッドがスリープ; 接続あたり 1 スレッドは数千で頭打ち。ノンブロッキング I/O:準備されていなければ syscall が即座に EAGAIN で戻る;1 つのイベントループが 10 万+ 接続を処理できる。 高レベルランタイム(Go、Java Loom、Rust async)はノンブロッキング I/O を ブロッキング形状の API の背後に隠すが、下では epoll/io_uring に依存する —— ラップされていない C コードへの真にブロッキングな呼び出しはカーネルスレッドを固定する。」
select → poll → epoll —— 多重化の進化
シングルスレッドが数千ソケットを監視するのは古典的な高スループットサーバ設計。 25 年で 3 つの syscall がこれを効率的に行うために進化した —— 各々が前任のスケーリング問題を修正した。
仕事:シングルスレッドが N 個のオープンソケットを保持し、いずれかが読み / 書き準備されたときに 起こされたい。3 世代の API:
select() —— 1980 年代
呼び出し元が 3 つの fd_set ビットマップ(読み準備、書き準備、例外)を渡す。 カーネルは任意の fd が準備されるまでブロックし、その後準備された fd のビットがセットされた ビットマップを返す。呼び出し元はビットマップをスキャンしてどれを処理するか見つける。
問題:ビットマップにはコンパイル時固定サイズがある(FD_SETSIZE、通常 1024)。 ビットマップはあらゆる呼び出しでユーザ→カーネル→ユーザにコピーされる。 ユーザ空間は呼び出しごとに準備された fd を見つけるためにビットマップ全体をスキャンせねばならない。 ~1000 fd を超えるとスループットが崩壊する。
poll() —— 1980 年代、BSD の答え
呼び出し元が pollfd 構造体の配列(fd + 望むイベント + 受け取ったイベント)を渡す。 固定サイズなし。それ以外の問題は同じ:カーネルが呼び出しごとに準備状態を確認するため 配列全体を歩き、ユーザ空間が準備されたものを見つけるため配列全体を歩く。呼び出しごとに O(N)。
epoll —— 2002 Linux
「C10K」(サーバあたり 1 万並行接続)を実用可能にしたブレークスルー。3 syscall:
epoll_create—— epoll インスタンスを作成、それを表す fd を返す。epoll_ctl—— 監視されているセットから fd を追加 / 変更 / 削除。 ウェイクアップごとではなく fd ライフサイクルあたり 1 回呼ばれる。epoll_wait—— 任意の登録 fd が準備されるまでブロック、 準備されたもののみを返す(セット全体ではなく)。
鍵となる勝利:カーネルが呼び出しを跨いでセットを維持(コピーなし)し、 準備された fd のみを返す(O(N) ではなく O(active))。1 万接続で任意の瞬間に 100 のみアクティブなら、 epoll はウェイクアップあたり ~100 単位の作業を行う;select は 1 万を行う。
レベルトリガ vs エッジトリガ
レベルトリガ(LT) —— epoll は現在準備されている任意の fd を、 各ウェイクアップで報告する。ソケットを完全に排出しないと、次の epoll_wait が再び報告する。 デフォルト;最も使いやすい。
エッジトリガ(ET) —— epoll は fd の準備状態が変化したときのみ報告する。 一度報告された後、EAGAIN まで読む(ソケットを排出する)必要があり、そうでないと次のデータを逃す。 わずかに速い(安定して準備された fd への繰り返しウェイクアップなし)が、正しく使うのが難しい。 性能クリティカルなサーバ(HAProxy、Envoy)が使う。
kqueue —— BSD の並行進化
FreeBSD/macOS は同じ問題を kqueue で解いた(1999、epoll より 2 年早い)。 API はより汎用的 —— kqueue は任意のイベント(ファイル変更、プロセスイベント、タイマー、シグナル)を 監視でき、fd だけではない。性能は epoll と同等。移植可能なクロスプラットフォーム抽象 (libevent、libev、libuv)が下で kqueue を走らせているか epoll を走らせているかを隠す。
要点。「シングルスレッドから数千 fd を多重化するには O(N) ではなく O(active) ウェイクアップメカニズムが必要。select / poll は O(N);epoll(Linux)と kqueue(BSD/macOS)は O(active)。epoll はあらゆる現代 Linux イベントループの基礎 (Node.js、nginx、Redis、Go ランタイム)。レベルトリガが簡単なデフォルト; エッジトリガはウェイクアップごとに完全に排出する必要性のコストでわずかなレイテンシを削減する。」
io_uring —— epoll を超えて
epoll は fd がいつ準備されたかを伝え、まだ syscall を作って読み書きしなければならなかった。 io_uring はその syscall も排除する。結果は 1980 年代以来 Linux が持つ最も根本的な I/O 再設計に近いもの。
io_uring(Linux 5.1、2019)はユーザコードとカーネル間で共有される 2 つのリングバッファを 中心に構築されている:
- 送信キュー(SQ) —— ユーザコードが I/O リクエスト (この fd をこのバッファに読む、このソケットで接続を accept する、等)をリングスロットに書く。 各エントリは 1 操作を記述する固定サイズの sqe。
- 完了キュー(CQ) —— カーネルが完了した操作の結果をリングスロットに書く。 各エントリは 1 つの完了した操作を記述する固定サイズの cqe。
両方のリングがカーネルとユーザアドレス空間の両方にマップされる。ユーザコードは syscall なしで SQ エントリを書き、SQ tail ポインタを更新する。カーネルはポーリングするか、io_uring_enter 経由で通知される(1 つの syscall が潜在的に多くの操作を送信); 操作を処理;CQ エントリを書く;CQ head ポインタを更新する。 ユーザコードは syscall なしで CQ エントリを読む。
何を変えるか
epoll では、各 I/O 操作に syscall(実際の読みや書き)と周期的な epoll_wait 呼び出しが必要。 io_uring では、合計 0 または 1 syscall で数千の操作を送信し収穫できる。 4 KB のランダムディスク読みベンチマークで、io_uring は同じハードウェアで epoll + read の ~2-3× のスループットを達成する、syscall オーバーヘッドが消えるから。
epoll が決してできなかったことも原生でサポートする:ファイルシステム I/O (epoll はソケット、パイプなどでのみ動く)、バッファ操作、固定バッファ (事前登録で操作あたりのコピーを減らす)、カーネルポーリング(カーネルが別スレッドで SQ をポーリング、負荷下で io_uring_enter syscall さえ排除する)。
どこで使われる
- fio —— ストレージベンチマークツール、大半のプロダクションコードより 先に io_uring バックエンドを得た。
- libuv(Node.js) —— 最近のバージョンで io_uring バックエンドがオプション。
- Tokio(Rust) —— async ランタイム、
tokio-uring経由でオプションの io_uring バックエンド。 - Postgres 17+ —— 一部の I/O パスに io_uring を使う。
- プロダクションサーバ —— 採用はベンチマークが示唆するより遅い。 セキュリティ CVE(2022-2023 年に複数)が一部のディストロ(Google、ChromeOS)に 非特権ユーザ向け io_uring を無効化させた。API も epoll より複雑で、性能勝利は 10 万 op/sec を超えてからのみ意味を持つ。
心モデルの転換
epoll は言う:「fd X がいつ準備されたか教えて、I/O は自分でやる」。 io_uring は言う:「ここに操作のリスト、それらを実行して完了時に知らせて」。 前者は準備ベース(あなたが作業を行う);後者は完了ベース(カーネルが作業を行い報告する)。 完了ベースのほうがスケールが良いが既存の reactor コードベースに収めるのが難しい。
要点。「io_uring はユーザコードとカーネル間で送信 / 完了リングバッファを 共有することで操作あたりの syscall を排除する。モデルを準備ベース(epoll)から完了ベースに切り替え、 高レートでより良く並列化しオーバーヘッドを償却する。採用は本物だがベンチマークが示唆するより遅い、 API の複雑さ、セキュリティ懸念、大半のワークロードで「epoll で既に十分速い」問題のため。」
ゼロコピー —— コピーせずにデータを動かす
古典的な read-then-write ループはバイトを 2 回コピーする: デバイス → カーネルバッファ → ユーザバッファ → カーネルバッファ → デバイス。 ゼロコピー技術はこれらのコピーの 1 つ以上を排除し、静的ファイル提供ワークロードの スループットを劇的に改善する。
100 MB ファイルを HTTP で提供することを考える。素朴なコードは:
for (;;) {
n = read(file_fd, buf, sizeof(buf)); // ファイル → カーネル page cache → ユーザ buf
if (n <= 0) break;
write(socket_fd, buf, n); // ユーザ buf → カーネル socket buf → NIC
}チャンクあたり 2 コピー + 2 syscall。100 MB ファイル + 64 KB チャンク: 1600 syscall + 200 MB の CPU 駆動メモリコピー。
sendfile()
sendfile(out_fd, in_fd, offset, count) はカーネルにファイルの page cache から ソケットの送信キューへ直接バイトを動かすよう伝える、ユーザ空間のコピーなし。 N 個の read+write ペアの代わりに 1 syscall、メモリコピー半減。これが nginx、Apache、 あらゆる静的ファイル CDN がレスポンスボディに対して背後で使うもの。
DMA scatter-gather をサポートする NIC 上で、sendfile は真のゼロコピー操作になり得る: NIC が DMA 経由でカーネル page cache から直接データを読み、CPU を経由しない。
splice() と tee()
splice(in_fd, in_offset, out_fd, out_offset, count, flags) は sendfile のより汎用的ないとこ:中間カーネルパイプ経由で任意の 2 fd 間でバイトを動かし、 ユーザ空間に触れない。プロキシ用に使う(1 ソケットから読み、別のソケットへユーザコードを 通さずに書く)。tee() は 2 パイプ間でバイトを複製する —— ロギングや分岐に使う。
MSG_ZEROCOPY
ソケット上の send() で MSG_ZEROCOPY フラグは カーネルにユーザバッファを固定したまま保持してそこから直接 DMA するよう伝え、 カーネルバッファへのコピーをしない。ユーザコードはバイトが送られバッファが再利用可能になったとき、 ソケットのエラーキュー経由で非同期通知を得る。大型 send のユーザ→カーネルコピーを節約; コストは通知処理とバッファを早く再利用または解放できない制約。
io_uring 登録バッファ
io_uring(前節)は事前登録バッファ(IORING_REGISTER_BUFFERS)をサポートする: ユーザが事前にカーネルにバッファのリストを与える;後続の I/O 操作はインデックスで参照する。 カーネルは一度固定し(操作あたり固定コストなし)、それらに直接 DMA するのが安全だと知る。 これが高スループット async サーバでゼロコピーを得る現代の方法。
ゼロコピーが助けるとき、助けないとき
- 助ける:大型静的ファイルの提供(sendfile が nginx に read+write の アプリよりスループット優位を与える);高スループットプロキシ(接続から接続への配管に splice); ビデオ / ファイルサーバ(NIC への DMA scatter-gather)。
- 助けない:syscall オーバーヘッドがどのみち支配する小型メッセージ; 読みと書きの間に変換が必要なデータ(暗号化、エンコーディング)—— 変換するためにコピーしなければならない;ランダム小型読みを行うデータベース —— page cache が既にそれを吸収する。
- 現代の代替:DPDK / AF_XDP —— ユーザ空間がカーネルネットワークスタックを 完全にバイパスし、NIC を直接ポーリング。極端な性能シナリオで使われる(クラウド ベンダーロードバランサ、金融取引システム)。異なるパラダイム —— 汎用性を生のスループットと引き換える。
要点。「素朴な read+write ループはチャンクあたり 2 コピー + 2 syscall を行う。 sendfile と splice は fd 間でユーザ空間関与なしでバイトを動かし、コピーを排除し syscall を半減する。 MSG_ZEROCOPY と io_uring 登録バッファはユーザメモリを固定して NIC が直接 DMA できるようにする。 静的ファイル提供とプロキシで勝利が大きい;小型メッセージや変換されたデータでは無視できる。」
クイックリファレンス
冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。
ブロッキングとノンブロッキング I/O の違いは?
ブロッキング:syscall は I/O 完了までスレッドをサスペンドする。 ノンブロッキング:syscall は即座に戻る、準備されていなければ EAGAIN。 ブロッキングは書くのが単純だが、~数千の並行接続(各々 1 カーネルスレッド)で頭打ち。 ノンブロッキングはイベントループ(select/epoll/io_uring)を必要とするが、 1 スレッドが 10 万+ 接続を処理できるようにする。
なぜ epoll が poll より速いか?
poll は O(N):カーネルとユーザランドの両方があらゆる呼び出しで、わずかしか 準備されていなくても fd セット全体をスキャンする。epoll は O(active): カーネルが epoll_ctl 経由で呼び出しを跨いでセットを維持し、epoll_wait が 準備されたもののみを返す。1 万接続で任意の時刻に 100 がアクティブな場合、 epoll は呼び出しごとに 1 万ではなく 100 単位の作業をする。
レベルトリガ vs エッジトリガ —— いつどちらを選ぶか?
レベルトリガが安全なデフォルト:epoll はあらゆるウェイクアップで現在準備されている 任意の fd を報告する。エッジトリガは状態変化のみ報告するので、各ウェイクアップで ソケットを完全に排出する(EAGAIN まで読む)必要があり、そうしないと次のデータを逃す。 ET は高レートシナリオ(HAProxy、Envoy)でわずかに速い、安定して準備された fd の 重複ウェイクアップを排除するから;LT が多くのアプリケーションコードが使うべきもの。
io_uring が epoll が解かない何の問題を解くか?
epoll は fd がいつ準備されたかを伝える;実際に読み書きするにはまだ syscall を作る。 io_uring はユーザとカーネル間で送信と完了リングバッファを共有するので、 1000 個の I/O 操作が合計 0 または 1 syscall で完了できる。シフトは準備ベース (あなたが作業を行う)から完了ベース(カーネルがそれを行って通知する)へ。 ファイルシステム I/O(epoll はしない)とゼロコピー用の登録バッファもサポートする。
sendfile はいつ read + write より速いか?
常に、変換なしでファイルからソケットへバイトを動かす場合。read+write は 2 回コピーし 2 syscall を行う;sendfile は page cache からソケットバッファへ(または DMA scatter-gather 経由で直接 NIC へ)バイトを 1 syscall で動かす、ユーザ空間関与なし。 nginx と Apache は静的ファイル応答に背後でそれを使う。読みと書きの間でバイトを変換する 必要がある場合は助けない —— 暗号化、gzip —— その場合はどのみちそれらをユーザ空間に 持ってくる必要があるから。
C10K 問題とは何で、何がそれを解いたか?
Dan Kegel(~1999)が特定した「サーバあたり 1 万並行接続」のチャレンジ。 初期のサーバは接続あたり 1 プロセスまたはスレッドを使い、メモリとスケジューラの オーバーヘッドのためマシンあたり数百接続で頭打ちだった。O(active) 多重化プリミティブに 支えられたイベントループが解いた —— Linux で epoll、BSD で kqueue。 C10K が解かれた後、次のチャレンジ(C10M、サーバあたり 1000 万)が DPDK、AF_XDP、 io_uring を駆動した。
コードレビューの赤旗
- 新コード内の select()。FD_SETSIZE は多くのシステムで 1024 で頭打ち、 呼び出しごとに O(N) スキャン。Linux で epoll、BSD で kqueue、または移植可能な抽象(libuv)を使う。
- EAGAIN まで排出しないエッジトリガ epoll。データを逃し、サイレントメッセージロスのように見える。慎重に排出するかレベルトリガに切り替える。
- goroutine / async タスクからブロッキング syscall を呼ぶ。カーネルスレッドを固定する;同じランタイムスレッド上の他の goroutine はそれが戻るまでブロックする。 async 等価物を使うかワーカースレッドへプッシュする。
- 大型静的ファイルの提供に read+write ループ。sendfile を使う。Apache と nginx がこれを行う;あなたのカスタムファイルサーバもそうすべき。
- 同じ fd でブロッキングとノンブロッキングを混ぜる。イベントループを走らせている間に accept したソケットに O_NONBLOCK をセットするのを忘れると、 1 つの遅いクライアントが次の読みでループ全体を停滞させ得る。