📚 背景知識(読んでから問題へ)
Day 031〜035ではstd::thread・Arc<Mutex<T>>・mpscチャンネル・Send/Syncという「OSスレッドによる並行処理」の土台を固めてきました。今日から数日は、Rustのもう一つの並行処理モデルである非同期処理(async/await)に入ります。
OSスレッドと何が違うのか。std::thread::spawnで作るスレッドはOS(カーネル)がスケジューリングする実体で、1つのOSスレッドはスタック領域だけで数百KB〜数MBを消費します。数千〜数百万の同時接続を捌くI/Oバウンドな処理でOSスレッドを1接続1スレッドで扱うと、生成コスト・メモリ使用量がボトルネックになります(いわゆる"C10K問題")。Rustのasync fnが生み出すのはOSスレッドではなく、軽量な「タスク」です。1つのasyncタスクの状態は数十〜数百バイト程度に収まることが多く、数十万タスクを1つのOSスレッド上で並行実行することも現実的です。
.awaitという「ここで一時停止してよい」という自己申告ポイントに到達するまで、決してCPUを手放しません。.awaitを含まない重いループをasync fnの中に書くと、そのタスクを実行しているスレッドを独占し続け、同じスレッド上の他のタスクが一切進まなくなります。Futureトレイト — asyncの正体。async fn foo() -> i32 { ... }という関数は、呼び出してもその場では何も実行されません。呼び出した瞬間に返るのはstd::future::Futureトレイトを実装した1つの状態機械(ステートマシン)の値です。
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
Futureは「未来のある時点で値が得られるかもしれない計算」を表す受動的な値です。誰か(ランタイム)がpollを呼んで初めて一歩前進します。Poll::Pendingを返す際、Futureは自分のWaker(cx.waker())をI/O完了などの通知元に登録しておき、準備が整ったらwake()経由でランタイムに「もう一度pollしてよい」と伝えます。これによりランタイムは「まだ準備ができていないFutureをビジーループで何度もpollし続ける」という無駄を避けられます。
async fnの本体はコンパイラによって、変数の状態を保持するenum(ステートマシン)に変換されます。.awaitのたびに「ここまで進んだ」という状態がバリアントとして刻まれ、次にpollされたときはそこから再開します。これはヒープ確保や動的ディスパッチを一切必要としない、コルーチンのゼロコスト実装です。
ランタイムが必要な理由。Futureはただの値なので、誰かがpollし続けなければ永遠に何も起きません。標準ライブラリは意図的にpollし続ける「エグゼキュータ」を提供せず、tokio・async-std・smolなど複数のランタイムが並立できる設計になっています。本スキルではtokioを標準採用します。
📝 問題
要件1(概念説明): 次の3つの問いに、コードを交えず日本語の文章で説明してください。
async fn foo() {}を呼び出しただけで(.awaitせずに)関数呼び出し文だけ書いた場合、fooの中身は実行されるか。理由も述べよ。Future::pollがPoll::Pendingを返したあと、そのFutureが「いつ再度pollされるべきか」をランタイムはどうやって知るのか。async fnの中でstd::thread::sleep(Duration::from_secs(1))(同期的なブロッキングsleep)を呼ぶと、tokioランタイム上で何が問題になるか。tokio::time::sleep(...).awaitとは何が違うか。
要件2(実装・比較実験): 以下の2つの非同期タスクを同時に実行し、実行順序のログを観察するプログラムをtokioで書いてください。
- タスクA:
std::thread::sleep(同期ブロッキング)で200ms待ってから"A done"を出力する処理を、あえてasync fnの中に書く - タスクB: 100ms間隔で3回
"B tick"を出力する処理をtokio::time::sleep(...).awaitで実装する
これを#[tokio::main(flavor = "current_thread")](シングルスレッドランタイム)でtokio::join!を使って同時に実行し、「Bのtickが期待通りのタイミングで出力されない(Aのブロッキングにスレッドを奪われる)」ことを実際にログで確認してください。
要件3(修正): 要件2のタスクAをtokio::task::spawn_blockingを使って正しく非同期タスクの外に逃がすように修正し、タスクBが期待通りのタイミングで動くようになることを確認してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
RustのFutureは「怠惰(lazy)」です。Futureという値を作っただけでは何も始まりません。JavaScriptのPromise(作った瞬間に実行が始まる"eager"な設計)との対比で考えると、async fnを呼んだだけで何が起きて何が起きないのかが整理しやすくなります。
ヒント2 — アプローチ
要件2は「あえて悪いコードを書いて問題を体感する」観察型の設問です。current_threadランタイムを使うことで「1つのOSスレッドの上で全タスクが協調動作している」状況を強制的に作り出せます(マルチスレッドランタイムだとタスクAが別のOSスレッドに逃げてしまい、問題が再現しにくくなります)。std::time::Instantでタイムスタンプを記録しながらログを出すと、ズレが定量的に見えます。
ヒント3 — コード骨格
use std::time::{Duration, Instant};
async fn blocking_task_a(start: Instant) {
std::thread::sleep(Duration::from_millis(200)); // ← ここが問題
println!("[{:?}] A done", start.elapsed());
}
async fn ticking_task_b(start: Instant) {
for i in 1..=3 {
tokio::time::sleep(Duration::from_millis(100)).await;
println!("[{:?}] B tick {}", start.elapsed(), i);
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
tokio::join!(blocking_task_a(start), ticking_task_b(start));
}
要件3ではblocking_task_aの中身をtokio::task::spawn_blocking(move || { ... }).awaitで包み、専用のブロッキングスレッドプールに処理を逃がします。
✅ 模範解答
// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
use std::time::{Duration, Instant};
/// 【問題のあるバージョン】async fnの中で同期的にブロッキングするタスク。
/// std::thread::sleepは.awaitではないため、ランタイムに制御を返さず
/// 実行中のOSスレッドを200ms独占し続ける。
async fn blocking_task_a(start: Instant) {
println!("[{:>5?}] A start (blocking sleep 200ms)", start.elapsed());
std::thread::sleep(Duration::from_millis(200)); // 協調ポイントが無い
println!("[{:>5?}] A done", start.elapsed());
}
/// 100ms間隔で3回tickするタスク。tokio::time::sleepは.awaitのたびに
/// 「準備ができるまでこのスレッドを他のタスクに譲る」という協調ポイントになる。
async fn ticking_task_b(start: Instant) {
for i in 1..=3 {
tokio::time::sleep(Duration::from_millis(100)).await;
println!("[{:>5?}] B tick {}", start.elapsed(), i);
}
}
/// 【修正バージョン】spawn_blockingで同期処理を専用スレッドプールへ逃がす。
/// これによりcurrent_threadランタイムのメインスレッドはブロックされず、
/// タスクBが本来のタイミング通りに進行できる。
async fn fixed_task_a(start: Instant) {
println!("[{:>5?}] A(fixed) start", start.elapsed());
tokio::task::spawn_blocking(move || {
std::thread::sleep(Duration::from_millis(200));
})
.await
.expect("blocking task panicked");
println!("[{:>5?}] A(fixed) done", start.elapsed());
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("=== 要件2: ブロッキングがBを止める様子を観察 ===");
let start = Instant::now();
tokio::join!(blocking_task_a(start), ticking_task_b(start));
println!();
println!("=== 要件3: spawn_blockingで修正した後 ===");
let start2 = Instant::now();
tokio::join!(fixed_task_a(start2), ticking_task_b(start2));
}
#[cfg(test)]
mod tests {
use super::*;
// current_threadランタイム上で、ブロッキングなAがある場合とspawn_blockingで
// 逃がした場合とで、Bの最初のtickが出るまでの経過時間が大きく変わることを
// 数値で確認するテスト。
#[tokio::test(flavor = "current_thread")]
async fn blocking_sleep_delays_sibling_task() {
let start = Instant::now();
// Aが先にpollされてブロッキングsleepに入ると、Bのタイマー登録自体が
// 200ms遅れるため、最初のtickは 200ms + 100ms 前後になる。
tokio::join!(blocking_task_a(start), async {
tokio::time::sleep(Duration::from_millis(100)).await;
});
assert!(start.elapsed() >= Duration::from_millis(200));
}
#[tokio::test(flavor = "current_thread")]
async fn spawn_blocking_does_not_delay_sibling_task() {
let start = Instant::now();
// spawn_blockingは別スレッドプールで実行されるため、メインタスク側は
// すぐにawait地点で制御を返し、100ms待ちのタスクは本来の時間で完了する。
tokio::join!(fixed_task_a(start), async {
tokio::time::sleep(Duration::from_millis(100)).await;
});
assert!(start.elapsed() < Duration::from_millis(400));
}
}
A start(0ms) → A done(200ms) → B tick1(200ms) → B tick2(300ms) → B tick3(400ms)となり、Bのtickが100ms/200ms/300msで出るべきところ、Aの完了後にまとめて連鎖して遅延することが確認できます。要件3の修正後はA(fixed) start(0ms) → B tick1(100ms) → B tick2(200ms) → B tick3(300ms) → A(fixed) done(200ms付近)のように、Bが本来のタイミングで進行します。🪜 Step-by-Step 解説
async fn呼び出しは何も実行しないfoo()の呼び出しは、Futureトレイトを実装したステートマシンの値を作るだけ。この値を.awaitするか、エグゼキュータのspawnに渡してpollされない限り、中身のprintln!は一切実行されない。これが要件1の問い1への回答の核心。
Poll::Pending後の再開はWakerが担うtokio::time::sleepは初回poll時にタイマーキューへ「この時刻になったらcx.waker().wake()を呼んで」と登録してPendingを返す。ランタイムは他タスクのpollに移り、指定時刻になるとタイマー機構がwake()を呼んでランタイムに「再度pollできる」と伝える。「ポーリングし続ける(ビジーウェイト)」のではなく「準備ができたら通知が来る」プッシュ型の仕組みが要件1の問い2の答え。
std::thread::sleepは.awaitポイントではないため、pollの実行そのものがOSに制御を返さず200ms止まる。current_threadランタイムでは、このスレッド1本が全タスクを処理しているため、Aのpollが戻ってこない限りBのpollは一切呼ばれない。これが要件1の問い3、および要件2で確認すべき現象。
spawn_blockingによる修正spawn_blockingはtokioが内部に持つ専用のブロッキングスレッドプールにクロージャを送り込み、そこで同期的に実行させる。呼び出し元のasync fnはその完了を知らせるJoinHandleを.awaitするだけなので正しい協調ポイントになり、メインの非同期ワーカースレッドは即座に他タスク(B)のpollに戻れる。
💡 設計思想・なぜこう書くのか
Futureが「怠惰な値」として設計されているのは、ゼロコスト抽象化の思想の延長です。もしasync fnを呼んだ瞬間に即座に実行が始まる(JavaScriptのPromiseのような"eager"設計)なら、コンパイラは「呼び出しと同時に何らかのタスクキューに登録する」処理を暗黙に埋め込まねばならず、常に何らかのランタイム・ヒープ確保が必要になります。Rustは「実行するかどうか、いつ・どのスレッドで実行するかはすべて呼び出し側(ランタイム)が制御する」という設計にすることで、標準ライブラリ自体はいかなるランタイムにも依存しない、ゼロオーバーヘッドなFutureという「値」だけを提供しています。
async fnの中に書いてはいけない」という新しい規律をプログラマに要求します。所有権システムが「メモリ安全性という制約と引き換えに実行時コストゼロを実現した」のと同じ構図で、「協調的スケジューリングという制約と引き換えに軽量な大規模並行性を実現した」と言えます。🛑 コンパイルエラーが出た場合
本問は概念理解が主題のためコンパイルエラーは想定していませんが、#[tokio::main]のfeatures指定を忘れると次のようなエラーが出ます。
Cargo.tomlにtokio = { version = "1", features = ["full"] }(または少なくともrt, macros, time)を追加していないことが原因です。tokioはフィーチャーフラグでランタイム・タイマー・I/O等をオプトインする設計になっており、必要な機能だけをビルドに含めることでコンパイル時間・バイナリサイズを抑えられます。🌐 他言語との比較
| 観点 | JavaScript (Promise/async) | Go (goroutine) | Rust (Future/async) |
|---|---|---|---|
| 実行開始タイミング | Promise生成と同時に即座に実行開始(eager) | go func()で即座に別goroutineとして実行開始 | Futureは値を作るだけで未実行(lazy)。pollされて初めて進む |
| スケジューリング方式 | シングルスレッドのイベントループ(協調的、I/O待ちはランタイム内蔵) | Goランタイムがプリエンプティブに近い形でM:Nスケジューリング | ランタイム(tokio等)が協調的にpollし、Wakerで再開通知を受ける |
| ブロッキング処理の扱い | イベントループを塞ぐ(同様の事故が起きうる) | Goランタイムがsyscall検知で自動的にOSスレッドを増やし緩和 | 明示的にspawn_blocking等で退避しないと事故る(自動緩和なし) |
| ランタイムの所在 | 言語・ブラウザ/Node.js組み込み | 言語ランタイムに組み込み、選択の余地なし | 標準ライブラリはFutureのみ定義。ランタイムはtokio等外部クレートを選択制 |
Go経験者にとって重要な注意点は、「goroutineは重い処理を書いてもGoランタイムがある程度自動的に緩和してくれる場面がある」のに対し、「Rustのasync fnは自分で.awaitしない限り絶対に協調しない」という点です。Goのつもりでasync fnの中に重い同期処理を書くと、要件2で観察した通りの詰まりが本番環境で発生します。
🏆 実務での使いどころ
- Webフレームワーク(axum, actix-web)のハンドラは
async fnで書くのが標準。DBクエリやHTTPクライアント呼び出しなど「待ち時間の長いI/O」を.awaitすることで、1つのOSスレッドで大量の同時リクエストを処理できる - CPU集約的な処理(画像処理・暗号化・大きなJSONのパース等)を非同期ハンドラの中に直接書くと、本問のタスクAのように他のリクエスト処理を止めてしまう。実務では
tokio::task::spawn_blockingやrayonとの連携で退避させるのが定石 - gRPCサーバー・メッセージキューコンシューマなど大量の同時接続・購読を扱うシステムでは、asyncタスクの軽量性(1タスクあたり数百バイト)がOSスレッドモデルに対する決定的な優位点になる
- ライブラリ設計では「このクレートの重い関数は同期版と非同期版のどちらを提供すべきか」を意識的に判断する必要があり、判断を誤ると利用者が本問と同じ事故を踏むことになる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
async fnを呼べば自動的に並行実行されると思う | JavaScriptのPromiseやGoのgoroutineの"eager"な挙動に慣れているため | RustのFutureはlazy。.awaitするかtokio::spawnに渡すまで一切実行されない |
async fnの中なら重い処理を書いても自動的に良い感じにスケジューリングされると思う | 「async」という単語から自動的な並行性を連想してしまう | 協調的マルチタスクでは.awaitポイントでしか制御が譲られない。重い同期処理は明示的にspawn_blocking等で退避が必要 |
.awaitは「別スレッドで実行する」という意味だと誤解する | thread::spawnとの類推 | .awaitは「準備ができるまでこのタスクを一時停止し、同じスレッド上の他のタスクに実行を譲る」という意味。別スレッドに移すわけではない |
複数のasync fn呼び出しを.awaitだけで並べれば並行に動くと思う | 複数書けば同時に走ると直感的に感じる | a().await; b().await;と書くと逐次実行になる。並行実行させたいならtokio::join!やtokio::spawnが必要 |
🚀 次のステップ
- 発展:
tokio::spawnで独立タスクとして切り出した場合とtokio::join!で1つのタスク内に併存させた場合とで、パニック時の挙動やキャンセルの伝播がどう変わるかを調べてみましょう - 次回予告: Futureトレイトとasyncランタイム(tokio) — 自前で簡単な
Futureを手書きしてpollモデルをさらに深く理解します