Linux 非同期 I/O: Epoll vs. io_uring
io_uring は Linux における非同期 I/O の現代的な標準であり、完了ベースのモデルを提供することで、大量のネットワークトラフィックを処理するために必要なシステムコールの数を劇的に削減します。epoll は数十年にわたり業界標準でしたが、io_uring はレディネス通知に関連する冗長な syscall を排除し、開発者が操作をバッチ処理してより高いスループットを実現することを可能にします。
レディネスモデル: epoll
Epoll はレディネスベースのシステムです。ファイル記述子が I/O 操作の 準備ができた 状態であることをアプリケーションに通知しますが、操作自体は実行しません。これにより、イベントごとに必要なシステムコールの数が増え、重大なパフォーマンスのボトルネックが生じます。
- Registration:
epoll_ctlを呼び出してソケットを登録する、一度限りの呼び出し。 - Notification: どのソケットが準備できているかを判断するために
epoll_waitを呼び出す。 - Execution: 実際にデータを移動させるために、個別の
read()またはwrite()呼び出し。
各システムコールがユーザーモードとカーネルモード間のコンテキストスイッチをトリガーするため、数千の同時接続を処理する場合、オーバーヘッドが非常に大きくなります。これは、単純な epoll ベースのプロキシが Nginx や HAProxy のような高度に最適化されたツールに及ばないことがよくある、先験的なアーキテクチャ上の制限です。
完了モデル: io_uring
io_uring は Linux カーネル v5.1 (2019) で導入され、アーキテクチャをレディネスから完了へと移行させます。io_uring は、アプリケーションに「読み取りが 可能 になった」と伝えるのではなく、「読み取りが 完了した」と伝えます。
仕組み
io_uring は、アプリケーションとカーネル間で共有される 2 つのリングバッファを利用します:
- Submission Queue (SQ): アプリケーションがここに I/O リクエストを配置します。
- Completion Queue (CQ): カーネルが完了した操作の結果をここに配置します。
この共有メモリアーキテクチャにより、アプリケーションは複数の I/O 操作をバッチで送信し、単一の io_uring_enter() システムコールで複数の完了通知を受け取ることができます。場合によっては、オーバーヘッドをほぼゼロにまで削減できます。
高度なパフォーマンス機能
- SQPOLL (Submission Queue Polling):
IORING_SETUP_SQPOLLを使用すると、専用のカーネルスレッドが Submission Queue をポーリングします。これにより、定常状態ではアプリケーションがio_uring_enter()を呼び出す必要が完全になくなりますが、キューが空の状態でもスレッドが回転するため CPU 使用率が増加します。 - Zero-Copy I/O: 開発者は
io_uring_register_buffers()を使用して、操作ごとにカーネルがメモリを再マッピングするのを避けることができます。ネットワーク送信の場合、IORING_OP_SEND_ZC(カーネル 6.0+ で利用可能) を使用すると、カーネルがバッファをカーネル空間へコピーすることを完全にスキップできます。
比較まとめ
| 機能 | epoll | io_uring |
|---|---|---|
| Model | レディネス (可能になった時に通知) | 完了 (完了した時に通知) |
| Syscall Overhead | 高い (イベントごとに 2+ 回の syscall) | 低い (バッチごとに 1 回の syscall、または SQPOLL で 0 回) |
| Kernel Version | レガシー (2002年以降) | モダン (v5.1+, 2019年) |
| Complexity | 比較的単純 | 高い (リングバッファの管理が必要) |
実装上の考慮事項とトレードオフ
io_uring は優れたパフォーマンスを提供しますが、同時に特定のエンジニアリング上の課題とセキュリティ上の懸念を引き起こします:
セキュリティと安定性
一部の環境では、セキュリティリスクのため、io_uring がデフォルトで無効化されています。カーネルとユーザーランドの間で直接メモリを共有するため、複数のエクスプロイトの標的となってきました。これが、Go のような一部の高パフォーマンス・ランタイムが、デフォルトではこれを使用しない理由です。
エラーハンドリング
io_uring のエラーは、同期的な syscall がエラーを即座に返すのとは異なり、非同期的に返されます。エラーは Completion Queue Entry (CQE) の res フィールドに格納されるため、エラー管理には異なるアプローチが必要です。
さらなるパフォーマンスの最適化
To push performance beyond the limits of io_uring, 開発者は以下の手法を探索できます:
- CPU Pinning: スレッドや listen ソケット (
SO_INCOMING_CPU) を特定のコアに固定 (Pinning) して、CPU 間の通信を避ける。 - Memory Alignment: メモリ整合性のあるバッファのために、
mimallocやconcurrencykitのような特殊なアロケータを使用する。 - Kernel Bypass: 極端なケースでは、DPDK (Data Plane Development Kit) を使用してカーネルを完全にバイパスすることが可能ですが、これは実装の複雑さを大幅に増加させます。