Rustでの高スループットLinux I/O: io_uring、Tokio、ゼロコピーネットワーキング

目次(16 項目)
LinuxのI/Oパフォーマンスは、大規模なネットワークサービスにとって極めて重要です。従来のepollベースの非同期I/Oは効率的ですが、繰り返されるシステムコールやカーネル空間とユーザー空間間のデータコピーにより、依然としてかなりのオーバーヘッドが発生します。Linuxカーネル5.1で導入されたio_uringは、システムコールを最小限に抑え、ゼロコピー操作を可能にする強力な非同期I/Oインターフェースを提供することで、これを根本的に再構築しました。このガイドでは、Rustでio_uringを活用する方法、特に高スループットでゼロコピーのネットワークのためにtokioと統合する方法について詳しく説明します。
io_uringのパラダイムシフト
io_uringは、ユーザー空間とカーネル間の共有リングバッファメカニズムで動作します。各I/O操作に対して個別のシステムコールを発行する代わりに、アプリケーションはSubmission Queue (SQ)にSubmission Queue Entries (SQEs)をエンキューします。その後、カーネルはこれらのSQEを非同期に処理し、操作が完了するとCompletion Queue (CQ)にCompletion Queue Entries (CQEs)を配置します。このバッチ処理により、コンテキストスイッチとシステムコールのオーバーヘッドが大幅に削減されます。
io_uringの主な機能:
- バッチ処理: 複数のI/O操作を単一の
io_uring_enterシステムコールで発行します。 - 非同期: 呼び出し元のスレッドをブロックすることなく操作が完了します。
- ポーリング: カーネルがSQを積極的にポーリングできるため、場合によっては
io_uring_enterが不要になり、レイテンシがさらに削減されます。 - ゼロコピー:
IORING_OP_SENDMSGやIORING_OP_RECVMSGのような操作は、ユーザー空間バッファで直接動作できるため、データコピーを回避できます。 - 固定バッファ/ファイル:
io_uringにバッファとファイルディスクリプタを登録することで、カーネルはアクセスを最適化し、繰り返しのルックアップを回避できます。
epoll vs. io_uring: アーキテクチャ比較
| 機能 | epoll (標準Tokio) | io_uring (tokio-uring) |
|---|---|---|
| I/Oモデル | エッジトリガー、イベント駆動型 | 非同期、リングバッファ |
| システムコールオーバーヘッド | 高 (操作ごとに1システムコール + epoll_wait) | 低 (バッチ処理、多くの操作でio_uring_enter) |
| データコピー | read/writeのユーザー-カーネルコピー | ゼロコピー可能 (MSG_ZEROCOPY) |
| バッファ管理 | ユーザー管理、システムコールごとに渡される | カーネル登録済み固定バッファ |
| ファイルディスクリプタ | システムコールごとに渡される | カーネル登録済み固定ファイル |
| レイテンシ | システムコール/コピーにより高 | バッチ処理/ゼロコピーにより低 |
| スループット | 多くの接続には良好、システムコールにより制限される | 大量のI/Oには優れている |
| カーネルバージョン | Linux 2.5.44+ | Linux 5.1+ |
| 複雑性 | よりシンプルなAPI | より複雑なAPI、学習曲線が高い |
Rust統合: tokio-uring
直接的なio_uringバインディング(例: io-uringクレート)は存在しますが、io_uringを既存の非同期ランタイム(tokioなど)と統合することが望ましい場合がよくあります。tokio-uringクレートは、io_uring上に構築されたtokio互換ランタイムを提供します。これは、TcpStream、UdpSocket、ファイルI/Oなどの一般的なtokio I/Oプリミティブに対応するio_uringベースの同等機能を提供します。
tokio-uringのセットアップ
Cargo.tomlに以下を追加します。
[dependencies]
tokio-uring = { version = "0.6", features = ["net", "fs"] }
bytes = "1.4"
log = "0.4"
env_logger = "0.10"
net機能はTcpStream、UdpSocketなどを有効にし、fsはファイルI/Oを有効にします。
基本的なtokio-uring TCPエコーサーバー
この例では、tokio-uringを使用したシンプルなTCPエコーサーバーを示します。tokio_uring::start()マクロは、io_uringランタイムを初期化することに注意してください。
use tokio_uring::net::{TcpListener, TcpStream};
use tokio_uring::buf::IoBuf;
use bytes::{BytesMut, BufMut};
use log::{info, error};
const BUFFER_SIZE: usize = 4096;
#[tokio_uring::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let addr = "127.0.0.1:8080";
let listener = TcpListener::bind(addr).await?;
info!("Listening on {}", addr);
loop {
match listener.accept().await {
Ok((socket, peer_addr)) => {
info!("Accepted connection from {}", peer_addr);
tokio_uring::spawn(async move {
if let Err(e) = handle_connection(socket).await {
error!("Error handling connection from {}: {}", peer_addr, e);
}
});
}
Err(e) => {
error!("Error accepting connection: {}", e);
}
}
}
}
async fn handle_connection(mut socket: TcpStream) -> Result<(), Box<dyn std::error::Error>> {
let mut buf = BytesMut::with_capacity(BUFFER_SIZE);
loop {
// Prepare a buffer for receiving data.
// `bytes::BytesMut` implements `IoBuf`, making it suitable for `tokio-uring`.
let (res, filled_buf) = socket.recv(buf.split_off(0).limit(BUFFER_SIZE)).await;
let bytes_read = res?;
if bytes_read == 0 {
info!("Client disconnected.");
break; // Client disconnected
}
// Advance the buffer to reflect the bytes read.
// `filled_buf` is the original buffer, but its `IoBuf` trait implementation
// ensures it's correctly sliced for the `recv` operation.
// We need to manually advance the `BytesMut` to reflect the data.
buf.unsplit(filled_buf);
buf.advance_mut(bytes_read);
info!("Received {} bytes: {:?}", bytes_read, &buf[..bytes_read]);
// Send the received data back.
// `send` takes an `IoBuf` and returns the buffer back after completion.
let (res, _) = socket.send(buf.split_to(bytes_read)).await;
let bytes_written = res?;
info!("Sent {} bytes.", bytes_written);
if bytes_written == 0 {
info!("Failed to send data, client likely disconnected.");
break;
}
}
Ok(())
}
これをテストするには、netcatを使用できます: nc 127.0.0.1 8080。テキストを入力してEnterキーを押すと、サーバーがそれをエコーバックします。
MSG_ZEROCOPYによるゼロコピーネットワーキング
ネットワーキングにおけるio_uringの真の力は、ゼロコピー操作を実行できることにあります。これは、MSG_ZEROCOPYフラグを使用したIORING_OP_SENDMSG操作によって実現されます。このフラグが設定されると、カーネルはユーザー空間バッファを自身のメモリ空間に直接マッピングして送信するため、従来のcopy_from_userシステムコールを回避できます。バッファが再利用可能になったとき(つまり、データが送信されたか、送信キューに入れられたとき)に、CQEを介してアプリケーションに通知されます。
tokio-uringは、TcpStreamのsend_zcメソッドを通じてこの機能を提供します。
ゼロコピーTCPストリーミングの実装
この例では、データを受信し、ゼロコピーを使用して固定応答を送信するサーバーを示します。重要なのは、バッファのライフサイクルを慎重に管理することです。
use tokio_uring::net::{TcpListener, TcpStream};
use tokio_uring::buf::IoBuf;
use bytes::{BytesMut, BufMut};
use log::{info, error};
use std::sync::Arc;
const BUFFER_SIZE: usize = 4096;
const RESPONSE_DATA: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello, World!";
#[tokio_uring::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let addr = "127.0.0.1:8080";
let listener = TcpListener::bind(addr).await?;
info!("Listening on {}", addr);
loop {
match listener.accept().await {
Ok((socket, peer_addr)) => {
info!("Accepted connection from {}", peer_addr);
tokio_uring::spawn(async move {
if let Err(e) = handle_zero_copy_connection(socket).await {
error!("Error handling zero-copy connection from {}: {}", peer_addr, e);
}
});
}
Err(e) => {
error!("Error accepting connection: {}", e);
}
}
}
}
async fn handle_zero_copy_connection(mut socket: TcpStream) -> Result<(), Box<dyn std::error::Error>> {
let mut recv_buf = BytesMut::with_capacity(BUFFER_SIZE);
let send_buf = Arc::new(BytesMut::from(RESPONSE_DATA)); // Use Arc for shared buffer for zero-copy send
loop {
// Receive data (standard copy, as MSG_ZEROCOPY is for send)
let (res, filled_buf) = socket.recv(recv_buf.split_off(0).limit(BUFFER_SIZE)).await;
let bytes_read = res?;
if bytes_read == 0 {
info!("Client disconnected.");
break;
}
recv_buf.unsplit(filled_buf);
recv_buf.advance_mut(bytes_read);
info!("Received {} bytes from client.", bytes_read);
// Perform zero-copy send
// `send_zc` returns a future that completes when the kernel is done with the buffer.
// The buffer is returned, allowing reuse.
let (res, returned_buf) = socket.send_zc(send_buf.clone().slice(..)).await;
let bytes_written = res?;
info!("Zero-copy sent {} bytes.", bytes_written);
if bytes_written == 0 {
info!("Failed to zero-copy send data, client likely disconnected.");
break;
}
// Clear the receive buffer for the next read
recv_buf.clear();
}
Ok(())
}
これをテストするには、curlを使用できます: curl http://127.0.0.1:8080。"Hello, World!"を受け取るはずです。
ゼロコピーに関する重要な考慮事項
- バッファのライフタイム:
send_zcに渡されるバッファは、send_zcフューチャが完了するまで有効で変更されないままでなければなりません。tokio-uringはバッファを返すことでこれを処理し、バッファが時期尚早にドロップされないようにします。共有される静的データの場合、Arc<BytesMut>またはArc<[u8]>が適しています。 - カーネルサポート:
MSG_ZEROCOPYにはLinuxカーネル5.2以降が必要です。 - エラー処理:
MSG_ZEROCOPYが失敗した場合(例: カーネルメモリ不足や古いカーネルのため)、send_zcはエラーを返します。本番環境では、標準のsendへのフォールバックが必要になる場合があります。 - バッファ登録: 特に同じデータを繰り返し送信する場合のパフォーマンスを最大化するには、
io_uringを使用してバッファをIORING_REGISTER_BUFFERSに登録することを検討してください。tokio-uringはこれのためにtokio_uring::buf::BoundedBufとtokio_uring::buf::FixedBufを提供します。
10万同時WebSocket接続のベンチマーク
10万同時WebSocket接続のio_uringのベンチマークには、堅牢なセットアップが必要です。ここでは、完全なベンチマーククライアント/サーバーを提供するのではなく、アプローチと期待される結果の概要を説明します。
サーバーアーキテクチャ
tokio-uringベースのWebSocketサーバーには以下が含まれます。
tokio_uring::net::TcpListener: 新しい接続を受け入れます。- WebSocketハンドシェイク: HTTPアップグレードを実行します。この部分は標準HTTPであり、
httparseなどを使用できます。 - WebSocketフレーミング: WebSocketプロトコルのフレーミング(マスキング、オペコード、長さ)を実装します。
tokio_uring::net::TcpStream::recv: WebSocketフレームを受信するため。tokio_uring::net::TcpStream::send_zc: WebSocketフレームを送信するため、特にブロードキャストメッセージや大量のデータの場合。ここでゼロコピーが威力を発揮します。
クライアントアーキテクチャ
ベンチマーククライアントは以下を行う必要があります。
- 10万のTCP接続を確立: これには、かなりのファイルディスクリプタ制限(
ulimit -n)が必要です。 - WebSocketハンドシェイクを実行: 各接続に対して。
- 接続を維持: 接続を維持するためにping/pongを送信します。
- データの送受信: アプリケーショントラフィックをシミュレートします。
- レイテンシとスループットを測定: ラウンドトリップ時間とデータレートを追跡します。
期待されるパフォーマンス向上
io_uringとゼロコピーを使用すると、サーバーが頻繁に同一のメッセージ(市場データ、ゲームの状態更新など)を多数のクライアントに送信する場合、以下が期待されます。
- CPU使用率の削減: システムコールとデータコピーが少ないため、カーネルCPU時間が短縮されます。
- スループットの向上: 単位時間あたりに処理されるデータ量が増加します。
- レイテンシの低減: 特に送信操作の場合、データが直接送信キューに入れられるため。
- 接続密度の増加: 接続ごとのオーバーヘッドが削減されるため、サーバーインスタンスあたりでより多くの接続を処理できます。
ベンチマーク環境
- カーネル: 最適な
io_uring機能のためにLinux 5.10+ (LTS) または 6.x。 - ハードウェア: 高コア数CPU、十分なRAM、高速NIC。
ulimit -n: 100,000を超える値に設定(例:ulimit -n 1048576)。- ネットワークチューニング:
sysctlパラメータ(例:net.core.somaxconn、net.ipv4.tcp_max_syn_backlog、net.ipv4.tcp_tw_reuse、net.ipv4.tcp_fin_timeout)。
本番環境での落とし穴とトラブルシューティング
io_uringが利用できない/有効になっていない:- 症状:
io_uring操作がENOSYSまたは同様のエラーで失敗する。 - 原因: カーネルバージョン < 5.1、または
io_uringモジュールがロードされていない/コンパイルされていない。 - 修正: カーネルを5.1+にアップグレードする(安定性/機能のために5.10+を推奨)。カーネル設定で
CONFIG_IO_URING=yを確認する。
- 症状:
ulimit -nが低すぎる:- 症状: 接続を受け入れる際やソケットを作成する際に
Too many open filesエラーが発生する。 - 原因: デフォルトのファイルディスクリプタ制限(通常1024)では、高い同時実行性には不十分。
- 修正: アプリケーションを実行しているユーザーの
ulimit -nを増やす。systemdサービスの場合、サービスユニットファイルでLimitNOFILEを設定する。
- 症状: 接続を受け入れる際やソケットを作成する際に
MSG_ZEROCOPYの失敗:- 症状:
send_zcがEOPNOTSUPPまたは他のエラーを返す。 - 原因: カーネルバージョン < 5.2、または特定のネットワークドライバの制限。
- 修正: カーネルをアップグレードする。
send_zcが失敗した場合にTcpStream::sendへのフォールバックを実装する。
- 症状:
- ゼロコピーでのバッファ管理の問題:
- 症状: データ破損、use-after-freeエラー、または予期しない動作。
- 原因:
send_zcが完了してバッファを返す前に、バッファを再利用または変更している。 - 修正: バッファのライフタイムが厳密に管理されていることを確認する。
tokio-uringのsend_zcはバッファを返し、再利用可能であることを示します。共有される静的データの場合、カーネル処理中に不変性を確保するためにArcとsliceを使用します。
io_uringポーリングによる高いCPU使用率:- 症状:
io_uringワーカースレッドが低負荷でもCPUを100%消費する。 - 原因:
IORING_SETUP_SQPOLL(カーネルポーリング)が積極的すぎる場合がある。正しく設定されていない場合、スピンウェイトする可能性がある。 - 修正: ワークロードに合わせて
io_uringが適切に設定されていることを確認する。tokio-uringは通常これを管理しますが、生のio-uringを使用している場合は、ポーリングフラグに注意してください。ほとんどのサーバーワークロードでは、イベント駆動型(非ポーリング)のio_uringで十分であり、ポーリングは超低レイテンシのシナリオに限定されます。
- 症状:
- メモリプレッシャー:
- 症状: OOMエラー、過剰なスワッピング。
- 原因: 多数の接続、それぞれが独自の受信バッファを持つ、または大量の登録済み固定バッファ。
- 修正: バッファサイズを最適化する。受信操作にバッファプールを使用することを検討する。固定バッファの場合、必要なものだけを登録する。
よくある質問
- 既存の
tokioコードでtokio-uringを使用できますか? はい、tokio-uringはtokio互換ランタイムを提供します。tokio_uring::spawnを使用して、通常のtokio::spawnフューチャと並行してio_uringベースのフューチャを実行できますが、最適なパフォーマンスを得るには、io_uring固有のI/Oをtokio-uringランタイムに保持することが一般的に推奨されます。 - ネットワーキングにおいて、
io_uringがepollよりも優れている主な利点は何ですか? 主な利点は、バッチ処理によるシステムコールオーバーヘッドの削減と、ユーザー空間とカーネル間のゼロコピーデータ転送を実行できることです。これにより、特に大量のI/O操作において、CPU使用率の低下、スループットの向上、レイテンシの低減が実現します。 io_uringは常にepollよりも高速ですか? 常にそうとは限りません。低並行性、低スループットのアプリケーションの場合、io_uringのセットアップオーバーヘッドがその利点を上回る可能性があります。io_uringは、高並行性、高I/Oボリューム、またはゼロコピーが重要なシナリオで威力を発揮します。また、最新のLinuxカーネルが必要です。tokio-uringはゼロコピーのバッファ管理をどのように処理しますか?tokio-uringのsend_zcメソッドは、IoBuf(BytesMutやArc<[u8]>など)を受け取り、カーネルがバッファの処理を終えるとそれを返します。これにより、バッファのライフタイムが正しく管理され、use-after-freeの問題が防止されます。静的データの場合、Arcを使用して所有権を共有します。io_uringとMSG_ZEROCOPYのカーネル要件は何ですか?io_uringにはLinuxカーネル5.1以降が必要です。MSG_ZEROCOPYは特にLinuxカーネル5.2以降が必要です。本番環境では、安定性と機能の完全性のために、カーネル5.10 (LTS) または最近の6.xカーネルが推奨されます。
結論
io_uringは、Linuxの非同期I/Oにおける大きな進歩を表しており、高スループットアプリケーションに比類のないパフォーマンスを提供します。Rustは、その強力な型システムとパフォーマンス特性により、io_uringの機能を活用するのに理想的な言語です。tokio-uringクレートは、io_uringをtokioベースのアプリケーションに統合するための堅牢で人間工学的な方法を提供し、開発者がゼロコピーデータ転送などの機能で非常に効率的なネットワークサービスを構築できるようにします。io_uringの学習曲線は従来のepollよりも急峻かもしれませんが、要求の厳しいワークロードに対するパフォーマンス向上は大きく、投資に値します。
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

RustとCircomによるゼロ知識証明:SnarkJS検証と本番環境ガイド
RustとCircomにおけるゼロ知識証明を網羅したガイドで、SnarkJS検証と本番環境向けアーキテクチャおよびコード例を解説します。
Read more
本番環境におけるeBPF:低オーバーヘッドなLinuxオブザーバビリティ、トレーシング、カーネルプロファイリング
eBPFを使用して、オーバーヘッドを最小限に抑えたLinuxカーネルのオブザーバビリティを実装します。システムコールのレイテンシ測定、メモリ割り当ての追跡、サイドカーなしでのネットワークソケット監視を解説します。
Read more
RustとAxumで高スループットなマイクロサービスを構築する: 完全なプロダクションガイド
RustとAxumで高スループットなマイクロサービスを構築するための包括的なガイド。プロダクションレベルのアーキテクチャとコード例を交え、本番環境での利用を想定した完全なプロダクションガイドです。
Read more