じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

RustでLinuxのシグナル処理とプロセス間通信をしてみた

はじめに

前回の記事「RustでLinuxプロセス管理をしてみた」の続編として、今回はシグナル処理プロセス間通信(IPC)について解説します。これらの技術は、システムプログラミングの根幹をなす重要な概念です。

doc.rust-lang.org

サンプルコードはこちらに配置しておきます。

github.com

2025年の最新動向

2025年現在、Rustエコシステムは大きな転換期を迎えています。Linux 6.13が2025年1月にリリースされ、Rustサポートが「転換点」に到達しました。また、非同期ランタイムの世界では、async-stdが2025年3月に廃止されることが決まり、Tokioが事実上の標準となっています。さらに、Rust 1.85ではasync closuresが安定化され、より表現力豊かな非同期処理が可能になりました。

1. 基礎知識

書籍はこちらがめちゃくちゃに詳しいのでオススメです。

プロセスとは

プロセスは「実行中のプログラムのインスタンス」です。皆さんが日常的に使うWebブラウザのタブやターミナルのセッションは、すべてプロセスとして動作しています。

各プロセスは独立したメモリ空間を持ち、他のプロセスから直接アクセスすることはできません。これがシステムの安定性と安全性を保証していますが、同時にプロセス間でデータをやり取りする特別な仕組みが必要になる理由でもあります。

シグナルとは

シグナルは、プロセス間の非同期通知メカニズムです。電話の着信音のように、プロセスに「何か重要なことが起きた」と割り込みで知らせる仕組みだと考えると分かりやすいでしょう。

主要なシグナルと実際の用途:

シグナル 番号 用途 実例
SIGTERM 15 正常終了要求 systemctl stopで送信される
SIGKILL 9 強制終了 kill -9、OOMキラー
SIGINT 2 割り込み Ctrl+Cを押したとき
SIGHUP 1 設定再読み込み nginxやsshdの設定リロード
SIGUSR1/2 10/12 カスタム用途 アプリ固有の動作トリガー

シグナルには重要な特徴がいくつかあります。まず非同期性という性質があり、いつ届くか予測できません。また割り込みとして動作するため、実行中の処理を中断して処理されます。そしてシンプルな仕組みで、シグナル番号以外の追加情報を送ることはできません。

rust-cli.github.io

プロセス間通信(IPC)とは

IPCは、独立したプロセス同士がデータをやり取りするための仕組みです。それぞれの方式には特徴があり、用途に応じて使い分けます:

方式 特徴 実際の使用例
パイプ 単方向、親子プロセス間 ls | grepなどのシェルパイプ
名前付きパイプ 双方向、無関係なプロセス間も可 ログ収集デーモンへのデータ送信
Unix Domain Socket 双方向、高速、信頼性高 Docker、systemd、PostgreSQL
共有メモリ 最速、同期が複雑 データベースのバッファプール
メッセージキュー 非同期、順序保証 ジョブキューシステム

2. シンプルなシグナル処理

Ctrl+Cを検知して安全に終了

最もシンプルな例から始めてみましょう。Ctrl+Cを押したときに、きちんと後処理をしてから終了するプログラムです。

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn main() {
    println!("プログラム開始(Ctrl+Cで終了)");
    
    // 実行中フラグ(スレッド間で安全に共有)
    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();
    
    // Ctrl+Cハンドラーを設定
    ctrlc::set_handler(move || {
        println!("\n終了シグナルを受信しました");
        r.store(false, Ordering::SeqCst);
    }).expect("シグナルハンドラーの設定に失敗");
    
    // メインループ
    let mut counter = 0;
    while running.load(Ordering::SeqCst) {
        counter += 1;
        println!("処理中... カウント: {}", counter);
        thread::sleep(Duration::from_secs(1));
    }
    
    println!("プログラムを安全に終了しました");
}

このコードにはいくつかの重要なポイントがあります。まずAtomicBoolを使ってスレッド間で安全にフラグを共有しています。シグナルハンドラーはいつ呼ばれるか分からないため、アトミック操作が必要になります。そしてループを抜けてから終了処理を行うことで、データの整合性を保っています。

docs.rs

github.com

複数のシグナルを処理

実際のサーバーアプリケーションでは、複数のシグナルを適切に処理する必要があります。

use signal_hook::{consts::signal::*, iterator::Signals};
use std::{error::Error, thread, time::Duration};

fn main() -> Result<(), Box<dyn Error>> {
    let mut signals = Signals::new(&[SIGTERM, SIGINT, SIGHUP])?;
    
    thread::spawn(move || {
        for sig in signals.forever() {
            match sig {
                SIGTERM | SIGINT => {
                    println!("終了シグナルを受信");
                    std::process::exit(0);
                }
                SIGHUP => {
                    println!("設定再読み込み");
                }
                _ => unreachable!(),
            }
        }
    });
    
    // メイン処理
    loop {
        println!("作業中...");
        thread::sleep(Duration::from_secs(2));
    }
}

docs.rs

github.com

3. プロセス間通信の基礎

シンプルなパイプ通信

親プロセスから子プロセスへメッセージを送る基本的な例です。

use std::io::{Write, Read};
use std::process::{Command, Stdio};

fn main() -> std::io::Result<()> {
    // catコマンドは標準入力をそのまま標準出力に出力
    let mut child = Command::new("cat")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()?;
    
    // 子プロセスに書き込み
    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(b"Hello from Rust!\n")?;
    }
    
    // 結果を読み取り
    let output = child.wait_with_output()?;
    println!("受信: {}", String::from_utf8_lossy(&output.stdout));
    
    Ok(())
}

パイプには特徴的な性質があります。まず単方向通信であり、データは一方向にのみ流れます。またバッファリング機能があり、OSが自動的にバッファを管理してくれます。そしてブロッキング動作をするため、読み込み側は書き込みを待つことになります。

docs.rs

Unix Domain Socket

より本格的な双方向通信の例です。多くのシステムソフトウェアが採用している方式です。

Unix Domain Socketには多くの利点があります。双方向通信が可能で、クライアント・サーバー間で自由にやり取りできます。また、ネットワークスタックを通らないため高速に動作します。そしてファイルシステム上のパスとして存在するため、アクセス制御が簡単に行えます。

4. デバッグツールの活用

システムプログラミングにおいて、問題を解決するには、まず問題を観察できなければならないという原則があります。特にシグナル処理やIPCのような非同期的な動作は、従来のprint文デバッグでは限界があります。そこで重要になるのが可観測性(Observability)という概念です。

効果的なデバッグには階層的なアプローチが必要です。まずアプリケーション層で何が起きているかを把握し、次にシステムコール層まで掘り下げ、必要に応じてカーネル層まで観察します。各層に適したツールを使い分けることで、最小のオーバーヘッドで最大の洞察を得ることができます。

また、動的トレーシング静的トレーシングを使い分けることも重要です。straceのような動的トレーシングツールは実行中のプロセスをリアルタイムで観察でき、rr-debuggerのような記録再生型ツールは時間を巻き戻して問題の根本原因を特定できます。これらを組み合わせることで、再現困難なバグも確実に捕捉できるようになります。

strace - システムコールトレース

シグナル処理やIPCのデバッグには、システムコールレベルでの動作確認が不可欠です。

# シグナル関連のシステムコールのみ表示
strace -e trace=signal,sigaction,kill,pause cargo run

# 実際の出力例
rt_sigaction(SIGINT, {sa_handler=0x5555555, ...}, NULL, 8) = 0
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
rt_sigreturn({mask=[]}) = 0

straceを使うと様々な情報が見えてきます。シグナルハンドラーの登録状況(sigaction)、シグナルの送受信タイミングブロックされたシグナル、そしてシステムコールの引数と戻り値などを確認できます。

strace.io

rr-debugger(最強のデバッグツール)

rrは、GDBを拡張して作られたデバッガで、プログラムの実行を記録し、逆方向にステップ実行できます。

# プログラムの実行を記録
rr record ./target/debug/my_program

# rust-gdbを使って再生
rr replay -d rust-gdb

# リバース実行のコマンド
(rr) reverse-continue  # 逆方向にcontinue
(rr) reverse-next      # 逆方向にnext

rrが強力な理由はいくつかあります。まず100%再現性があり、非決定的な動作も完全に再現できます。また逆実行機能により、エラーの原因を遡って調査できます。そして低オーバーヘッドで動作するため、実用的な速度で記録が可能です。

特にシステムプログラミングでは、「たまにしか起きないエラー」や「データ競合」のデバッグで威力を発揮します。

rr-project.org

tokio-console - 非同期ランタイムデバッグ

非同期Rustアプリケーションのデバッグには、tokio-consoleが非常に有用です。タスクの状態実行時間リソース使用状況をリアルタイムで監視できます。

# tokio-consoleをインストール
cargo install --locked tokio-console

# アプリケーション起動(別ターミナル)
RUSTFLAGS="--cfg tokio_unstable" cargo run

# tokio-consoleで監視
tokio-console

github.com

5. グレイスフルシャットダウン

実際のサービスで必要な、適切な終了処理の実装例を見てみましょう。

グレイスフルシャットダウンが重要な理由は複数あります。まずデータの整合性を保つため、処理中のタスクを完了してから終了する必要があります。またリソースの解放として、ファイルやソケットを適切にクローズしなければなりません。そして状態の保存により、次回起動時に必要な情報を保存することも重要です。

実装する際のポイントとしては、まず新規タスクの受付を停止し、新しい仕事を受け付けないようにします。次に既存タスクの完了を待機し、実行中の処理を最後まで実行させます。その後リソースのクリーンアップを行い、ファイルやネットワーク接続を閉じます。最後に統計情報の出力を行い、ログに実行結果を記録します。

6. Tokioを使った非同期グレイスフルシャットダウン

モダンなRustアプリケーションでは、Tokioを使った非同期処理が主流です。

use tokio::signal;
use tokio_util::sync::CancellationToken;

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    
    // Ctrl+Cハンドラー
    let shutdown_token = token.clone();
    tokio::spawn(async move {
        signal::ctrl_c().await.unwrap();
        println!("シャットダウン開始");
        shutdown_token.cancel();
    });
    
    // メインループ
    loop {
        tokio::select! {
            _ = token.cancelled() => {
                println!("終了処理中...");
                break;
            }
            _ = do_work() => {
                // 通常の処理
            }
        }
    }
}

async fn do_work() {
    // 非同期処理
}

CancellationTokenには多くの利点があります。階層的なキャンセルが可能で、親トークンをキャンセルすると子もキャンセルされます。また協調的な仕組みにより、各タスクが自分のタイミングで終了できます。そして非同期対応により、async/awaitと自然に統合されています。

tokio.rs

docs.rs

github.com

docs.rs

tokio.rs

7. nixクレートでシステムコールを扱う

Rustでは、nixクレートを使って安全にUnixシステムコールを扱うことができます。libcクレートの生のAPIをラップし、Rust的な安全なインターフェースを提供しています。

use nix::sys::signal::{self, Signal};
use nix::unistd::{fork, ForkResult};

match fork() {
    Ok(ForkResult::Parent { child }) => {
        println!("親プロセス、子PID: {}", child);
    }
    Ok(ForkResult::Child) => {
        println!("子プロセス");
    }
    Err(_) => eprintln!("fork失敗"),
}

nixクレートを使うことで、エラーハンドリングが適切に行われメモリ安全性が保証されます。生のシステムコールを直接扱う必要がなくなり、より安全なコードが書けるようになります。

docs.rs

github.com

8. 2025年の新機能:Async Closures

Rust 1.85.0で安定化されたasync closuresを使うと、より柔軟な非同期処理が書けます。

async fn retry_with_backoff<F, Fut>(
    mut f: F, 
    max_retries: u32,
) -> Result<String>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<String>>,
{
    for attempt in 1..=max_retries {
        match f().await {
            Ok(result) => return Ok(result),
            Err(e) if attempt < max_retries => {
                let backoff = Duration::from_secs(2_u64.pow(attempt - 1));
                sleep(backoff).await;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}

async closuresを使うメリットは多岐にわたります。まず簡潔な記述が可能になり、非同期処理を関数引数として渡せるようになります。また型安全であるため、コンパイル時に型チェックが行われます。そして柔軟な制御フローにより、リトライやタイムアウトの実装が簡単になります。

実装パターンの選び方

シグナル処理の選択基準

シグナル処理の実装方法を選ぶ際は、用途に応じて適切なツールを選択することが重要です。単純な終了処理であればctrlcクレートで十分です。複数のシグナルを扱う必要がある場合はsignal-hookを使用します。そして非同期処理と組み合わせる場合は、Tokioのsignalモジュールが最適です。

IPC方式の選択基準

IPC方式も同様に、用途に応じて選択します。親子プロセス間の単純な通信であればパイプが適しています。高速な双方向通信が必要な場合はUnix Domain Socketを選びます。大量データの共有には共有メモリが最適で、非同期メッセージングにはメッセージキューが向いています。

まとめ

この記事では、Rustでのシグナル処理とプロセス間通信について、基礎から実践まで段階的に解説しました。

重要なポイント

今回学んだ重要なポイントを振り返ってみましょう。まず、シグナルは非同期であり、いつ届くか分からないためアトミック操作が必要です。IPCは用途に応じて選ぶ必要があり、速度、双方向性、複雑さのトレードオフを考慮します。グレイスフルシャットダウンはデータの整合性を保つために必須です。stracerr-debuggerなどのデバッグツールを活用することで、問題を効率的に解決できます。そして、async closuresCancellationTokenなどの最新機能を活用することで、保守性を向上させることができます。

各IPC方式の使い分け

実際の開発では、各IPC方式を適切に使い分けることが重要です。パイプシェルスクリプトとの連携や親子プロセス間の単純な通信に適しています。名前付きパイプはログ収集や順序保証が必要な場合に使います。Unix Domain Socketは高速な双方向通信やサービス間連携に最適です。共有メモリは大量データの高速処理やリアルタイム性が必要な場合に選択します。

次のステップ

この基礎を踏まえて、さらに高度な実装に挑戦することができます。分散システムへの拡張としてgRPCやメッセージキューの実装、コンテナ環境でのIPC最適化リアルタイムシステムでの応用、そしてマイクロサービスアーキテクチャでの実装などが考えられます。

完全なソースコードGitHubリポジトリで公開しています。

前回の記事「RustでLinuxプロセス管理をしてみた」と合わせて読むことで、Rustでのシステムプログラミングの基礎がしっかりと身につきます。