1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustで実装するTCP/UDPネットワークプログラミング:std::netとTokioの実践ガイド

1
Last updated at Posted at 2026-03-21

Rustで実装するTCP/UDPネットワークプログラミング:std::netとTokioの実践ガイド

Rustの所有権システムとゼロコスト抽象化は、ネットワークプログラミングにおいてメモリ安全性と高パフォーマンスの両立を可能にします。本記事では、標準ライブラリstd::netによる同期的なTCP/UDP実装から、Tokioを用いた非同期サーバーの構築まで、段階的に解説します。

この記事でわかること

  • Rust標準ライブラリstd::netを使ったTCP/UDPソケットプログラミングの基礎
  • Tokioによる非同期TCPサーバーの構築とグレースフルシャットダウンの実装
  • UDPマルチキャストの実装方法とsocket2クレートによる高度なソケット設定
  • 同期vs非同期アプローチの使い分け基準と性能特性の違い
  • 実運用を見据えたエラーハンドリングとタイムアウト制御のパターン

対象読者

  • 想定読者: Rustの基礎文法を習得済みで、ネットワークプログラミングに入門したいエンジニア
  • 必要な前提知識:
    • Rustの基礎文法(所有権、借用、Result型の扱い)
    • TCP/IPの基本概念(3ウェイハンドシェイク、ポート番号の役割)
    • コマンドラインでのcargo操作(Pythonでいうpip+pythonに相当)
    • async/awaitの概念(Pythonのasyncioをご存じであれば十分)

結論・成果

本記事で実装する内容を適用すると、以下のような効果が報告されています。

  • パフォーマンス: RustのTCPプロキシ実装はC/C++実装と同等の性能を達成し、Nginxの直接配信と比較して70〜80%のスループットを維持(TCP Proxy Benchmark
  • 安全性: Rustのコンパイル時チェックにより、C/C++で頻発するバッファオーバーフローやuse-after-freeを原理的に排除
  • 開発効率: std::netのシンプルなAPIにより、TCP Echoサーバーを約30行で実装可能。Tokioを使った非同期版でも約50行で並行接続処理を実現
  • 学習曲線: PythonやGoのソケットAPI経験者であれば、本記事の内容を2〜3時間で習得可能

std::netでTCP/UDPの基礎を実装する

まずはRust標準ライブラリのstd::netモジュールを使い、同期的なTCP/UDP通信を実装してみましょう。std::netはRustに組み込まれているため、外部クレートのインストールは不要です。Pythonのsocketモジュールに相当するものと考えてください。

TCP Echoサーバーを実装する

TCP(Transmission Control Protocol)は、データの順序保証と到達確認を行うコネクション型プロトコルです。Webサーバーやデータベース接続など、信頼性が求められる通信に使われます。

以下は、クライアントから受信したデータをそのまま返す「Echoサーバー」の実装です。

// tcp_echo_server.rs
use std::io::{Read, Write};
use std::net::TcpListener;

fn main() -> std::io::Result<()> {
    // 127.0.0.1:7878 でリッスン開始
    // Pythonの socket.bind(("127.0.0.1", 7878)) + socket.listen() に相当
    let listener = TcpListener::bind("127.0.0.1:7878")?;
    println!("TCP Echo Server listening on 127.0.0.1:7878");

    // incoming() は接続を待ち続けるイテレータ
    // Pythonの while True: conn, addr = socket.accept() に相当
    for stream in listener.incoming() {
        match stream {
            Ok(mut stream) => {
                let peer = stream.peer_addr()?;
                println!("New connection from: {}", peer);

                let mut buffer = [0u8; 1024];
                // read() はデータが届くまでブロック(同期I/O)
                match stream.read(&mut buffer) {
                    Ok(n) if n > 0 => {
                        println!("Received {} bytes", n);
                        // 受信データをそのまま返送
                        stream.write_all(&buffer[..n])?;
                    }
                    Ok(_) => println!("Connection closed by client"),
                    Err(e) => eprintln!("Read error: {}", e),
                }
            }
            Err(e) => eprintln!("Connection failed: {}", e),
        }
    }
    Ok(())
}

対応するクライアント側の実装です。

// tcp_echo_client.rs
use std::io::{Read, Write};
use std::net::TcpStream;

fn main() -> std::io::Result<()> {
    // サーバーに接続(3ウェイハンドシェイクが自動実行される)
    let mut stream = TcpStream::connect("127.0.0.1:7878")?;
    println!("Connected to server");

    let message = b"Hello, Rust TCP!";
    stream.write_all(message)?;

    let mut buffer = [0u8; 1024];
    let n = stream.read(&mut buffer)?;
    println!("Echo: {}", String::from_utf8_lossy(&buffer[..n]));

    Ok(())
}

なぜこの実装を選んだか:

  • TcpListener::bindToSocketAddrsトレイトを実装した型を受け取るため、"127.0.0.1:7878"の文字列リテラルをそのまま渡せます。IPアドレスの手動パースは不要です
  • readで固定サイズバッファを使用していますが、これは教育目的の簡潔さのためです。実運用ではBufReaderの使用を検討してください

注意点:

この同期版サーバーは1つの接続を処理している間、他の接続を受け付けられません。同時に複数クライアントが接続する場合は、std::thread::spawnでスレッドを生成するか、後述するTokioの非同期版を使用してください。

TCPサーバーをマルチスレッド化する

同期版の制約を解消するため、接続ごとにスレッドを生成するアプローチを実装します。

// tcp_multithread_server.rs
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;

fn handle_client(mut stream: std::net::TcpStream) {
    let peer = stream.peer_addr().unwrap();
    println!("Handling connection from: {}", peer);

    let mut buffer = [0u8; 4096];
    loop {
        match stream.read(&mut buffer) {
            Ok(0) => {
                println!("Client {} disconnected", peer);
                break;
            }
            Ok(n) => {
                if stream.write_all(&buffer[..n]).is_err() {
                    eprintln!("Write failed for {}", peer);
                    break;
                }
            }
            Err(e) => {
                eprintln!("Read error from {}: {}", peer, e);
                break;
            }
        }
    }
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:7878")?;
    println!("Multi-threaded TCP Server on 127.0.0.1:7878");

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                // 接続ごとにOSスレッドを生成
                // stream の所有権がクロージャにムーブされる(Rustの所有権システム)
                thread::spawn(move || {
                    handle_client(stream);
                });
            }
            Err(e) => eprintln!("Accept error: {}", e),
        }
    }
    Ok(())
}

よくある間違い: 最初はstream&mutで渡そうとしがちですが、スレッドに渡すには所有権のムーブが必要です。moveキーワードを忘れるとコンパイルエラーになります。これはRustの所有権システムが、スレッド間でのデータ競合を防いでいるためです。

制約条件: OSスレッドは1スレッドあたり約2〜8MBのスタックメモリを消費します。1万接続を超えるような場面では、スレッドの生成コストがボトルネックになります。この場合はTokioの非同期タスク(約数KB/タスク)への移行を検討してください。

UDPでデータグラム通信を実装する

UDP(User Datagram Protocol)は、接続を確立せずにデータを送受信するプロトコルです。TCPと比較して低遅延ですが、パケットの到達保証や順序保証はありません。DNS、リアルタイム動画配信、ゲームサーバーなどで使われます。

// udp_server.rs
use std::net::UdpSocket;

fn main() -> std::io::Result<()> {
    let socket = UdpSocket::bind("127.0.0.1:8080")?;
    println!("UDP Server listening on 127.0.0.1:8080");

    let mut buffer = [0u8; 1024];
    loop {
        // recv_from はデータグラムと送信元アドレスを返す
        let (n, src_addr) = socket.recv_from(&mut buffer)?;
        println!("Received {} bytes from {}", n, src_addr);

        let response = format!(
            "Echo: {}",
            String::from_utf8_lossy(&buffer[..n])
        );
        // 送信元にそのまま返信
        socket.send_to(response.as_bytes(), src_addr)?;
    }
}
// udp_client.rs
use std::net::UdpSocket;

fn main() -> std::io::Result<()> {
    let socket = UdpSocket::bind("127.0.0.1:0")?; // OSにポートを自動割当
    let server_addr = "127.0.0.1:8080";

    socket.send_to(b"Hello, Rust UDP!", server_addr)?;

    let mut buffer = [0u8; 1024];
    let (n, _) = socket.recv_from(&mut buffer)?;
    println!("Response: {}", String::from_utf8_lossy(&buffer[..n]));

    Ok(())
}

以下の表でTCPとUDPの使い分けを整理します。

特性 TCP UDP
接続確立 必要(3ウェイハンドシェイク) 不要
データ到達保証 あり(再送制御) なし
順序保証 あり なし
レイテンシ 高い(ハンドシェイク+ACK待ち) 低い
適用例 HTTP、DB接続、ファイル転送 DNS、動画配信、ゲーム
Rust API TcpListener / TcpStream UdpSocket

Tokioで非同期TCPサーバーを構築する

大量の同時接続を効率的に処理するには、非同期I/Oが有効です。Rustの非同期ランタイムとしてはTokio(v1.50.0、2026年3月時点)が事実上の標準です。以前はasync-stdも選択肢でしたが、2025年3月に開発が終了し、後継のsmolへの移行が推奨されています(Goodbye Async-Std, Welcome Smol)。

Tokioプロジェクトをセットアップする

まず、Cargo.tomlに依存関係を追加します。

[package]
name = "rust-network-example"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1.50", features = ["full"] }

features = ["full"]はTokioの全機能を有効にします。本番環境では必要な機能のみを有効にしてバイナリサイズを削減できます(例: features = ["net", "rt-multi-thread", "macros", "io-util"])。

非同期Echoサーバーを実装する

// async_echo_server.rs
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:7878").await?;
    println!("Async TCP Server on 127.0.0.1:7878");

    loop {
        // await でブロックせずに次の接続を待機
        let (mut socket, addr) = listener.accept().await?;
        println!("New connection: {}", addr);

        // tokio::spawn で軽量タスクを生成(OSスレッドではない)
        // Pythonの asyncio.create_task() に相当
        tokio::spawn(async move {
            let mut buffer = [0u8; 4096];
            loop {
                match socket.read(&mut buffer).await {
                    Ok(0) => {
                        println!("Client {} disconnected", addr);
                        return;
                    }
                    Ok(n) => {
                        if socket.write_all(&buffer[..n]).await.is_err() {
                            eprintln!("Write failed for {}", addr);
                            return;
                        }
                    }
                    Err(e) => {
                        eprintln!("Read error from {}: {}", addr, e);
                        return;
                    }
                }
            }
        });
    }
}

なぜTokioを選んだか:

  • Tokioはcrates.ioで20,000以上のクレートから依存されており、エコシステムの規模が圧倒的
  • async-stdは2025年3月に開発終了。smolは軽量だが、ネットワーク向けのユーティリティ(tokio::io::copy等)が少ない
  • Tokio 1.x系はLTSリリース(1.47.x: 2026年9月まで)があり、長期安定運用に適している

グレースフルシャットダウンを実装する

本番運用では、SIGTERM受信時に既存接続の処理を完了してから終了する「グレースフルシャットダウン」が必要です。

// graceful_shutdown_server.rs
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::signal;
use tokio::sync::broadcast;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:7878").await?;
    println!("Server with graceful shutdown on 127.0.0.1:7878");

    // シャットダウン通知用のブロードキャストチャネル
    let (shutdown_tx, _) = broadcast::channel::<()>(1);

    loop {
        tokio::select! {
            // 新規接続の受付
            result = listener.accept() => {
                let (mut socket, addr) = result?;
                println!("New connection: {}", addr);

                // 各タスクにシャットダウン受信機を配布
                let mut shutdown_rx = shutdown_tx.subscribe();

                tokio::spawn(async move {
                    let mut buffer = [0u8; 4096];
                    loop {
                        tokio::select! {
                            // データ読み取り
                            result = socket.read(&mut buffer) => {
                                match result {
                                    Ok(0) | Err(_) => return,
                                    Ok(n) => {
                                        if socket.write_all(&buffer[..n]).await.is_err() {
                                            return;
                                        }
                                    }
                                }
                            }
                            // シャットダウンシグナル受信
                            _ = shutdown_rx.recv() => {
                                println!("Shutting down connection: {}", addr);
                                // 残りのデータをフラッシュしてから終了
                                let _ = socket.shutdown().await;
                                return;
                            }
                        }
                    }
                });
            }
            // Ctrl+C(SIGINT)を待機
            _ = signal::ctrl_c() => {
                println!("Shutdown signal received, closing connections...");
                // 全タスクにシャットダウンを通知
                let _ = shutdown_tx.send(());
                break;
            }
        }
    }

    // タスクの完了を待つための猶予期間
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
    println!("Server stopped");
    Ok(())
}

ハマりポイント: broadcast::channelの受信側(subscribe)をacceptループの外で作成すると、チャネルバッファがあふれてメッセージがドロップされることがあります。必ず各タスク内でsubscribe()を呼んでください。

タイムアウトとエラーハンドリングを設定する

ネットワークプログラミングでは、接続タイムアウトやリードタイムアウトの設定が不可欠です。

use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;

async fn connect_with_timeout(
    addr: &str,
    connect_timeout: Duration,
    read_timeout: Duration,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    // 接続タイムアウト: 指定時間内にTCPハンドシェイクが完了しなければエラー
    let mut stream = timeout(
        connect_timeout,
        TcpStream::connect(addr),
    )
    .await
    .map_err(|_| "Connection timed out")?  // Elapsed エラーを文字列に変換
    .map_err(|e| format!("Connection failed: {}", e))?;

    stream.write_all(b"GET / HTTP/1.0\r\n\r\n").await?;

    let mut response = Vec::new();
    // 読み取りタイムアウト: 指定時間内にレスポンスが届かなければエラー
    timeout(read_timeout, stream.read_to_end(&mut response))
        .await
        .map_err(|_| "Read timed out")??;

    Ok(response)
}

トレードオフ: タイムアウトを短くするとレスポンスが速くなりますが、低速なネットワーク環境では正常なリクエストがタイムアウトになります。一般的には接続タイムアウト5秒、読み取りタイムアウト30秒を起点に調整するのがよいでしょう。

UDPマルチキャストとsocket2による高度な設定を実装する

UDPマルチキャストは、1つのパケットを複数の受信者に同時配信する技術です。サービスディスカバリー(mDNS)やリアルタイム動画配信などで使われます。

UDPマルチキャスト送受信を実装する

Rust標準ライブラリのみでマルチキャストの基本は実装できますが、高度なソケットオプションの設定にはsocket2クレートが必要です。

[dependencies]
socket2 = "0.5"
// multicast_receiver.rs
use std::net::{Ipv4Addr, UdpSocket};

fn main() -> std::io::Result<()> {
    let multicast_addr = Ipv4Addr::new(239, 0, 0, 1);
    let any_addr = Ipv4Addr::UNSPECIFIED; // 0.0.0.0

    let socket = UdpSocket::bind("0.0.0.0:9000")?;

    // マルチキャストグループに参加
    // 第1引数: マルチキャストアドレス(224.0.0.0〜239.255.255.255)
    // 第2引数: 使用するネットワークインターフェース(0.0.0.0 = OS任せ)
    socket.join_multicast_v4(&multicast_addr, &any_addr)?;
    println!("Joined multicast group 239.0.0.1:9000");

    let mut buffer = [0u8; 1024];
    loop {
        let (n, src) = socket.recv_from(&mut buffer)?;
        println!(
            "Multicast from {}: {}",
            src,
            String::from_utf8_lossy(&buffer[..n])
        );
    }
}
// multicast_sender.rs
use std::net::UdpSocket;

fn main() -> std::io::Result<()> {
    let socket = UdpSocket::bind("0.0.0.0:0")?;

    // マルチキャストTTL(Time To Live)の設定
    // 1 = 同一サブネット内のみ、2以上 = ルーターを越える
    socket.set_multicast_ttl_v4(1)?;

    let message = b"Hello, multicast receivers!";
    socket.send_to(message, "239.0.0.1:9000")?;
    println!("Sent multicast message");

    Ok(())
}

socket2でソケットオプションを設定する

socket2クレートを使うと、std::netでは設定できないOSレベルのソケットオプションを安全に操作できます。

use socket2::{Domain, Protocol, SockAddr, Socket, Type};
use std::net::SocketAddr;

fn create_optimized_tcp_listener(
    addr: &str,
) -> std::io::Result<std::net::TcpListener> {
    let addr: SocketAddr = addr.parse().unwrap();
    let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))?;

    // SO_REUSEADDR: サーバー再起動時に "Address already in use" エラーを回避
    // TIME_WAIT状態のポートを即座に再利用可能にする
    socket.set_reuse_address(true)?;

    // TCP_NODELAY: Nagleアルゴリズムを無効化
    // 小さなパケットを即座に送信(低遅延が求められるプロトコルで有効)
    // ただし、ネットワーク帯域の使用効率は低下する
    socket.set_nodelay(true)?;

    // SO_RCVBUF / SO_SNDBUF: バッファサイズの調整
    // デフォルト値(通常87,380バイト)より大きくすると高スループットに有利
    socket.set_recv_buffer_size(256 * 1024)?; // 256KB
    socket.set_send_buffer_size(256 * 1024)?; // 256KB

    socket.bind(&SockAddr::from(addr))?;
    socket.listen(128)?; // backlog: 接続待ちキューの最大長

    // socket2::Socket を std::net::TcpListener に変換
    Ok(std::net::TcpListener::from(socket))
}

以下の表に主要なソケットオプションをまとめます。

オプション 効果 用途 注意点
SO_REUSEADDR ポートの即座再利用 サーバー再起動時 セキュリティ上、本番ではファイアウォールと併用
TCP_NODELAY Nagle無効化(即時送信) 低遅延通信 小パケット大量送信でネットワーク効率低下
SO_RCVBUF 受信バッファサイズ 高スループット OS上限を超えるとエラー(sysctlで変更可能)
SO_KEEPALIVE 接続の死活監視 長時間接続 デフォルトのインターバルが長い(通常2時間)
SO_LINGER close時のデータ送信保証 データ損失防止 タイムアウト0でRSTパケット送信(強制切断)

制約条件: SO_RCVBUFSO_SNDBUFの最大値はOSカーネルの設定に制限されます。Linuxでは/proc/sys/net/core/rmem_max/proc/sys/net/core/wmem_maxで上限を確認してください。設定値がOS上限を超えると、サイレントに切り詰められます(エラーにはなりません)。

同期と非同期の使い分けとパフォーマンス特性を理解する

「いつstd::netを使い、いつTokioを使うべきか」は実務で頻出する判断です。以下に判断基準をまとめます。

判断基準 std::net(同期) Tokio(非同期)
同時接続数 〜100程度 数千〜数万
メモリ使用量/接続 2〜8MB(OSスレッド) 数KB(Tokioタスク)
実装の複雑さ 低い 中程度
デバッグしやすさ 高い(スタックトレース明確) やや低い(async境界でトレースが分断)
適用シーン CLIツール、テスト用サーバー、プロトタイプ 本番Webサーバー、プロキシ、マイクロサービス
外部クレート依存 なし tokio

よくある間違い: 「非同期は常に速い」と思われがちですが、接続数が少なくCPU-bound処理が多い場合は、非同期のオーバーヘッド(Futureのポーリング、タスクスケジューリング)が逆に性能を落とすことがあります。Tokio公式ドキュメントでも「Tokio is designed for IO-bound applications(I/Oバウンドなアプリケーション向け)」と明記されています(Tokio Tutorial)。

非同期ランタイムの選択肢を比較する

2026年3月時点でのRust非同期ランタイムの状況を整理します。

ランタイム 状態 特徴 crates.io依存数
Tokio 1.50.0 活発に開発中 フル機能、LTSあり 20,000+
smol 活発に開発中 軽量、composable 1,000+
async-std 開発終了(2025年3月) smolに移行推奨 -

新規プロジェクトにはTokioを推奨します。エコシステムの規模が大きく、hyper(HTTPライブラリ)、tonic(gRPCフレームワーク)、sqlx(データベース)など主要なクレートがTokioベースです。

ライブラリ作者で特定のランタイムに依存させたくない場合は、smolをベースにすると柔軟性が高くなります。smolベースのコードはTokioランタイム上でも動作しますが、その逆は成立しません(The State of Async Rust: Runtimes)。

よくある問題と解決方法

問題 原因 解決方法
Address already in use TIME_WAIT状態のポートが残存 socket2SO_REUSEADDRを設定、またはTokioのTcpSocket::set_reuseaddr(true)
Connection refused サーバーが起動していない、またはポート不一致 netstat -tlnpでリッスン状態を確認
Too many open files ファイルディスクリプタ上限に到達 ulimit -n 65535で上限引き上げ
Connection reset by peer サーバー側が強制切断(RST送信) SO_LINGER設定を確認、タイムアウト処理を追加
Broken pipe 切断済みソケットへの書き込み write_allの戻り値を確認、接続状態の管理を追加
readが0バイトを返す クライアントがFINを送信(正常切断) ループからbreakして接続をクリーンアップ
非同期タスクがパニック tokio::spawn内のunwrap()失敗 spawnの戻り値(JoinHandle)で.awaitしてエラーを捕捉

まとめと次のステップ

まとめ:

  • Rust標準ライブラリのstd::netモジュールにより、外部依存なしでTCP/UDPプログラミングが可能です。学習やプロトタイプに最適です
  • 本番サーバーにはTokio(v1.50.0)が推奨されます。非同期I/Oにより数千の同時接続を数KB/タスクのメモリで処理できます
  • socket2クレートでSO_REUSEADDRTCP_NODELAYなどのOSレベルのソケットオプションを安全に設定できます
  • 同期vs非同期の選択は、同時接続数とI/O待ち時間の割合で判断します。100接続以下のシンプルなプロトコルなら同期、それ以上なら非同期を検討してください
  • UDPマルチキャストにより、1対多のリアルタイム配信パターンを実装できます

次にやるべきこと:

  • Tokio公式チュートリアルのI/OセクションとFramingセクションで、プロトコル実装を深掘りする
  • tokio-tungsteniteを使ってWebSocketサーバーを構築し、双方向リアルタイム通信を実装する
  • rustls(TLSライブラリ)と組み合わせて、TLS暗号化されたTCP通信を実装する

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?