asyncの実体
この章でわかること:
async fnがコンパイルで何になるか(状態機械とそのサイズの実測)pollとWakerによる駆動モデル。ランタイム(tokio)が何をしているか- タスクとOSスレッドのコスト差の実測(生成コストで約160倍)
- asyncで最も注意すべき点: ランタイムをブロックすること(実測つき)
- なぜ体系に必須か: Webアプリケーションの並行処理の中心はasyncです。 5章で「並行はasyncが担う」と整理したまま 内部を説明しなければ、本書の並行処理の全体像は半分欠けたままです
async fnは何にコンパイルされるか
最も重要な点として、async fnの呼び出しは、8章の
遅延評価の実験で見たイテレータと同じく、実行すべき処理を表す値を
作って返すだけであり、その時点では実行されません。
この値がFutureです。
Futureの実体は、コンパイラがasync fnの本体を
.awaitの位置で分割した状態機械(state machine)に変換したものです。
「開始前」「1つ目のawaitで中断中」「2つ目のawaitで中断中」のような
状態をenumのように持ち、
poll(後述)が呼ばれるたびに続きを実行して次の状態へ進む構造体です。
性能上重要なのはサイズです。中断中も有効な局所変数は、 すべてこの構造体の中に保存されます。実験で確かめます。
use std::mem::size_of_val;
async fn tiny() -> u64 {
1 + 1
}
// 4KBのバッファを .await をまたいで持つ
async fn holds_buffer() -> u64 {
let buf = [7u8; 4096];
tokio::task::yield_now().await; // ここで中断しうる
buf.iter().map(|&b| b as u64).sum()
}
// 同じバッファでも .await の前に使い終わる
async fn drops_before_await() -> u64 {
let sum = {
let buf = [7u8; 4096];
buf.iter().map(|&b| b as u64).sum()
};
tokio::task::yield_now().await;
sum
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let f1 = tiny();
let f2 = holds_buffer();
let f3 = drops_before_await();
println!("tiny のFuture: {:>5} bytes", size_of_val(&f1));
println!("バッファをまたぐ のFuture: {:>5} bytes", size_of_val(&f2));
println!("またぐ前に手放す のFuture: {:>5} bytes", size_of_val(&f3));
println!("結果: {} {} {}", f1.await, f2.await, f3.await);
}
筆者の実測(Playground)では、次の結果でした。
- 何も持たない
async fnのFutureは1バイトです(状態番号だけ) - 4KBの配列を
.awaitをまたいで持つFutureは4120バイトです - 同じ配列でも
.awaitの前に使い終わるFutureは32バイトです
.awaitをまたぐ変数はFutureに埋め込まれます。この規則を知らないと、
「大きなバッファを持ったままawaitする関数」を重ねただけで
Futureが大きくなります(入れ子のasync fnのFutureは外側のFutureに
入れ子で埋め込まれるため、深い呼び出し連鎖では合算されます)。
tokioのspawnはFutureをヒープに置くので、これは
アロケーションのサイズ(18章)に直結します。
対策は実験の3つ目が示しています。大きな一時データは
awaitをまたぐ前にdropするか、Boxでヒープに置きます。
poll、Waker、ランタイム
Futureを実行するのがランタイム(async runtime)です。
Rust本体はFutureの形だけを定め、実行機構はtokioなどのライブラリに
委ねています。その「形」がFutureトレイトです。
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
見慣れないPin<&mut Self>は「このFutureをこれ以降メモリ上で
移動しない」という制約です。状態機械の中には「自分自身の
別のフィールドを指す参照」が保存されうるため(awaitをまたいで
局所変数への参照が有効な場合)、構造体ごとメモリ上を移動すると
その内部参照は無効になります。Pinは移動をコンパイル時に禁じることで
これを防ぎます。Contextは後述のWakerを保持する構造体です。
駆動モデルはプル型です。
- ランタイムがFutureの
pollを呼び、処理の続行を要求します - Futureは進められるところまで進み、完了なら
Ready(結果)、 待ちが必要ならPendingを返します。このとき、待っている対象 (ソケットなど)が準備できたら通知を受けられるよう、Wakerという 通知ハンドルを登録します - OSのI/O通知(Linuxならepoll)が来たら、ランタイムはWaker経由で
そのタスクを実行待ち行列に戻し、また
pollします
つまりasyncの並行性とは、「何万もの状態機械を、少数のOSスレッドが
pollする」構造です。tokioの既定(マルチスレッドランタイム)は
コア数分のワーカースレッドを持ち、5章のrayonと
同じワークスティーリングでタスクを分配します。rayonとの違いは
扱う処理の性質です。rayonは計算が終わるまで実行し続ける処理を、
tokioはすぐにPendingで戻る処理を扱うように設計されています。
タスクのコスト: 実測
「asyncのタスクはOSスレッドより軽い」とよく言われます。実測で確かめます。
use std::time::Instant;
fn main() {
// (1) OSスレッドを1万本
let start = Instant::now();
let handles: Vec<_> = (0..10_000)
.map(|i| std::thread::spawn(move || i as u64 * 2))
.collect();
let sum: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
println!("OSスレッド 1万: {:>9.3?} (sum={sum})", start.elapsed());
// (2) tokioタスクを10万個
let rt = tokio::runtime::Runtime::new().unwrap();
let start = Instant::now();
let sum: u64 = rt.block_on(async {
let handles: Vec<_> = (0..100_000)
.map(|i| tokio::spawn(async move { i as u64 * 2 }))
.collect();
let mut sum = 0u64;
for h in handles {
sum += h.await.unwrap();
}
sum
});
println!("tokioタスク 10万: {:>9.3?} (sum={sum})", start.elapsed());
}
筆者の実測(Playground)では、OSスレッド1万本の生成と合流が 約760ミリ秒(1本あたり約76マイクロ秒)、tokioタスク10万個が 約47ミリ秒(1個あたり約0.5マイクロ秒)でした。1個あたり約160倍の差です (規模も条件も同一ではない粗い比較です。スレッドを10万本 生成する実験自体が現実的でない、という差も含めて読んでください)。
差の原因は明確です。OSスレッドはカーネルへのシステムコール (27章)と、スタック用のメモリ領域 (既定で数MBの仮想空間+実体は14章の デマンドページング)を必要とします。tokioのタスクは ユーザー空間でのヒープ確保1回(Futureのサイズ分)と 待ち行列への追加だけです。「1接続1タスク」を10万接続で 実現できるのはこのコスト差のためです。
同じ理由で、タスクの切り替え(あるタスクがPendingになり 別のタスクをpollする)もユーザー空間の関数呼び出しにすぎず、 OSのコンテキストスイッチ(27章で実測します)より大幅に低コストです。
最も注意すべき点: ランタイムをブロックする
asyncの協調モデルには前提があります。
タスクはpollされたら速やかにPendingかReadyを返すことです。
pollの中で長時間戻らないタスクは、そのワーカースレッドを占有し、
同じスレッドで実行されるはずだった他の全タスクも停止させます。
これを実測します。1スレッド構成のランタイムで、 「300ミリ秒の同期スリープ(CPUを解放しないブロッキング処理の代わり)」を するタスクAと、「50ミリ秒後に完了するはずの」タスクBを同じランタイムで実行します。
use std::time::{Duration, Instant};
// 1スレッド構成のランタイムで、
// 「重い同期処理」が同居するタスクにどう影響するかを見る
fn main() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
// (1) タスクAが std::thread::sleep でスレッドごと止める
rt.block_on(async {
let start = Instant::now();
let b = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
start.elapsed()
});
// タスクBが先にタイマーを登録できるよう、一度実行を譲る
tokio::task::yield_now().await;
let a = tokio::spawn(async {
std::thread::sleep(Duration::from_millis(300)); // ブロッキング!
});
a.await.unwrap();
let b_done = b.await.unwrap();
println!("(1) ブロッキング同居: 50msのはずのタスクBの完了 = {b_done:?}");
});
// (2) 重い処理を spawn_blocking で専用スレッドへ逃がす
rt.block_on(async {
let start = Instant::now();
let b = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
start.elapsed()
});
tokio::task::yield_now().await;
let a = tokio::task::spawn_blocking(|| {
std::thread::sleep(Duration::from_millis(300));
});
a.await.unwrap();
let b_done = b.await.unwrap();
println!("(2) spawn_blocking : 50msのはずのタスクBの完了 = {b_done:?}");
});
}
筆者の実測(Playground)では、タスクBの完了は約300ミリ秒でした。
Bは50ミリ秒のタイマーですが、タスクAが実行を返さない間は
一度もpollされず、Aのブロックが終わるまで待たされました。
spawn_blocking(ブロッキング処理を専用のスレッドプールで実行するtokioのAPI)に
変えると、Bは予定どおり51ミリ秒で完了します。
実務での規則は次のとおりです。
- 同期I/O、重いCPU計算、ロック待ちの長い処理を、asyncタスクの中で
直接実行しません。
spawn_blocking(または重い計算ならrayonへの 受け渡し)で隔離します - マルチスレッドランタイムでも、ワーカーが8本なら8個のブロッキングで 全停止するため、問題は残ります。負荷が上がったときだけ レイテンシが増える、という発見しにくい形で現れます
- 「重い」の目安としては、10〜100マイクロ秒を超えて
pollを 占有する処理、という値がtokioの開発者たちの解説でよく引かれます
.awaitとゼロコスト抽象化
状態機械への変換自体は7章の意味で
ゼロコストに近く、.await1回は「関数呼び出し+分岐」程度です。
コストが発生するのは、Futureのサイズ(この章の実験1)、
タスクのspawn(ヒープ確保)、そしてランタイムのI/O多重化の層です。
遅さは、どの層のコストかという単位で分析するものであり、
「asyncだから遅い」とまとめられるものではありません。
使い分け: async / スレッド / rayon
5章の表を完成させます。
| 手段 | 向く処理 | 単位コスト |
|---|---|---|
| async (tokio) | 大量の待ち(I/O)の多重化 | タスク約0.5µs、待ちのコストはほぼゼロ |
| OSスレッド | 少数の長寿命な並行作業、ブロッキングの隔離 | 生成約76µs+スタック |
| rayon | CPUを使い切るデータ並列計算 | 5章のとおり |
Webサーバの典型構成はこの3層の組み合わせです。接続の待ち受けと
I/Oはtokioが担当し、その中で発生した重い計算はrayonか
spawn_blockingに渡します。この分担に反したとき(計算をasyncで直接実行する、
I/Oをrayonで待つ)に、この章で述べたランタイムの停止が起きます。
まとめ
async fnは.awaitで分割された状態機械を返します。 awaitをまたぐ変数はFutureに埋め込まれ、サイズを実測で 1バイト〜4120バイトまで見ました- ランタイムは
pollとWakerで状態機械を駆動します。tokioは コア数のワーカー+ワークスティーリングです - タスクの生成はスレッドの約160分の1のコストでした。 「1接続1タスク」の根拠です
pollを長時間占有するとランタイムごと止まります(実測: 50msの タスクが約300msに)。同期I/Oと重い計算はspawn_blockingやrayonに渡します- async・スレッド・rayonは競合ではなく、待ち/長寿命/計算の分担です
次章は、最適化の最終手段としてたびたび言及したunsafeを
正面から扱います。未定義動作とは何か、何が許され何が許されないか、
そしてそれを機械的に検査するツール(Miri)までを説明します。