はじめに
スレッドを使ってコードを同時実行するへようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。
この実験では、プログラミングにおけるスレッドの概念と、それを使ってコードを同時実行する方法を探ります。これによりパフォーマンスが向上しますが、競合条件、死鎖、再現が困難なバグなど、複雑さと潜在的な問題も増えます。
This tutorial is from open-source community. Access the source code
💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください
スレッドを使ってコードを同時実行するへようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。
この実験では、プログラミングにおけるスレッドの概念と、それを使ってコードを同時実行する方法を探ります。これによりパフォーマンスが向上しますが、競合条件、死鎖、再現が困難なバグなど、複雑さと潜在的な問題も増えます。
現在のほとんどのオペレーティングシステムでは、実行されるプログラムのコードは プロセス 内で実行され、オペレーティングシステムは一度に複数のプロセスを管理します。プログラム内では、同時に実行される独立した部分も持つことができます。これらの独立した部分を実行する機能は スレッド と呼ばれます。たとえば、Web サーバーは複数のスレッドを持つことができ、同時に 1 つ以上の要求に応答できるようになります。
プログラム内の計算を複数のスレッドに分割して、同時に複数のタスクを実行することでパフォーマンスを向上させることができますが、複雑さも増えます。スレッドは同時に実行できるため、異なるスレッド上のコードの各部分が実行される順序については、何らかの保証がありません。これにより、次のような問題が発生する可能性があります。
Rust は、スレッドを使用することのマイナス影響を軽減しようとしていますが、マルチスレッドコンテキストでのプログラミングには、依然として注意深い考えが必要であり、単一スレッドで実行されるプログラムとは異なるコード構造が必要です。
プログラミング言語は、いくつかの異なる方法でスレッドを実装しており、多くのオペレーティングシステムは、新しいスレッドを作成するために言語が呼び出せる API を提供しています。Rust 標準ライブラリは、スレッド実装の 1 対 1 モデルを使用しており、プログラムは 1 つの言語スレッドに対して 1 つのオペレーティングシステムスレッドを使用します。1 対 1 モデルとは異なるトレードオフを行う他のスレッドモデルを実装するクレートもあります。
新しいスレッドを作成するには、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 {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
リスト 16-1: 新しいスレッドを作成して、メインスレッドが別のものを表示する間に何かを表示する
Rust プログラムのメインスレッドが完了すると、すべての生成されたスレッドは、実行が完了しているかどうかに関係なく終了します。このプログラムの出力は、毎回少し異なる場合がありますが、次のようになるでしょう。
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
への呼び出しにより、スレッドは短時間実行を停止し、別のスレッドが実行できるようになります。スレッドはおそらく交互になるでしょうが、それは保証されていません。どのようにオペレーティングシステムがスレッドをスケジュールするかに依存します。この実行では、生成されたスレッドの print 文がコード内で最初に表示されているにもかかわらず、メインスレッドが最初に表示されました。また、生成されたスレッドに i
が 9 になるまで表示するように指示しましたが、メインスレッドが終了する前に 5 までしか表示されませんでした。
このコードを実行して、メインスレッドの出力のみが表示されるか、または何も重複して表示されない場合は、範囲内の数値を増やして、オペレーティングシステムがスレッド間で切り替える機会を増やしてみてください。
リスト 16-1 のコードは、主にメインスレッドが終了するために生成されたスレッドを不適切に早期に終了させるだけでなく、スレッドが実行される順序が保証されていないため、生成されたスレッドがまったく実行されないことさえ保証できません!
生成されたスレッドが実行されないか、または早期に終了する問題を解決するには、thread::spawn
の戻り値を変数に保存します。thread::spawn
の戻り型は JoinHandle<T>
です。JoinHandle<T>
は、所有された値であり、その join
メソッドを呼び出すと、そのスレッドが終了するまで待ちます。リスト 16-2 は、リスト 16-1 で作成したスレッドの JoinHandle<T>
を使用して、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 {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
リスト 16-2: thread::spawn
からの JoinHandle<T>
を保存して、スレッドが完了するまで実行されることを保証する
ハンドルに対して 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()
の呼び出しのためにメインスレッドが待機し、生成されたスレッドが終了するまで終了しません。
では、main
の for
ループの前に handle.join()
を移動するとどうなるか見てみましょう。このように:
ファイル名:src/main.rs
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
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
を呼び出す場所などの小さな詳細が、スレッドが同時に実行されるかどうかに影響を与えることがあります。
thread::spawn
に渡されるクロージャでは、move
キーワードを頻繁に使用します。これは、クロージャが環境から使用する値の所有権を取得するため、それらの値の所有権を 1 つのスレッドから別のスレッドに移すためです。「クロージャで環境をキャプチャする」では、クロージャのコンテキストで move
について説明しました。今回は、move
と thread::spawn
の相互作用に焦点を当てます。
リスト 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();
}
リスト 16-3: 別のスレッドでメインスレッドによって作成されたベクトルを使用しようとする
クロージャは v
を使用するため、v
をキャプチャしてクロージャの環境の一部にします。thread::spawn
はこのクロージャを新しいスレッドで実行するため、その新しいスレッド内で v
にアクセスできるはずです。しかし、この例をコンパイルすると、次のエラーが表示されます。
error[E0373]: closure may outlive the current function, but it borrows `v`,
which is owned by the current function
--> 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
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {:?}", v);
8 | | });
| |______^
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 || {
| ++++
Rust は、どのように v
をキャプチャするかを推論します。println!
は v
への参照のみが必要なため、クロージャは v
を借用しようとします。しかし、問題があります。Rust は生成されたスレッドがどのくらい実行されるかを知ることができないため、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();
}
リスト 16-4: メインスレッドが v
を破棄する生成されたスレッドによって v
への参照をキャプチャしようとするクロージャを持つスレッド
Rust がこのコードを実行させる場合、生成されたスレッドがまったく実行されずに即座にバックグラウンドに置かれる可能性があります。生成されたスレッドは内部で 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
キーワードを追加することで、クロージャに値の所有権を取得させるように強制し、Rust が値を借用するように推論するのを許さなくなります。リスト 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();
}
リスト 16-5: move
キーワードを使用して、クロージャに使用する値の所有権を取得させる
リスト 16-4 のコードを修正するために、メインスレッドが drop
を呼び出した場合と同じことを試してみたくなるかもしれません。しかし、この修正は機能しません。なぜなら、リスト 16-4 が試していることは、別の理由で禁止されているからです。クロージャに move
を追加すると、v
がクロージャの環境に移動し、メインスレッドではもはや drop
を呼び出せなくなります。代わりに、次のコンパイラエラーが表示されます。
error[E0382]: use of moved value: `v`
--> src/main.rs:10:10
|
4 | let v = vec![1, 2, 3];
| - move occurs because `v` has type `Vec<i32>`, which does not
implement the `Copy` trait
5 |
6 | let handle = thread::spawn(move || {
| ------- value moved into closure here
7 | println!("Here's a vector: {:?}", v);
| - variable moved due to use in
closure
...
10 | drop(v); // oh no!
| ^ value used here after move
Rust の所有権ルールが再び助けてくれました!リスト 16-3 のコードでエラーが表示されたのは、Rust が保守的で、スレッドに対して v
を借用していたためです。これは、理論的にメインスレッドが生成されたスレッドの参照を無効にする可能性があることを意味します。v
の所有権を生成されたスレッドに移動するように Rust に指示することで、メインスレッドがもはや v
を使用しないことを Rust に保証しています。同じ方法でリスト 16-4 を変更すると、メインスレッドで v
を使用しようとするときに所有権ルールに違反してしまいます。move
キーワードは、借用する Rust の保守的な既定値を上書きします。所有権ルールを破ることはできません。
ここまでで、スレッドとは何か、およびスレッド API が提供するメソッドについて説明しました。次に、スレッドを使用できる状況をいくつか見てみましょう。
おめでとうございます! 「スレッドを使ってコードを同時実行する」実験を完了しました。LabEx でさらに多くの実験を行って、技術力を向上させることができます。