スレッドを使ってコードを同時実行する

RustRustBeginner
オンラインで実践に進む

This tutorial is from open-source community. Access the source code

💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください

はじめに

スレッドを使ってコードを同時実行するへようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。

この実験では、プログラミングにおけるスレッドの概念と、それを使ってコードを同時実行する方法を探ります。これによりパフォーマンスが向上しますが、競合条件、死鎖、再現が困難なバグなど、複雑さと潜在的な問題も増えます。

スレッドを使ってコードを同時実行する

現在のほとんどのオペレーティングシステムでは、実行されるプログラムのコードは プロセス 内で実行され、オペレーティングシステムは一度に複数のプロセスを管理します。プログラム内では、同時に実行される独立した部分も持つことができます。これらの独立した部分を実行する機能は スレッド と呼ばれます。たとえば、Web サーバーは複数のスレッドを持つことができ、同時に 1 つ以上の要求に応答できるようになります。

プログラム内の計算を複数のスレッドに分割して、同時に複数のタスクを実行することでパフォーマンスを向上させることができますが、複雑さも増えます。スレッドは同時に実行できるため、異なるスレッド上のコードの各部分が実行される順序については、何らかの保証がありません。これにより、次のような問題が発生する可能性があります。

  • 競合条件:スレッドがデータやリソースにアクセスする順序が整合していない場合
  • 死鎖:2 つのスレッドが互いを待ち続け、両方のスレッドが進行できなくなる場合
  • 特定の状況でのみ発生し、再現と信頼性の高い修正が困難なバグ

Rust は、スレッドを使用することのマイナス影響を軽減しようとしていますが、マルチスレッドコンテキストでのプログラミングには、依然として注意深い考えが必要であり、単一スレッドで実行されるプログラムとは異なるコード構造が必要です。

プログラミング言語は、いくつかの異なる方法でスレッドを実装しており、多くのオペレーティングシステムは、新しいスレッドを作成するために言語が呼び出せる API を提供しています。Rust 標準ライブラリは、スレッド実装の 1 対 1 モデルを使用しており、プログラムは 1 つの言語スレッドに対して 1 つのオペレーティングシステムスレッドを使用します。1 対 1 モデルとは異なるトレードオフを行う他のスレッドモデルを実装するクレートもあります。

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 {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 までしか表示されませんでした。

このコードを実行して、メインスレッドの出力のみが表示されるか、または何も重複して表示されない場合は、範囲内の数値を増やして、オペレーティングシステムがスレッド間で切り替える機会を増やしてみてください。

join ハンドルを使ってすべてのスレッドが終了するのを待つ

リスト 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() の呼び出しのためにメインスレッドが待機し、生成されたスレッドが終了するまで終了しません。

では、mainfor ループの前に 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 を呼び出す場所などの小さな詳細が、スレッドが同時に実行されるかどうかに影響を与えることがあります。

スレッドで move クロージャを使用する

thread::spawn に渡されるクロージャでは、move キーワードを頻繁に使用します。これは、クロージャが環境から使用する値の所有権を取得するため、それらの値の所有権を 1 つのスレッドから別のスレッドに移すためです。「クロージャで環境をキャプチャする」では、クロージャのコンテキストで move について説明しました。今回は、movethread::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 でさらに多くの実験を行って、技術力を向上させることができます。