スレッドを使用してコードを同時に走らせる

多くの現代のOSでは、実行中のプログラムのコードはプロセスで走り、OSは同時に複数のプロセスを管理します。 自分のプログラム内で、独立した部分を同時に実行できます。これらの独立した部分を走らせる機能をスレッドと呼びます。

プログラム内の計算を複数のスレッドに分けると、パフォーマンスが改善します。プログラムが同時に複数の作業をするからですが、 複雑度も増します。スレッドは同時に走らせることができるので、異なるスレッドのコードが走る順番に関して、 本来的に保証はありません。これは例えば以下のような問題を招きます:

  • スレッドがデータやリソースに矛盾した順番でアクセスする競合状態
  • 2つのスレッドがお互いにもう一方が持っているリソースを使用し終わるのを待ち、両者が継続するのを防ぐデッドロック
  • 特定の状況でのみ起き、確実な再現や修正が困難なバグ

Rustは、スレッドを使用する際の悪影響を軽減しようとしていますが、それでも、マルチスレッドの文脈でのプログラミングでは、 注意深い思考とシングルスレッドで走るプログラムとは異なるコード構造が必要です。

プログラミング言語によってスレッドはいくつかの方法で実装されています。多くのOSで、新規スレッドを生成するAPIが提供されています。 言語がOSのAPIを呼び出してスレッドを生成するこのモデルを時に1:1と呼び、1つのOSスレッドに対して1つの言語スレッドを意味します。

多くのプログラミング言語がスレッドの独自の特別な実装を提供しています。プログラミング言語が提供するスレッドは、 グリーンスレッドとして知られ、このグリーンスレッドを使用する言語は、それを異なる数のOSスレッドの文脈で実行します。 このため、グリーンスレッドのモデルはM:Nモデルと呼ばれます: M個のグリーンスレッドに対して、 N個のOSスレッドがあり、MNは必ずしも同じ数字ではありません。

各モデルには、それだけの利点と代償があり、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));
    }
}

リスト16-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();
}

リスト16-2: thread::spawnJoinHandleを保存してスレッドが完了するのを保証する

ハンドルに対して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();
}

リスト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
(エラー: クロージャは現在の関数よりも長生きするかもしれませんが、現在の関数が所有している
`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();
}

リスト16-4: vをドロップするメインスレッドからvへの参照をキャプチャしようとするクロージャを伴うスレッド

このコードを実行できてしまうなら、立ち上げたスレッドはまったく実行されることなく即座にバックグラウンドに置かれる可能性があります。 立ち上げたスレッドは内部に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();
}

リスト16-5: moveキーワードを使用してクロージャに使用している値の所有権を強制的に奪わせる

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の基礎知識を得たので、スレッドでできることを見ていきましょう。

関連キーワード:  thread, from, hi, spawned, コード, メインスレッド, リスト, 実行, spawn, handle