Rustの非同期・同期処理とスレッドモデルの仕組み

Tokioなどの非同期ランタイムを用いた開発では、main関数に#[tokio::main]マクロを付与して記述するケースが一般的です。一見すると非同期関数として動作するように見えますが、コンパイル時に展開される実際のコード構造は同期ベースです。このマクロは構文糖衣であり、内部的には以下のような同期関数に展開されます。

fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("ランタイムの初期化に失敗しました");

    rt.block_on(async {
        println!("プログラム起動");
    });
}

この仕組みにより、ユーザーコードは非同期の閉包内で実行され、基底となるスレッドは「メインスレッド」と呼ばれます。しかし、ここで重要な制約が生まれます。非同期コンテキスト内でCPU集約型の同期処理や、適切に非同期化されていないブロッキング関数を呼び出すと、該当するスレッドプール内のワーカーが占有されます。結果としてランタイムのポーリングメカニズムが停止し、全体のスループットが劣化します。

ブロッキング処理の安全な分離とタスク生成

ブロッキング処理を安全に分離し、かつawaitによる結果待ちを柔軟に制御するには、タスク生成APIの適切な使い分けが不可欠です。具体的にはtokio::task::JoinHandleを返す設計が有効です。

use tokio::task::JoinHandle;

pub struct TaskController;

impl TaskController {
    /// 同期のブロッキング処理を別スレッドで実行
    pub fn schedule_blocking() -> JoinHandle<String> {
        tokio::task::spawn_blocking(|| {
            // CPU集約型やI/Oブロッキング処理を記述
            "ブロック処理完了".to_string()
        })
    }

    /// 非同期タスクとして実行
    pub fn schedule_async() -> JoinHandle<String> {
        tokio::spawn(async {
            // 非同期I/Oや軽量な処理を記述
            "非同期処理完了".to_string()
        })
    }
}

これらのハンドラは実行パターンに応じて以下のように組み立てられます。

// 1. ブロッキングタスクを生成し、結果を待機(メインスレッドは解放されないが、スレッドプールは占有されない)
TaskController::schedule_blocking().await.unwrap();

// 2. ブロッキングタスクを生成し、即座に次の処理へ移行
TaskController::schedule_blocking();

// 3. 非同期タスクを生成し、完了を待機
TaskController::schedule_async().await.unwrap();

// 4. 非同期タスクを生成し、非同期に実行させる
TaskController::schedule_async();

spawn_blockingは同期処理専用のスレッドプールを割り当てるため、イベントループを停止させることがありません。一方、tokio::spawnは通常のワーカープールにタスクを格納するため、内部でブロッキングが発生すれば同様にランタイムをフリーズさせます。どちらの場合でも、スリープ処理にはstd::thread::sleepではなくtokio::time::sleepを使用し、長時間の処理はstd::thread::spawnへ移行するのが鉄則です。

非同期関数とJoinHandleの実行トリガーの違い

async fnJoinHandleを返すラップ関数の本質的な違いは、実行のトリガーにあります。生粋の非同期関数はawaitが評価された時点で初めて内部コードがスケジューリングされます。対してJoinHandleを返す形式は、関数呼び出し時点でタスクが即時生成され、awaitの有無に関わらずバックグラウンドで実行が開始されます。

協調的スケジューリングとスリープ処理

以下のように無限ループでyield処理を行わない場合、ワーカーを完全に占有してランタイムが停止します。

tokio::spawn(async {
    loop {
        // 何もしない処理
    }
});

一方、tokio::time::sleepawaitすることで、一定期間制御をランタイムへ返却できます。

use std::time::Duration;

tokio::spawn(async {
    loop {
        tokio::time::sleep(Duration::from_secs(2)).await;
        // 次回ループへ
    }
});

後者の例が安全な理由は、tokio::time::sleepが内部的にタイマーイベントを登録し、指定時間中は現在のタスクを実行キューから外すためです。これにより他のpendingタスクがスケジュールされ、タイマー切れ後に再び実行権が戻ってきます。これは協調的スケジューリングの核心であり、同期スレッドのブロッキングスリープとは全く異なる動作を行います。

タグ: rust tokio 非同期プログラミング タスクスケジューリング スレッドプール

7月17日 21:12 投稿