- 高性能Webサーバーを作るため、従来は select()、poll()、epoll などさまざまなイベント駆動モデルが使われてきた
- しかし、これらのシステムコールには性能上の限界があり、io_uring が登場して、リクエストをキューに入れてカーネルが非同期に処理する方式が導入された
- kTLS はカーネルがTLS暗号化処理を担当し、sendfile() の利用可能性やハードウェアオフロード など追加の最適化が可能になる
- Descriptorless files の導入により、ファイルディスクリプタを直接渡さずに io_uring に最適化されたアクセス方式を提供する
- Rust、io_uring、kTLS を組み合わせた tarweb オープンソースプロジェクト を通じて、リクエストごとの追加システムコールなしでHTTPSを提供し、安全性とメモリ管理に関する課題も議論している
高性能Webサーバー構造の進化
- 2000年代初頭から大規模Webサーバーへの要求が高まった
- 初期には各リクエストごとに新しいプロセスを生成する方式が一般的だったが、高コストという問題から preforking 手法が登場した
- その後、スレッド の導入や select()、poll() の活用を経て、コンテキストスイッチのコストを減らす方向へ発展した
- ただし、select() や poll() 方式も接続数が増えるほどカーネルに大きな配列を頻繁に渡す必要があるため、スケーラビリティに限界がある
epollの登場
- Linux環境では epoll が導入され、従来方式より効率的な多重接続処理が可能になった
- epoll は変更点(デルタ)だけを処理することで、不要なリソース消費を減らす
- すべてのシステムコールが完全になくなるわけではないが、コストは大幅に下がる
io_uring 概要
- io_uring は各リクエストごとにシステムコールを呼ぶ代わりに、カーネルが非同期に処理できるようリクエストをメモリ上のキューに追加する
- たとえば accept() をキューに入れておけば、カーネルが処理後に完了キューへ結果を返す
- Webサーバーはキューにリクエストを追加し、結果は別のメモリ領域で確認する仕組みで動作する
- ビジーループを避けるため、キューに変化がなければWebサーバーとカーネルの双方が必要なときにだけシステムコールを呼び、省電力効果を得る
- 適切なライブラリを使えば、稼働中のサーバーはリクエスト処理中に追加のシステムコールなしで動作できる
マルチコアとNUMA環境
- 現代CPUのマルチコア環境を踏まえ、コアごとに単一スレッド で実行し、データ構造の共有を最小限にする戦略が有効である
- NUMA環境では各スレッドが自分のローカルノードのメモリにのみアクセスするよう最適化する
- リクエスト分配を完全に均等化するには追加の研究が必要である
メモリ割り当て
- カーネル側とWebサーバー側の両方でメモリ割り当ては依然として必要であり、ユーザー空間での割り当ても最終的にはシステムコールにつながる
- Webサーバー側では接続ごとに固定サイズのメモリブロックをあらかじめ割り当て、断片化や不足の問題を防ぐ
- カーネル側でも接続ごとに入出力バッファが必要であり、ソケットオプションなどで一部調整できる
- メモリ不足が発生すると深刻な障害につながる可能性がある
kTLS(カーネルTLS)紹介
- kTLS は Linuxカーネルで暗号化および復号処理を担う機能である
- ハンドシェイクはアプリケーション側で処理するが、その後はカーネルが平文を扱うようにデータ転送を処理する
- sendfile() が利用可能になり、ユーザー空間とカーネル空間の間のメモリコピーを減らせる
- ネットワークカードが対応していれば、暗号化処理までハードウェアにオフロードできる利点がある
Descriptorless Files
- ユーザー空間からカーネル空間へファイルディスクリプタを直接渡す際に発生するオーバーヘッドを減らすために登場した方式である
- register_files を使って io_uring にのみ有効な別の「整数」ファイル番号を用い、/proc/pid/fd には表示されない
- システムの ulimit 制限は引き続き適用される
tarwebプロジェクト紹介
- tarweb は上記すべての技術を適用した例示的なWebサーバーのオープンソースプロジェクトである
- 単一の tar ファイルの内容を配信する構造で、Rust、io_uring、kTLS など最新の高性能技術が組み合わされている
- 実運用の過程で io_uring と kTLS の互換性問題(setsockopt 未対応など)があり、Pull Request によって一部の問題を解決した
- プロジェクトはまだ未完成段階であり、Rust の rustls ライブラリがハンドシェイク過程でメモリ割り当てを行う場合がある
- 核心は 各リクエストごとに追加システムコールなしでHTTPSサービスが可能 だという点である
ベンチマークと性能測定
- 著者はまだ十分なベンチマークを行っておらず、コード整備後に性能テストを実施する予定である
io_uring と Rust の安全性の問題
- 同期システムコールとは異なり、io_uring では完了イベント以前にメモリバッファが解放されてはならない
- io-uring クレートは Rust のコンパイル時安全性を保証せず、ランタイムチェックも不十分である
- 誤用すると C++ に似た深刻な問題につながる可能性があり、Rust 本来の安全性が弱まる
- pinning と borrow checker を積極的に活用する別の safer-ring クレートが必要である
- この問題はすでにコミュニティで議論されている
参考および追加リンク
- 本内容は 2025-08-22 時点で HackerNews で議論された投稿である
1件のコメント
Hacker Newsのコメント
io_uringを使って書き込み処理を投入する際、メモリ位置が解放されたり上書きされたりしないようにしなければならないが、io-uring crateのAPIではこの点でRustのborrow checkerが助けにならず、ランタイムチェックもないように見える
この状況について書かれた記事やコメントを読んだが、結局のところ、io_uringを包む安全なRust非同期ライブラリを作るのは本当に難しいという印象だ
tokioチームのAliceが最近、この問題を乗り越えようという関心はあまり高くないと述べていたのも覚えている
今の性能が「十分に良い」状態だからだ
参考: https://boats.gitlab.io/blog/post/io-uring/
Rust asyncについて不満は多いが、その一つがこういう点だ
Rust asyncはepollが標準だった時代に設計され、IOCPにはほとんど配慮していなかった
同期syscallでこの問題が起きないのは、read呼び出し時にバッファの可変参照をカーネルへ渡しても、ネイティブなRustのownership/borrowモデルとうまく噛み合うからだ
しかしcompletion-based IOを所有権モデルにきちんと合わせるには、処理が完了するまでユーザーコードが継続して実行されないことを保証する必要があり、これはstate machine polling構造ではできない
ここではスレッディングモデルやgreen thread構造がぴったり合う
もしRustが「async専用ターゲット」を追加していたら、もっとよかった気がする
Rust開発陣は非同期のstackless pollingモデルに大きな期待をかけていたが、その結末を見守っているところだ
Rustのborrow checkerでは適切に扱えない所有権モデルがあると思う
仮に「ホットポテト所有権」と呼んでいるが、バッファを一時的に渡して、また返してもらう構造だ
Rustでこうしたパターンを安全にコード化しようとすると、とても難しく、コードも雑然としてしまう
tokioチームのAliceの発言とは違って、ファイルIOの方には関心がある
ファイルIOはすでにspawn_blocking方式で実装されていて、io_uringと同じバッファ問題を抱えており、io_uringへ移行するのはそれほど難しくない
ただしtokio::netの既存APIはio_uringベースのバッファAPIと互換性がないため、readinessチェックはできても完全なサポートは難しい
安全なio_uringインターフェースを作るには、リングが所有するバッファを受け取って使い、書き込み開始時にそれを返す方式が最も適していると思う
必ずしもすべてをborrowsで表現する必要はない
Slabのようなデータ構造を使えばcancel safeにできる
参考: https://github.com/steelcake/io2
今回の記事は本当に面白く読めた
性能テストが楽しみではあるが、筆者がベンチマークより先にコードをきれいに整えると言っていた点が印象的だった
ベンチマークばかり重視されるこの時代に、こういうことを考える人がいるのは新鮮だ
11歳ごろにデータベースを構築しようとしてcgi-binに触れ、それがリクエストごとに新しいプロセスを起動する方式だったことを今になって理解した
sendfileが大規模ゲームフォーラムでデモのダウンロードを同時処理する際のゲームチェンジャーであり、Netflixの40ms削減事例やGTA 5の読み込み時間70%短縮の結果を見ると、さらに大きなインパクトを持つエンジニアリングが隠れていると感じる
関連リンク: Common Gateway Interface, Netflix 40ms事例, GTA Onlineの読み込み短縮
CGIだけでなく、昔のCERNやApache系のHTTPセッションはサーバー全体をforkして動かしていた
時間とともに改善はされたが、Apacheの構成方式のため、nginxのように最初からイベント駆動I/Oとして設計された軽量サーバーが大きな人気を得ることになった
sendfileの効率性には懐疑的だ
90年代末には流行したが、実際の性能向上はわずかだと思う
ほとんどのクラウドワークロードオーケストレータ(CloudRun、GKE、EKS、ローカルDockerなど)はio_uringをデフォルトで無効化している
この点が改善されない限り、当面のあいだio_uringは非常に限定的な技術のままになりそうだ
なぜ彼らはio_uringを無効化しているのだろう、と疑問に思う
こういう状況なら、またセルフホスティングに戻るしかない
本当に楽しく読めた
ベンチマークは待つのでゆっくりでいいし、ベンチマークより先にコード整理を重視する著者の姿勢がとても印象的だ
最近はベンチマークのスコアに全振りするプロジェクトが多いので、こういう考え方は本当に新鮮で尊敬できる
ktlsやio_uringがここまで多様に使えるとは知らなかった
現時点での非同期処理の状況は以下の通りだ
Rust: Futures、Pin、Waker、async runtime、Send/Sync bounds、asyncトレイトオブジェクトなど、多くの概念を理解する必要がある
C++20: coroutines
Go: goroutines
Java21+: 仮想スレッド
C++コルーチンは、Pinが解決する問題を避けるためにヒープ割り当てを使っている
これはC++が掲げる「ゼロオーバーヘッド」原則から大きく外れている
Rustでasyncトレイトの導入に時間がかかったのも、Rustがfuturesをヒープ割り当てしないからだ
パフォーマンス/移植性と複雑さのトレードオフは、各プロジェクトによって価値づけが異なりうる
Send/Sync関連の制約は他の言語でも依然として意味があり、それがなければ微妙に誤ったコードをより書きやすくなる
「十分にまともな」Rustコードを書き、誰かが作った中レベルのプリミティブを使うのであれば、わざわざそうした概念をすべて知る必要はない
Rustはそうした概念を理解しないとそもそもコンパイルが通らないように強制する
Goではgoroutineは非同期そのものではなく、チャネルを理解しなければgoroutineも理解できない
Goのチャネル実装は独特で、境界ケースでの振る舞いが常識的には予測しにくい
Goは深く理解していなくてもコーディングできるので、一長一短がある
「安価なスレッド」は非同期と同じではない
tarweb(ブログに出てきたサーバー)はio_uringベースのイベントループによるシングルスレッド構造で、CPUコアごとに1本ずつスレッドを置くという発想だ
「大規模並行性の現状」よりも「安価なスレッドの現状」の方が適切な表現かもしれない
cheap threadとasync loopの最大の違いは、reasoningしやすいことだ
欠点もあり、各スレッドは軽量とはいえスタックサイズを必要とする
kTLSは間違いなく前進だ
私も数年前に本当にリクエストあたりsyscallが0のサーバーを作ってブログに書いたことがある(https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
ただし、常にbusy-loopingしなければならないという欠点がある
io_uringはこの数年で本当に驚くほどの速さで進化してきた
このプロジェクトは本当に素晴らしいし、かなり前から似たようなものを構想していたので、誰かが実装してくれてうれしい
BPFもRustで書くならAyaを勧める
Ayaプロジェクト Github
kTLSの現在の状況が気になる
少し前にCiliumの開発者に聞いたところ、Thomas Grafは期待しているものの、実際には多くのLinuxディストリビューションでカーネルサポートが不十分で、デフォルト有効化まではまだ遠いとのことだった
残念ではあるが、有効化がどれほど難しいのかも気になる
カーネルをカスタムしなければならないのか、それともランタイムですぐ有効にできるのか
FreeBSDは13版からカーネル/OpenSSLにkTLSが入り、sysctl (
kern.ipc.tls.enable=1) でランタイム切り替えが可能だFreeBSD-15ではデフォルトで有効になる予定で、Netflixではほぼ10年にわたりトラフィック暗号化にkTLSを使ってきた
kTLSは全体として悪いアイデアのように感じる
1コアあたり1スレッド構造がタイムスライスベースのシステムに合っているのか疑問だ
私の経験では、「オーバーサブスクライブ」方式(コア数より多くスレッドを置くこと)の方が、実際の壁時計時間で利得がある
プリエンプティブスケジューリングがない場合や、1コアあたり1本の方が適しているのではないか
もちろん、そうなるとUnixの話ではないが
低遅延と高スループットが欲しいなら、コアを分離してスレッドを固定する方法が効果的だ
こうした方式はLinuxでうまく機能し、トレーディングシステムなどでは非効率を受け入れてでも広く使われている
コアの大半はアイドル状態でspinし、実際には仕事がなくても、レイテンシとスループットの面では最適だ
thread-per-core構造の落とし穴は、「都合のいい部分だけ使おう」と勘違いすることだ
実際には全面的に採用するか、使わないかのどちらかになる
中途半端な実装ではまったく効率が出ない
ただし正しく設計すれば、ほぼあらゆる状況で高い効率を出せる
TPC設計のノウハウ(コア間ロードバランシングなど)をよく知る開発者は少ない
thread-per-coreは「CPUバウンド」なときにだけ効率的だ
このサーバープロジェクトのように、ほとんどの作業が非同期かつイベント駆動であるなら、サーバーはほぼI/Oやsyscall待ちなしに次のリクエストへ進めるので、理論上はコアごとに1本のスレッドが正しい構造だ
しかし現実世界ではこうした理想的状況はほとんどないため、無条件にnprocスレッドへ制限するのは危険だという点は覚えておくべきだ
io_uringでは、1コアあたりユーザースレッド1本だけにするのも悪い選択ではないと思う
カーネル側がスレッドプールとして動作するからだ
DPDKのようにカーネルを完全に迂回するスタイルも見てみたい
論文リンク: https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf