1 ポイント 投稿者 GN⁺ 3 시간 전 | 1件のコメント | WhatsAppで共有
  • 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_waitread() / 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_ctlSTDIN_FILENO を登録する
    • epoll_wait で読み取り可能になるまでブロックする
    • イベントが来たら read() syscall でデータを読む
    • この流れでは、実際のI/Oイベントごとに epoll_waitread が必要になる
  • io_uringの例

    • liburing を使う
    • io_uring_queue_init でリングを初期化する
    • io_uring_get_sqe でサブミッションキューエントリを取得する
    • io_uring_prep_readstdin 読み取り処理を準備する
    • 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件のコメント

 
GN⁺ 3 시간 전
Hacker Newsのコメント
  • GitHub リポジトリ https://github.com/sibexico/TinyGate をざっと見たが、CPU ピニングはまだ使っていないようだ
    スレッドと listen ソケットを CPU に固定し、sockopt SO_INCOMING_CPU を使えば、もう少し性能を引き出せる
    outbound ソケットまで CPU アラインメントを取れればかなり大きな改善になるはずだが、知る限りそのための良い API はない。Linux には対応 NIC 向けの トラフィックステアリング/フローステアリング API があり、NIC が使うハッシュが何か—おそらく Toeplitz だろう—を分かっていれば、バックエンド向けのソースポートをうまく選んでハッシュを合わせられる
    目的は、プロキシが CPU 間通信なしでパケットを処理できるようにすることだ

    • リポジトリの v0 と v1 は、ほぼ最初から書き直した まったく別の実装 で、今は 3 つ目の実装に取り組んでおり、おそらくこれが最後になる。アーキテクチャの選択も完全に変わった
    • そのパッチの ベンチマーク を見てみたい
  • https://github.com/concurrencykit/ckhttps://github.com/microsoft/mimalloc は見ておくとよさそうだ。ゼロコピーでメモリアラインされたリバースプロキシにかなり合うはず
    DDoS 防御やより高度な L4 機能を入れたいなら、https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ も確認する価値がある

    • 計画としては、他のレイヤーで最適化を適用した後に アロケータ に移るつもりだった。今は学生たちとアロケータを勉強していて、ブログの前回の記事は Zig 言語で作ったカスタムアロケータについてのものだった
  • 本当に良い記事だ
    この記事のせいで、uring、カーネル開発、C を掘り下げるウサギの穴に落ちた。Rust と C++ の開発はかなり長くやってきたが、小さくて適度な規模の C プログラム には単純さと芸術的な趣さえある

  • io_uring ベースの Web サーバーでは、まだ 共有バッファ はテストしていない。ファイルから read して write する代わりに、mmap された領域から直接送っているためだ
    実際には io_uringsendfile を使いたいのだが、まだサポートされていない
    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) は実装されているので、uringsendfile 的なやり方 を使える。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がある

    • 最近、Asioを自作のepollイベントループに置き換えたところ、RPSが約**16%**改善した。中規模のSQLサーバーでの結果なので、よくラップされたライブラリを使うときは注意が必要
    • データベースサーバーでAsioのepollバックエンドをio_uringに置き換えたところ、CPU使用率が大きく上がった。使い方やイベントコードへの統合方法によって大きく変わる可能性がある
    • Boostはあまりにも扱いづらい。ビルドも利用も難しい巨大な動的ライブラリ群だ。すでにCMakeを使っていたのに、Boostをインストールして見つけられる状態にするまでの過程がとても面倒だった。ただし、Macでの話
  • 2050年ごろには、Linuxでソケットをポーリングする方法が20種類くらいになっていそう

    • その通り、io_uringの中でもそうだ。より高速化するためにio_uringのワンショット方式が出てきて、その次にはマルチショット方式まで生まれた
  • その通り、io_uringepollより明らかに速い。私の場合、io_uringのほうが毎秒リクエスト数ベースで**約20%**速かった気がする
    問題は、カーネルで明示的に有効化する必要があり、セキュリティ上の理由からほぼあらゆる場所で無効化されている点だ。カーネルとユーザー空間の間で直接メモリ共有があるようで、かなり気味が悪い。最近はio_uringを狙ったエクスプロイトも何度かあった
    そのため、Goのように可能な限り最高性能を狙うエンジニアリングプロジェクトでも、io_uringを妥当なデフォルトとして深く組み込んではいない。リスクを取りたいなら、好きな言語で直接動かすことはできる。より高速だが、その代償は潜在的なエクスプロイトの可能性だ

    • 無効化される主な理由は、いまや解決済みだ。最新RCにcBPFサポートが入ったことで、全面的に無効化する代わりに実行可能な処理を制限できるようになった
    • ケースバイケースだ。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
    • RHEL 9と10は現在、デフォルトでio_uringを完全にサポートしている。ごく最近のことだが、これで多くの企業向けLinux導入環境が含まれる。GeminiはUbuntuとSuSEもサポートしていると「言っていた」が、それを証明するリンクは示さなかった
      https://access.redhat.com/solutions/4723221
      Goもサポートを再検討すべきだ。一度やってみる価値はある
    • Goのようなプロジェクトなら、ランタイム起動時に一度だけ**io_uring機能検出**を行うという選択肢もあるのではないか。エクスプロイトはio_uringを使うと決めたプログラムだけの問題ではなく、OS全体の問題ではないのか?
    • あらゆる種類のポーリングモード・ネットワーキング—RDMA、DPDK、io_uring—は、結局のところメモリ分離をユーザー側が責任を持って扱わなければならない性質が強い
      ただしio_uringの場合、リングはカーネル内にあるので、ユーザーにできることはあまり多くない
      LLMのおかげで今後改善することを期待しているが、解決が難しい問題だ。カーネル自体で対処するのも非常に難しく、人々もそれをどうチューニングすべきかをきちんと理解していないことが多い