3.5 - 非同期プログラミングの理解
非同期プログラミングの理解
この章まで、私たちはコードを逐次的に書いてきました。標準的なスクリプトであればこれで十分です。しかし、Web開発では、サーバーへのリクエストが何度もあったり、API呼び出しでアイドルタイムが発生したりするため、非同期プログラミングが重要です。Pythonなどの他の言語では、非同期の概念に触れることなくWebサーバーを構築できるものもあります。これらのWebフレームワークでは、非同期の概念は利用されているものの、その実装はボンネットの中で定義されている。これはRustフレームワークのRocketにも言えることです。しかし、これまで見てきたように、Actix Webでは直接実装されています。
非同期コードを活用する場合、私たちが理解しなければならない主な概念は2つあります。
- プロセス
- プロセスとは、実行中のプログラムのことです。独自のメモリスタック、変数用レジスタ、コードを持つ。
- スレッド
- スレッドは、スケジューラによって独立して管理される軽量なプロセスです。しかし、スレッドは他のスレッドやメインプログラムとデータ、コード、ヒープを共有します。ただし、スレッドはスタックを共有しません。
これを示すのが、次のような古典的な図である。
図3.1 スレッドとプロセスの関係 [source:Cburnett (2007) (https://commons.wikimedia.org/wiki/File:Multithreaded_process.svg), CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/deed.en)].
スレッドとは何か、スレッドがコードとどのような関係にあるのかを高レベルで理解したところで、コードにスレッドを活用する方法を理解し、スレッドの効果を直接見るために、おもちゃの例で遊ぶことができます。古典的な例としては、単にスリープして時間をブロックする基本的な関数を作ることです。これは、ネットワークリクエストのような時間のかかる関数をシミュレートすることができます。次のようなコードで逐次実行することができます。
use std::{thread, time};
fn do_something(number: i8) -> i8 {
println!("number {} is running", number);
let two_seconds = time::Duration::new(2, 0);
thread::sleep(two_seconds);
return 2
}
fn main() {
let now = time::Instant::now();
let one: i8 = do_something(1);
let two: i8 = do_something(2);
let three: i8 = do_something(3);
println!("time elapsed {:?}", now.elapsed());
println!("result {}", one + two + three);
}
先のコードを実行すると、次のようなプリントアウトが得られます。
先の出力では、時間のかかる関数が期待通りの順序で実行されていることがわかります。また、プログラム全体の実行に6秒強かかっていますが、これは、2秒ずつスリープする高価な関数を3つ実行しているためです。また、高価な関数は値2を返します。3つの高価な関数の結果をすべて足し合わせると、値6の結果が得られることになります。3つのスレッドを同時に起動し、スレッドの完了を待って次に進むことで、プログラム全体をおよそ2秒に高速化しました。スレッドが完了するのを待ってから次に進むことを結合といいます。そこで、スレッドを回転させ始める前に、次のコードでjoinハンドラをインポートしておく必要があります。
次のコードで、メイン関数の中でスレッドをスピンアップできるようになりました。
let now = time::Instant::now();
let thread_one: JoinHandle<i8> = thread::spawn(|| do_something(1));
let thread_two: JoinHandle<i8> = thread::spawn(|| do_something(2));
let thread_three: JoinHandle<i8> = thread::spawn(|| do_something(3));
let result_one = thread_one.join();
let result_two = thread_two.join();
let result_three = thread_three.join();
println!("time elapsed {:?}", now.elapsed());
println!("result {}", result_one.unwrap() + result_two.unwrap() + result_three.unwrap());
先のコードを実行すると、次のようなプリントアウトが得られます。
見ての通り、全体の処理に2秒強の時間がかかっています。これは、3つのスレッドがすべて同時に実行されているためです。また、スレッド3はスレッド2の前に発射されていることに注意してください。1、2、3という順番になったとしても心配しないでください。スレッドは不定な順序で終了します。スケジューリングは決定論的ですが、CPUに何かをさせるようなイベントが何千も裏で起こっています。その結果、各スレッドが取得する正確なタイムスライスは決して同じではありません。このような小さな変化が積み重なっていくのです。このため、スレッドが確定的な順序で終了することを保証することはできません。
スレッドをスピンオフする方法を振り返ると、スレッドにクロージャを渡していることがわかります。もし、do_something関数をスレッドに渡そうとすると、コンパイラがFnOnce<()>クロージャを期待したのに、代わりにi8が見つかったというエラーが発生する。これは、標準的なクロージャがFnOnce<()> public traitを実装しているのに対し、do_something関数が単にi8を返すからです。FnOnce<()>が実装されている場合、クロージャは一度しか呼び出すことができません。つまり、スレッドを作成するときに、クロージャが一度だけ呼び出され、それが返されたときにスレッドが終了するようにすることができるのです。do_something関数はクロージャの最終行なので、i8が返されます。ただし、FnOnce<()> traitが実装されているからといって、複数回呼び出すことができるわけではないことに注意が必要です。この特性は、コンテキストが必要とする場合にのみ呼び出されます。つまり、スレッドコンテキストの外でクロージャを呼び出すとしたら、複数回呼び出すことができるということです。
また、結果を直接アンラップしていることにも注意してください。このことから、JoinHandle構造体のjoin関数はResultを返すことが推測され、ResultはErrまたはOkであることも分かっています。単にスリープして整数を返すだけなので、結果を直接アンラップしても問題ないことが分かっています。また、結果をプリントアウトしたところ、確かに整数でした。しかし、このエラーは期待するようなものではありません。Resultの型は、Result
match thread_result {
Ok(result) => {
println!("the result for {} is {}",
result, name);
}
Err(result) => {
if let Some(string) = result.downcast_ref::<String>() {
println!("the error for {} is: {}", name, string);
} else {
println!("there error for {} does not have a
message", name);
}
}
}
前述のコードにより、スレッドの結果を優雅に管理することができます。これで、スレッドの失敗を記録したり、以前のスレッドの結果に基づいて新しいスレッドを立ち上げたりすることを妨げるものは何もありません。このように、Result構造体がいかに強力であるかがわかります。スレッドに名前をつけたり、チャンネルを使ってスレッド間でデータを受け渡ししたりと、スレッドでできることはまだまだあります。しかし、本書の焦点はWebプログラミングであり、高度な並行処理デザインパターンや概念ではありません。しかし、このテーマに関するさらなる読み物が章末に提供されています。
これで、Rustでスレッドを立ち上げる方法、スレッドが返すもの、そしてスレッドを処理する方法について理解できました。この情報をもとに、次のセクションのasyncとawaitの構文を理解することに進みます。この構文はActix Webサーバで使用されるものです。