async/await のさらに詳しいトピック
ユニットテスト
async コードをユニットテストするにはどうすればよいでしょうか?問題は、await できるのは async コンテキストの内部からだけであり、Rust のユニットテストは async ではないという点です。幸いなことに、ほとんどのランタイムは async main 用のものと似た、テスト用の便利な属性を提供しています。Tokio を使う場合は、次のようになります。
#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_something() {
// ここにテストを書きます。必要なだけ `await` を含めて構いません。
}
}
テストを設定する方法は多数あります。詳細についてはドキュメントを参照してください。
async コードのテストには、さらに高度なトピックがいくつかあります(たとえば、競合状態、デッドロックなどのテスト)。このガイドの後の箇所で、その一部を取り上げます。
ブロッキングとキャンセル
async Rust でプログラミングする際には、ブロッキングとキャンセルを念頭に置くことが重要です。これらの概念は特定の機能や関数に局所化されたものではなく、正しいコードを書くために理解しなければならない、システム全体に遍在する性質です。
ブロッキング IO
スレッド(ここで話しているのは OS スレッドであり、async タスクではないことに注意してください)が進行できないとき、そのスレッドはブロックされていると言います。通常、それは OS がそのスレッドの代わりにタスク(通常は I/O)を完了するのを待っているためです。重要なのは、スレッドがブロックされている間、OS は他のスレッドが進行できるように、そのスレッドをスケジュールしないことを知っているという点です。これはマルチスレッドプログラムでは問題ありません。ブロックされたスレッドが待機している間に、他のスレッドが進行できるためです。しかし、async プログラムでは、同じ OS スレッド上でスケジュールされるべき他のタスクがありますが、OS はそれらを認識しておらず、スレッド全体を待機させ続けます。つまり、単一のタスクが I/O の完了を待つ(これは問題ありません)のではなく、多くのタスクが待たなければならない(これは問題です)ということです。
非ブロッキング/async I/O については、まもなく説明します。今のところは、非ブロッキング I/O とは async ランタイムが認識している I/O であり、そのため現在のタスクだけが待機し、スレッド自体はブロックされない、ということだけを知っておいてください。async タスクからは非ブロッキング I/O のみを使用し、ブロッキング I/O(Rust の標準ライブラリで提供される唯一の種類)を決して使用しないことが非常に重要です。
ブロッキング計算
計算を行うことによってスレッドをブロックすることもできます(OS が関与しないため、これはブロッキング I/O とまったく同じではありませんが、効果は似ています)。ランタイムに制御を譲らずに長時間実行される計算(ブロッキング I/O の有無にかかわらず)がある場合、そのタスクは、ランタイムのスケジューラが他のタスクをスケジュールする機会をまったく与えません。async プログラミングは協調的マルチタスクを使用することを思い出してください。ここではタスクが協調していないため、他のタスクは作業を進める機会を得られません。これを緩和する方法については後で説明します。
スレッド全体をブロックする方法は他にも多数あり、このガイドではブロッキングについて何度か再び取り上げます。
キャンセル
キャンセルとは、future(またはタスク)の実行を停止することを意味します。Rust では(そして他の多くの async/await システムとは対照的に)、future は外部の力(async ランタイムなど)によって前に進められなければならないため、future がもはや前に進められなければ、それ以上実行されません。future がドロップされると(future は単なる普通の Rust オブジェクトであることを思い出してください)、それ以上進行できなくなり、キャンセルされます。
キャンセルはいくつかの方法で開始できます。
- future を単にドロップする(それを所有している場合)。
- タスクの ‘JoinHandle’(または
AbortHandle)でabortを呼び出す。 CancellationTokenを介する(キャンセルされる future がトークンに気づき、協調的に自分自身をキャンセルする必要があります)。selectのような関数やマクロによって暗黙的に行う。
中央の 2 つは Tokio 固有のものですが、ほとんどのランタイムは同様の機能を提供しています。CancellationToken を使用するには、キャンセルされる future の協力が必要ですが、その他の方法では必要ありません。これらの他の場合では、キャンセルされた future はキャンセルの通知を受け取らず、(デストラクタ以外に)クリーンアップする機会もありません。future がキャンセルトークンを持っている場合でも、キャンセルトークンをトリガーしない他の方法でキャンセルされる可能性があることに注意してください。
async コード(async 関数、ブロック、future など)を書くという観点では、そのコードは任意の await(マクロ内の隠れたものを含む)で実行を停止し、二度と再開しない可能性があります。コードが正しい(具体的にはキャンセル安全である)ためには、正常に完了する場合でも、任意の await ポイントで終了する場合でも、正しく動作しなければなりません1。
#![allow(unused)]
fn main() {
async fn some_function(input: Option<Input>) {
let Some(input) = input else {
return; // ここで終了する可能性があります(`return`)。
};
let x = foo(input)?; // ここで終了する可能性があります(`?`)。
let y = bar(x).await; // ここで終了する可能性があります(`await`)。
// ...
// ここで終了する可能性があります(暗黙の return)。
}
}
これがどのように問題になるかの例として、async 関数がデータを内部バッファに読み込み、その後で次のデータを await する場合があります。データの読み込みが破壊的である場合(つまり、元のソースから再読み込みできない場合)に async 関数がキャンセルされると、内部バッファはドロップされ、その中のデータは失われます。future をキャンセルすること、future を再起動すること、または同じデータに触れる新しい future を開始することによって、future とそれが触れるデータがどのような影響を受けるかを考慮することが重要です。
このガイドでは、キャンセルとキャンセル安全性について何度か再び取り上げます。また、リファレンスセクションにはこのトピックに関する章が丸ごとあります。
async ブロック
通常のブロック({ ... })は、ソース内でコードをまとめ、名前のカプセル化スコープを作成します。実行時には、ブロックは順番に実行され、最後の式の値(または末尾の式がない場合はユニット型(()))に評価されます。
async 関数と同様に、async ブロックは通常のブロックの遅延版です。async ブロックはコードと名前を一緒にスコープ化しますが、実行時にはすぐには実行されず、future に評価されます。ブロックを実行して結果を得るには、await されなければなりません。例:
#![allow(unused)]
fn main() {
let s1 = {
let a = 42;
format!("The answer is {a}")
};
let s2 = async {
let q = question().await;
format!("The question is {q}")
};
}
continue; を呼び出していたなら、s1 は出力可能な文字列になりますが、s2 は Future になります。question() は呼び出されていません。s2 を出力するには、まず s2.await する必要があります。
async ブロックは、async コンテキストを開始して Future を作成する最も単純な方法です。これは一般に、1 か所でしか使われない小さな Future を作成するために使われます。
残念ながら、async ブロックでの制御フローには少し癖があります。async ブロックは素直に実行されるのではなく Future を作成するため、制御フローに関しては通常のブロックよりも関数に近い振る舞いをします。break と continue は、通常のブロックの場合のように async ブロックを「突き抜ける」ことはできません。代わりに return を使う必要があります。
#![allow(unused)]
fn main() {
loop {
{
if ... {
// OK
continue;
}
}
async {
if ... {
// OK ではない
// continue;
// OK - `loop` の次回の実行へ進む。ただし、async ブロックの後に
// ループ内のコードがある場合、そのコードは実行されることに注意。
return;
}
}.await
}
}
break を実装するには、ブロックの値をテストする必要があります(一般的なイディオムは、ブロックの値として ControlFlow を使うことで、これにより ? も使用できます)。
同様に、async ブロック内の ? は、エラーが存在する場合に Future の実行を終了させ、await されたブロックがそのエラーの値を取るようにしますが、周囲の関数からは抜けません(通常のブロック内の ? ならそうなります)。そのためには、await の後にもう 1 つ ? が必要です。
#![allow(unused)]
fn main() {
async {
let x = foo()?; // この `?` は async ブロックだけを抜け、周囲の関数は抜けません。
consume(x);
Ok(())
}.await?
}
厄介なことに、これによってコンパイラーが混乱することがよくあります。なぜなら、(関数とは異なり)async ブロックの「戻り値」の型は明示されていないからです。これを動作させるには、おそらく変数に型アノテーションを追加するか、turbofish による型指定を使う必要があります。たとえば、上の例では Ok(()) の代わりに Ok::<_, MyError>(()) を使います。
async ブロックを返す関数は、async 関数とかなり似ています。async fn foo() -> ... { ... } と書くことは、おおよそ fn foo() -> ... { async { ... } } と等価です。実際、呼び出し元の視点ではこれらは等価であり、一方の形式からもう一方の形式へ変更しても破壊的変更にはなりません。さらに、async トレイトを実装するときに、一方でもう一方をオーバーライドできます(下記参照)。ただし、型は調整する必要があり、async ブロック版では Future を明示します。つまり、async fn foo() -> Foo は fn foo() -> impl Future<Output = Foo> になります(たとえば Send や 'static など、他の境界も明示する必要があるかもしれません)。
通常は、より単純で明確な async 関数版を選ぶでしょう。しかし、async ブロック版のほうが柔軟です。関数が呼び出されたときに実行するコード(async ブロックの外側に書く)と、結果が await されたときに実行するコード(async ブロックの内側のコード)を分けられるためです。
Async クロージャ
- クロージャ
- 近日公開予定(https://github.com/rust-lang/rust/pull/132706, https://blog.rust-lang.org/inside-rust/2024/08/09/async-closures-call-for-testing.html)
- クロージャ内の async ブロックと async クロージャの比較
ライフタイムと借用
- 上で static ライフタイムについて言及しました
- Future 上のライフタイム境界(
Future + '_など) - await ポイントをまたぐ借用
- わかりません。async 関数にはきっともっと多くのライフタイムの問題があるはずです……
Future 上の Send + 'static 境界
- なぜそれらがあるのか、マルチスレッドランタイム
- それらを避けるために spawn local する
- async fn を
Send + 'staticにするものと、それに関するバグの修正方法
Async トレイト
- 構文
Send + 'staticの問題とその回避策- trait_variant
- 明示的な Future
- 戻り値型表記(https://blog.rust-lang.org/inside-rust/2024/09/26/rtn-call-for-testing.html)
- オーバーライド
- メソッドにおける Future 表記と async 表記
- オブジェクト安全性
- キャプチャ規則(https://blog.rust-lang.org/2024/09/05/impl-trait-capture-rules.html)
- 歴史と async-trait クレート
再帰
- 許可されています(比較的新しい)が、明示的なボックス化がいくらか必要です
- Future への前方参照、pinning
- https://rust-lang.github.io/async-book/07_workarounds/04_recursion.html
- https://blog.rust-lang.org/2024/03/21/Rust-1.77.0.html#support-for-recursion-in-async-fn
- async-recursion マクロ(https://docs.rs/async-recursion/latest/async_recursion/)
-
async プログラミングにおけるキャンセルを、スレッドのキャンセルと比較するのは興味深いことです。スレッドをキャンセルすることは可能です(たとえば C で
pthread_cancelを使用する場合。Rust にはこれを直接行う方法はありません)が、キャンセルされるスレッドはどこでも終了し得るため、ほとんどの場合、それは非常に、非常に悪い考えです。対照的に、async タスクのキャンセルは await ポイントでのみ発生します。その結果、プロセス全体を終了せずに OS スレッドをキャンセルすることは非常にまれであり、そのためプログラマとしては通常、それが発生することを心配しません。しかし async Rust では、キャンセルは間違いなく発生し得るものです。これにどう対処するかについては、進めながら説明していきます。 ↩