すべてをまとめる: Future、タスク、スレッド
第16章で見たように、スレッドは並行性への1つのアプローチを提供します。
この章では別のアプローチ、すなわち Future とストリームとともに async を
使う方法も見てきました。どちらの方法をいつ選ぶべきか気になるなら、その答えは
「場合による」です! そして多くの場合、選択肢はスレッド か async
ではなく、スレッド と async です。
多くのオペレーティングシステムは、すでに何十年にもわたってスレッドベースの 並行モデルを提供してきており、その結果として多くのプログラミング言語がそれを サポートしています。しかし、これらのモデルにもトレードオフがないわけでは ありません。多くのオペレーティングシステムでは、スレッドごとにかなりの量の メモリを使用します。また、スレッドが選択肢になるのは、オペレーティング システムとハードウェアがそれをサポートしている場合だけです。一般的な デスクトップやモバイルコンピューターとは異なり、一部の組み込みシステムには そもそも OS がないため、スレッドもありません。
async モデルは、異なる、そして最終的には相補的なトレードオフの組を
提供します。async モデルでは、並行な操作はそれぞれ専用のスレッドを必要と
しません。その代わり、ストリームの節で同期関数から処理を開始するために
trpl::spawn_task を使ったときのように、タスク上で実行できます。タスクは
スレッドに似ていますが、オペレーティングシステムに管理されるのではなく、
ライブラリレベルのコード、つまりランタイムに管理されます。
スレッドを生成する API とタスクを生成する API がこれほどよく似ているのには 理由があります。スレッドは同期操作の集合に対する境界として機能し、並行性は スレッド 間 で可能になります。タスクは 非同期 操作の集合に対する境界として 機能し、タスクは本体の中で Future を切り替えられるため、並行性はタスク 間 と タスク 内部 の両方で可能になります。最後に、Future は Rust における最も 粒度の細かい並行性の単位であり、それぞれの Future は別の Future の木を表す ことがあります。ランタイム、具体的にはそのエグゼキュータがタスクを管理し、 タスクが Future を管理します。その点で、タスクは軽量でランタイム管理の スレッドに似ていますが、オペレーティングシステムではなくランタイムによって 管理されることから生まれる追加の機能を備えています。
だからといって、async タスクが常にスレッドより優れている(あるいはその逆)
わけではありません。スレッドによる並行性は、ある意味では async による
並行性よりも単純なプログラミングモデルです。それは強みにも弱みにもなりえます。
スレッドはある程度「fire and forget」であり、Future に相当するネイティブな
仕組みを持たないため、オペレーティングシステム自身によって中断される場合を
除いて、単に完了まで実行されます。
そして実際のところ、スレッドとタスクはしばしば非常にうまく連携します。
なぜなら、タスクは(少なくとも一部のランタイムでは)スレッド間を移動できる
からです。実際、内部では、私たちが使ってきたランタイムは spawn_blocking と
spawn_task 関数を含め、デフォルトでマルチスレッドです! 多くのランタイムは、
システム全体のパフォーマンスを向上させるために、現在スレッドがどのように
利用されているかに基づいて、タスクをスレッド間で透過的に移動させる
ワークスティーリング と呼ばれるアプローチを使います。そのアプローチには
実際にはスレッド と タスク、したがって Future が必要です。
どの方法をいつ使うかを考えるときには、次の経験則を考慮してください。
- 作業が 非常に並列化しやすい(つまり、CPU バウンド)場合、たとえば それぞれの部分を別々に処理できる大量のデータを処理するようなケースでは、 スレッドのほうが適しています。
- 作業が 非常に並行的(つまり、I/O バウンド)である場合、たとえば異なる
間隔や異なる速度で届く可能性がある多数の異なるソースからのメッセージを
処理するようなケースでは、
asyncのほうが適しています。
また、並列性と並行性の両方が必要な場合でも、スレッドと async のどちらかを
選ばなければならないわけではありません。両者を自由に組み合わせて使い、
それぞれが最も得意な役割を担わせることができます。たとえば、リスト 17-25 は
実世界の Rust コードにおけるこの種の組み合わせの、かなり一般的な例を示して
います。
extern crate trpl; // for mdbook test
use std::{thread, time::Duration};
fn main() {
let (tx, mut rx) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
tx.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::block_on(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
まず async チャネルを作成し、それから move キーワードを使ってチャネルの
送信側の所有権を受け取るスレッドを生成します。スレッドの中では、1 から 10
までの数値を送信し、その間に毎回 1 秒ずつスリープします。最後に、この章を
通して行ってきたのと同じように、trpl::block_on に渡した async ブロックで
作成した Future を実行します。その Future では、これまで見てきたほかの
メッセージ受け渡しの例と同じように、それらのメッセージを待機します。
章の冒頭で取り上げたシナリオに戻ると、専用スレッドを使って一連の動画 エンコードタスクを実行し(動画エンコードは計算バウンドであるため)、 それらの操作が完了したことを async チャネルで UI に通知する場面を想像して みてください。この種の組み合わせの例は、実世界のユースケースに無数にあります。
まとめ
この本で並行性を扱うのはこれが最後ではありません。第21章 のプロジェクトでは、ここで扱ったより単純な例よりも現実的な状況でこれらの 概念を適用し、スレッド化とタスクおよび Future による問題解決をより直接的に 比較します。
これらのアプローチのどれを選ぶにしても、Rust は安全で高速な並行コードを 書くために必要なツールを提供してくれます。高スループットな Web サーバー向け であれ、組み込みオペレーティングシステム向けであれ、それは同じです。
次は、Rust プログラムが大きくなるにつれて、問題をモデル化し解決策を構造化 するための慣用的な方法について話します。さらに、Rust のイディオムが、 オブジェクト指向プログラミングで見慣れているかもしれないものとどのように 関係しているかも議論します。