スレッドを使用してコードを同時に走らせる
多くの現代のOSでは、実行中のプログラムのコードはプロセスで走り、OSは同時に複数のプロセスを管理します。 自分のプログラム内で、独立した部分を同時に実行できます。これらの独立した部分を走らせる機能をスレッドと呼びます。
プログラム内の計算を複数のスレッドに分けると、パフォーマンスが改善します。プログラムが同時に複数の作業をするからですが、 複雑度も増します。スレッドは同時に走らせることができるので、異なるスレッドのコードが走る順番に関して、 本来的に保証はありません。これは例えば以下のような問題を招きます:
- スレッドがデータやリソースに矛盾した順番でアクセスする競合状態
- 2つのスレッドがお互いにもう一方が持っているリソースを使用し終わるのを待ち、両者が継続するのを防ぐデッドロック
- 特定の状況でのみ起き、確実な再現や修正が困難なバグ
Rustは、スレッドを使用する際の悪影響を軽減しようとしていますが、それでも、マルチスレッドの文脈でのプログラミングでは、 注意深い思考とシングルスレッドで走るプログラムとは異なるコード構造が必要です。
プログラミング言語によってスレッドはいくつかの方法で実装されています。多くのOSで、新規スレッドを生成するAPIが提供されています。 言語がOSのAPIを呼び出してスレッドを生成するこのモデルを時に1:1と呼び、1つのOSスレッドに対して1つの言語スレッドを意味します。
多くのプログラミング言語がスレッドの独自の特別な実装を提供しています。プログラミング言語が提供するスレッドは、
グリーンスレッドとして知られ、このグリーンスレッドを使用する言語は、それを異なる数のOSスレッドの文脈で実行します。
このため、グリーンスレッドのモデルはM:Nモデルと呼ばれます: M
個のグリーンスレッドに対して、
N
個のOSスレッドがあり、M
とN
は必ずしも同じ数字ではありません。
各モデルには、それだけの利点と代償があり、Rustにとって最も重要な代償は、ランタイムのサポートです。 ランタイムは、混乱しやすい用語で文脈によって意味も変わります。
この文脈でのランタイムとは、言語によって全てのバイナリに含まれるコードのことを意味します。 言語によってこのコードの大小は決まりますが、非アセンブリ言語は全てある量の実行時コードを含みます。 そのため、口語的に誰かが「ノーランタイム」と言ったら、「小さいランタイム」のことを意味することがしばしばあります。 ランタイムが小さいと機能も少ないですが、バイナリのサイズも小さくなるという利点があり、 その言語を他の言語とより多くの文脈で組み合わせることが容易になります。多くの言語では、 より多くの機能と引き換えにランタイムのサイズが膨れ上がるのは、受け入れられることですが、 Rustにはほとんどゼロのランタイムが必要でパフォーマンスを維持するためにCコードを呼び出せることを妥協できないのです。
M:Nのグリーンスレッドモデルは、スレッドを管理するのにより大きな言語ランタイムが必要です。よって、 Rustの標準ライブラリは、1:1スレッドの実装のみを提供しています。Rustはそのような低級言語なので、 例えば、むしろどのスレッドがいつ走るかのより詳細な制御や、より低コストの文脈切り替えなどの一面をオーバーヘッドと引き換えるなら、 M:Nスレッドの実装をしたクレートもあります。
今やRustにおけるスレッドを定義したので、標準ライブラリで提供されているスレッド関連のAPIの使用法を探究しましょう。
spawn
で新規スレッドを生成する
新規スレッドを生成するには、thread::spawn
関数を呼び出し、
新規スレッドで走らせたいコードを含むクロージャ(クロージャについては第13章で語りました)を渡します。
リスト16-1の例は、メインスレッドと新規スレッドからテキストを出力します:
ファイル名: src/main.rs
use std::thread; use std::time::Duration; fn main() { thread::spawn(|| { for i in 1..10 { // やあ!立ち上げたスレッドから数字{}だよ! println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } }); for i in 1..5 { // メインスレッドから数字{}だよ! println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } }
この関数では、新しいスレッドは、実行が終わったかどうかにかかわらず、メインスレッドが終了したら停止することに注意してください。 このプログラムからの出力は毎回少々異なる可能性がありますが、だいたい以下のような感じでしょう:
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
thread::sleep
を呼び出すと、少々の間、スレッドの実行を止め、違うスレッドを走らせることができます。
スレッドはおそらく切り替わるでしょうが、保証はありません: OSがスレッドのスケジュールを行う方法によります。
この実行では、コード上では立ち上げられたスレッドのprint文が先に現れているのに、メインスレッドが先に出力しています。また、
立ち上げたスレッドにはi
が9になるまで出力するよう指示しているのに、メインスレッドが終了する前の5までしか到達していません。
このコードを実行してメインスレッドの出力しか目の当たりにできなかったり、オーバーラップがなければ、 範囲の値を増やしてOSがスレッド切り替えを行う機会を増やしてみてください。
join
ハンドルで全スレッドの終了を待つ
リスト16-1のコードは、メインスレッドが終了するためにほとんどの場合、立ち上げたスレッドがすべて実行されないだけでなく、 立ち上げたスレッドが実行されるかどうかも保証できません。原因は、スレッドの実行順に保証がないからです。
thread::spawn
の戻り値を変数に保存することで、立ち上げたスレッドが実行されなかったり、
完全には実行されなかったりする問題を修正することができます。thread:spawn
の戻り値の型はJoinHandle
です。
JoinHandle
は、そのjoin
メソッドを呼び出したときにスレッドの終了を待つ所有された値です。
リスト16-2は、リスト16-1で生成したスレッドのJoinHandle
を使用し、join
を呼び出して、
main
が終了する前に、立ち上げたスレッドが確実に完了する方法を示しています:
ファイル名: src/main.rs
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } }); for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } handle.join().unwrap(); }
ハンドルに対してjoin
を呼び出すと、ハンドルが表すスレッドが終了するまで現在実行中のスレッドをブロックします。
スレッドをブロックするとは、そのスレッドが動いたり、終了したりすることを防ぐことです。
join
の呼び出しをメインスレッドのfor
ループの後に配置したので、リスト16-2を実行すると、
以下のように出力されるはずです:
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
2つのスレッドが代わる代わる実行されていますが、handle.join()
呼び出しのためにメインスレッドは待機し、
立ち上げたスレッドが終了するまで終わりません。
ですが、代わりにhandle.join()
をfor
ループの前に移動したらどうなるのか確認しましょう。こんな感じに:
ファイル名: src/main.rs
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } }); handle.join().unwrap(); for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } }
メインスレッドは、立ち上げたスレッドが終了するまで待ち、それからfor
ループを実行するので、
以下のように出力はもう混ざらないでしょう:
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
どこでjoin
を呼ぶかといったほんの些細なことが、スレッドが同時に走るかどうかに影響することもあります。
スレッドでmove
クロージャを使用する
move
クロージャは、thread::spawn
とともによく使用されます。
あるスレッドのデータを別のスレッドで使用できるようになるからです。
第13章で、クロージャの引数リストの前にmove
キーワードを使用して、
クロージャに環境で使用している値の所有権を強制的に奪わせることができると述べました。
このテクニックは、あるスレッドから別のスレッドに値の所有権を移すために新しいスレッドを生成する際に特に有用です。
リスト16-1において、thread::spawn
に渡したクロージャには引数がなかったことに注目してください:
立ち上げたスレッドのコードでメインスレッドからのデータは何も使用していないのです。
立ち上げたスレッドでメインスレッドのデータを使用するには、立ち上げるスレッドのクロージャは、
必要な値をキャプチャしなければなりません。リスト16-3は、メインスレッドでベクタを生成し、
立ち上げたスレッドで使用する試みを示しています。しかしながら、すぐにわかるように、これはまだ動きません:
ファイル名: src/main.rs
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
// こちらがベクタ: {:?}
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
クロージャはv
を使用しているので、v
をキャプチャし、クロージャの環境の一部にしています。
thread::spawn
はこのクロージャを新しいスレッドで走らせるので、
その新しいスレッド内でv
にアクセスできるはずです。しかし、このコードをコンパイルすると、
以下のようなエラーが出ます:
error[E0373]: closure may outlive the current function, but it borrows `v`,
which is owned by the current function
(エラー: クロージャは現在の関数よりも長生きするかもしれませんが、現在の関数が所有している
`v`を借用しています)
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {:?}", v);
| - `v` is borrowed here
|
help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
(助言: `v`(や他の参照されている変数)の所有権をクロージャに奪わせるには、`move`キーワードを使用してください)
|
6 | let handle = thread::spawn(move || {
| ^^^^^^^
Rustはv
のキャプチャ方法を推論し、println!
はv
への参照のみを必要とするので、クロージャは、
v
を借用しようとします。ですが、問題があります: コンパイラには、立ち上げたスレッドがどのくらいの期間走るのかわからないので、
v
への参照が常に有効であるか把握できないのです。
リスト16-4は、v
への参照がより有効でなさそうな筋書きです:
ファイル名: src/main.rs
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
// いや〜!
drop(v); // oh no!
handle.join().unwrap();
}
このコードを実行できてしまうなら、立ち上げたスレッドはまったく実行されることなく即座にバックグラウンドに置かれる可能性があります。
立ち上げたスレッドは内部にv
への参照を保持していますが、メインスレッドは、第15章で議論したdrop
関数を使用して、
即座にv
をドロップしています。そして、立ち上げたスレッドが実行を開始する時には、v
はもう有効ではなく、
参照も不正になるのです。あちゃー!
リスト16-3のコンパイルエラーを修正するには、エラーメッセージのアドバイスを活用できます:
help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ^^^^^^^
クロージャの前にmove
キーワードを付することで、コンパイラに値を借用すべきと推論させるのではなく、
クロージャに使用している値の所有権を強制的に奪わせます。リスト16-5に示したリスト16-3に対する変更は、
コンパイルでき、意図通りに動きます:
ファイル名: src/main.rs
use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(move || { println!("Here's a vector: {:?}", v); }); handle.join().unwrap(); }
move
クロージャを使用していたら、メインスレッドがdrop
を呼び出すリスト16-4のコードはどうなるのでしょうか?
move
で解決するのでしょうか?残念ながら、違います; リスト16-4が試みていることは別の理由によりできないので、
違うエラーが出ます。クロージャにmove
を付与したら、v
をクロージャの環境にムーブするので、
最早メインスレッドでdrop
を呼び出すことは叶わなくなるでしょう。代わりにこのようなコンパイルエラーが出るでしょう:
error[E0382]: use of moved value: `v`
(エラー: ムーブされた値の使用: `v`)
--> src/main.rs:10:10
|
6 | let handle = thread::spawn(move || {
| ------- value moved (into closure) here
...
10 | drop(v); // oh no!
| ^ value used here after move
|
= note: move occurs because `v` has type `std::vec::Vec<i32>`, which does
not implement the `Copy` trait
(注釈: `v`の型が`std::vec::Vec<i32>`のためムーブが起きました。この型は、`Copy`トレイトを実装していません)
再三Rustの所有権規則が救ってくれました!リスト16-3のコードはエラーになりました。
コンパイラが一時的に保守的になり、スレッドに対してv
を借用しただけだったからで、
これは、メインスレッドは理論上、立ち上げたスレッドの参照を不正化する可能性があることを意味します。
v
の所有権を立ち上げたスレッドに移動するとコンパイラに指示することで、
メインスレッドはもうv
を使用しないとコンパイラに保証しているのです。リスト16-4も同様に変更したら、
メインスレッドでv
を使用しようとする際に所有権の規則に違反することになります。
move
キーワードにより、Rustの保守的な借用のデフォルトが上書きされるのです;
所有権の規則を侵害させてくれないのです。
スレッドとスレッドAPIの基礎知識を得たので、スレッドでできることを見ていきましょう。