Linuxのepollとio_uringの比較
(sibexi.co)- TinyGateリバースプロキシはワーカーベース構造から epoll に切り替えて性能を引き上げたが、その後限界に突き当たり io_uring で再実装された
- epollはI/O可能な時点を知らせる 準備状態モデル であり、
epoll_waitの後にread()/write()を別途呼び出す必要がある - io_uringはI/O完了を基準に動く 完了モデル で、アプリケーションとカーネルが共有リングバッファでサブミッションキューとコンプリーションキューをやり取りする
io_uring_enter()は基本的に必要だが、複数の処理を一度に投入・回収でき、IORING_SETUP_SQPOLLはsyscallを減らす代わりに CPU使用量 をコストとして伴う- kernel v5.1+ を使う最新のLinuxサーバーで新規プロジェクトを始めるなら、epollより io_uring のほうが適した選択肢と評価される
TinyGateが示したepollの限界
- TinyGateは学生たちと作った リバースプロキシサーバー で、最初のバージョンは単純なワーカーベース構造だった
- 教育用プロジェクトとしては動作したが、nginxやhaproxyのようなツールと比べるとアーキテクチャ上の限界が大きかった
- 2番目のバージョンは epollベース に変わり、初代バージョンより性能が大きく向上した
- ただしベンチマークでは依然としてnginx/haproxyを上回れなかった
- その後、epollの限界のため io_uring に移行し、プロジェクトを最初から書き直すことになった
epoll: 準備状態通知と反復されるsyscall
- epollはLinuxで長く使われてきた非同期I/O管理方式で、2002年にLinux kernelへ取り込まれた
- 核心はI/O可能な時点を知らせる 準備状態通知 にある
- epollは「読み取り可能または書き込み可能」であることを知らせる
- 実際のデータの読み書きは、その後に
read()またはwrite()syscall をアプリケーションが実行する
- 一般的な流れではイベントごとにsyscallのコストが繰り返される
epoll_ctlはファイルディスクリプタを登録する一回限りのsyscallである- 実際のI/Oイベントごとに
epoll_waitとread()/write()が必要になる - 結果として、イベント処理には追加のsyscallが継続的に発生する
- syscallはユーザーモードとカーネルモードの間で コンテキストスイッチ を生み、接続数が増えるほどオーバーヘッドが大きくなる
io_uring: 完了モデルと共有リングバッファ
- io_uringはepollがLinux kernelに入ってから約17年後の2019年に登場し、kernel v5.1+ でサポートされる
- epollと異なり、I/Oが可能かどうかではなく I/Oが完了したか を基準に動作する
- アプリケーションとカーネルは共有メモリ上のリングバッファを一緒に使う
- サブミッションキューにはアプリケーションがカーネルに依頼する処理を入れる
- コンプリーションキューにはカーネルが完了結果を返す
- 基本設定では、カーネルにサブミッションキューを確認させるため
io_uring_enter()を呼び出す必要がある- 一度の呼び出しで複数の処理を投入し、複数の完了を回収できる
- epollと
read()の組み合わせのように、処理ごとにsyscallのペアを繰り返す構造ではない
IORING_SETUP_SQPOLLを使うと、カーネルスレッドがサブミッションキューをポーリングする- 通常運用状態ではsyscallをほぼなくせる
- キューが空でもカーネルスレッドが回り続けるためCPUを消費する
sq_thread_idleの後はsleepに移行するが、コストが消えるわけではない
コード例で見る違い
-
epollの例
stdinのファイルディスクリプタを登録し、イベントが来たら別途read()を呼び出すepoll_create1でepollインスタンスを作るepoll_ctlでSTDIN_FILENOを登録するepoll_waitで読み取り可能になるまでブロックする- イベントが来たら
read()syscall でデータを読む - この流れでは、実際のI/Oイベントごとに
epoll_waitとreadが必要になる
-
io_uringの例
liburingを使うio_uring_queue_initでリングを初期化するio_uring_get_sqeでサブミッションキューエントリを取得するio_uring_prep_readでstdin読み取り処理を準備するio_uring_submitで投入し、io_uring_wait_cqeで完了を待つ- io_uringの例には別途 準備状態の確認 がなく、完了時点で別に
read()を呼び出さない - 単純化のため、両方の例では重要な例外処理が省かれている
stdinにデータがなければ永遠にブロックされる可能性がある- io_uringの例は、サブミッションキューが満杯のとき
io_uring_get_sqe()がNULLを返すケースをチェックしていない
io_uringを使う際の追加条件
- zero-copy I/O を使うには、
io_uring_register_buffers()でバッファを事前登録する必要がある- 処理ごとにカーネルがメモリを再マッピングすることを避けられる
- ネットワーク送信では、kernel 6.0+ の
IORING_OP_SEND_ZCがバッファをカーネルへコピーしない送信を提供する
IORING_SETUP_SQPOLLはsyscallを減らせるが、代償は CPU使用量 である- キューが空でもカーネルスレッドが継続してポーリングする
- アイドルタイムアウト後にsleepへ移行できるが、コスト自体がなくなるわけではない
- io_uringのエラーは、同期syscallの直接の戻り値ではなく、コンプリーションキューエントリの
resフィールドとして非同期に返ってくる- エラー処理は
cqe->resを通じて行う必要がある
- エラー処理は
最新のLinuxサーバーでの選択
- epollはI/O可能時点の通知と別個のsyscall呼び出しに基づく、古くからあるLinuxの非同期I/O方式である
- io_uringは最新のLinuxで 完了ベースモデル とバッチ投入・完了処理を提供する
- 現代のLinuxサーバーで最初から新しいプロジェクトを作るなら、io_uring を選ぶほうが自然である
- 古いシステムのサポートを妥当な時点で打ち切れるなら、kernel v5.1+ 環境ではepollを選ぶ理由はあまり多くない
1件のコメント
Hacker Newsのコメント
GitHub リポジトリ https://github.com/sibexico/TinyGate をざっと見たが、CPU ピニングはまだ使っていないようだ
スレッドと listen ソケットを CPU に固定し、
sockopt SO_INCOMING_CPUを使えば、もう少し性能を引き出せるoutbound ソケットまで CPU アラインメントを取れればかなり大きな改善になるはずだが、知る限りそのための良い API はない。Linux には対応 NIC 向けの トラフィックステアリング/フローステアリング API があり、NIC が使うハッシュが何か—おそらく Toeplitz だろう—を分かっていれば、バックエンド向けのソースポートをうまく選んでハッシュを合わせられる
目的は、プロキシが CPU 間通信なしでパケットを処理できるようにすることだ
https://github.com/concurrencykit/ck と https://github.com/microsoft/mimalloc は見ておくとよさそうだ。ゼロコピーでメモリアラインされたリバースプロキシにかなり合うはず
DDoS 防御やより高度な L4 機能を入れたいなら、https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ も確認する価値がある
本当に良い記事だ
この記事のせいで、
uring、カーネル開発、C を掘り下げるウサギの穴に落ちた。Rust と C++ の開発はかなり長くやってきたが、小さくて適度な規模の C プログラム には単純さと芸術的な趣さえあるio_uringベースの Web サーバーでは、まだ 共有バッファ はテストしていない。ファイルから read して write する代わりに、mmapされた領域から直接送っているためだ実際には
io_uringでsendfileを使いたいのだが、まだサポートされていないRust と kTLS という流行語を添えた記事: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
HN にも投稿されていた: https://news.ycombinator.com/item?id=44980865
splice(2)は実装されているので、uringで sendfile 的なやり方 を使える。sendfileほど使いやすくはないが、ほぼ同じように動くはずだDPDK で作ればかなり複雑にはなるが、性能面では nginx を圧倒するチャンス がある
FPGA で動かせるようにすればさらに複雑になる
教訓は、性能のためには抽象化を熱いナイフでバターを切るように突き抜けていく姿勢が必要だが、その分あらゆることが難しくなるということだ。ソケットと接続ごとのスレッド方式は、ネットワークが CPU に比べて非常に遅かった時代には良いアプローチで、今でも依然として最も単純なやり方であることが多い
私もずっとこれが気になっていて、核心的な違いを身につけるために最近 HTTP ファイルサーバー の実装をいくつか書いてみた
https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...
プロキシの文脈では
epoll_waitのビジーポーリング にも触れるべきだ。最近、低レイテンシのオプションを検討していて見ていたのだが、DPDK/VMA/io_uring なしでも、単なるソケットだけでユーザー空間ビジーポーリングに近いことができそうで、Fastly がこれに貢献して実運用しているあまりに低レベルなので全体を理解したとは言えず、概念だけ把握した程度だがリンクを置いておく。NAPI の
epollコンテキストごとにしか動かず、NAPI ID を簡単に制御することもできないが、マシン全体をプロキシ専用に使うなら、NAPI ID ごとにソケットを専用ポーラーへ割り当てる簡単なハックは可能だ私の用途はプロキシではなく、1 台のマシンで N 個のソケットをポーリングして受信データを処理することだった。そういう場合には実現可能には見えず、単一スレッドで NAPI コンテキストをラウンドロビンでポーリングすれば可能かもしれない。いつかカーネルに「信じてくれ、この単一ソケットは最終的に自分でポーリングするから、IRQ パスは絶対に使わないでくれ」と簡単に伝えられるようになるといいのだが
このカーネル機能に関する以前の HN 議論: https://news.ycombinator.com/item?id=43749271
Fastly の貢献者による優れた発表資料で、大局を理解しやすい図がある: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
LWN の記事: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
カーネルドキュメント: https://docs.kernel.org/networking/napi.html#irq-mitigation
C++と非同期ネットワーキングが好きなら、Boost.Asioがある
epollイベントループに置き換えたところ、RPSが約**16%**改善した。中規模のSQLサーバーでの結果なので、よくラップされたライブラリを使うときは注意が必要epollバックエンドをio_uringに置き換えたところ、CPU使用率が大きく上がった。使い方やイベントコードへの統合方法によって大きく変わる可能性がある2050年ごろには、Linuxでソケットをポーリングする方法が20種類くらいになっていそう
io_uringの中でもそうだ。より高速化するためにio_uringのワンショット方式が出てきて、その次にはマルチショット方式まで生まれたその通り、
io_uringはepollより明らかに速い。私の場合、io_uringのほうが毎秒リクエスト数ベースで**約20%**速かった気がする問題は、カーネルで明示的に有効化する必要があり、セキュリティ上の理由からほぼあらゆる場所で無効化されている点だ。カーネルとユーザー空間の間で直接メモリ共有があるようで、かなり気味が悪い。最近は
io_uringを狙ったエクスプロイトも何度かあったそのため、Goのように可能な限り最高性能を狙うエンジニアリングプロジェクトでも、
io_uringを妥当なデフォルトとして深く組み込んではいない。リスクを取りたいなら、好きな言語で直接動かすことはできる。より高速だが、その代償は潜在的なエクスプロイトの可能性だepollではなくpollで作った自分のPOSIX方式io_uringエミュレーションが、io_uringより速かったこともある。ただし、大きなゼロコピー・バッファではio_uringが最高だio_uringは非同期I/Oでなくても有用だ。たとえば、mkdirのあとにそのディレクトリを開くといった処理チェーンを、単一の原子的な操作のように実装できるネットワーキングで毎秒パケット数を最大化しようとすると、カーネルの限界[1]に非常に早く突き当たり、結局はGSO/GROのような機能を活用するか、ネットワークスタックを完全にバイパスする必要がある
1: https://github.com/axboe/liburing/discussions/1346
io_uringを完全にサポートしている。ごく最近のことだが、これで多くの企業向けLinux導入環境が含まれる。GeminiはUbuntuとSuSEもサポートしていると「言っていた」が、それを証明するリンクは示さなかったhttps://access.redhat.com/solutions/4723221
Goもサポートを再検討すべきだ。一度やってみる価値はある
io_uring機能検出**を行うという選択肢もあるのではないか。エクスプロイトはio_uringを使うと決めたプログラムだけの問題ではなく、OS全体の問題ではないのか?io_uring—は、結局のところメモリ分離をユーザー側が責任を持って扱わなければならない性質が強いただし
io_uringの場合、リングはカーネル内にあるので、ユーザーにできることはあまり多くないLLMのおかげで今後改善することを期待しているが、解決が難しい問題だ。カーネル自体で対処するのも非常に難しく、人々もそれをどうチューニングすべきかをきちんと理解していないことが多い