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 fnとJoinHandleを返すラップ関数の本質的な違いは、実行のトリガーにあります。生粋の非同期関数はawaitが評価された時点で初めて内部コードがスケジューリングされます。対してJoinHandleを返す形式は、関数呼び出し時点でタスクが即時生成され、awaitの有無に関わらずバックグラウンドで実行が開始されます。
協調的スケジューリングとスリープ処理
以下のように無限ループでyield処理を行わない場合、ワーカーを完全に占有してランタイムが停止します。
tokio::spawn(async {
loop {
// 何もしない処理
}
});
一方、tokio::time::sleepをawaitすることで、一定期間制御をランタイムへ返却できます。
use std::time::Duration;
tokio::spawn(async {
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
// 次回ループへ
}
});
後者の例が安全な理由は、tokio::time::sleepが内部的にタイマーイベントを登録し、指定時間中は現在のタスクを実行キューから外すためです。これにより他のpendingタスクがスケジュールされ、タイマー切れ後に再び実行権が戻ってきます。これは協調的スケジューリングの核心であり、同期スレッドのブロッキングスリープとは全く異なる動作を行います。