コンテンツにスキップ

Rustの並行処理

並行処理(concurrency)とは、複数の作業を同時に進めることです。多くの言語では、ここが本当に危ない部分になります。2つのスレッドが同じデータを同時に触ると、データが壊れてしまうことがあり、しかもその原因を突き止めるのがとても大変だからです。借用のルールは、まさにこういうバグを防ぐために作られています。そしてその守りは、そのままスレッドにも効くのです。

use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("スレッド: {i}");
}
});
for i in 1..=3 {
println!("メイン: {i}");
}
handle.join().unwrap(); // スレッドが終わるのを待つ
}
▶ 実際に試してみよう

thread::spawn はクロージャを受け取り、それを新しいスレッドで、ほかの処理と並行して実行します。.join() は、そのスレッドが終わるまで待ちます。これがないと、作ったスレッドが動き出す前に main が終了してしまうかもしれません。

スレッドにデータをムーブする

Section titled “スレッドにデータをムーブする”

thread::spawn に渡すクロージャは、使うデータを完全に所有している必要があります。元のスコープが終わったあとで実行されるかもしれないからです。move キーワードを付けると、所有権をクロージャに移せます。

use std::thread;
fn main() {
let message = String::from("スレッドからこんにちは");
let handle = thread::spawn(move || {
println!("{message}");
});
handle.join().unwrap();
}

実行結果:

スレッドからこんにちは
▶ 実際に試してみよう

move がないと、Rust はこのコードをコンパイルしません。新しいスレッドが実際に message を使うときに、message がまだ有効かどうかを保証できないからです。move は message の所有権をクロージャへ移します。所有権のページで見た「ムーブ」と同じもので、渡す相手が別の変数ではなくスレッドになっただけです。

スレッド間で安全にデータを共有する

Section titled “スレッド間で安全にデータを共有する”

ふつうの参照はスレッドをまたいで使えません(参照は、それを作ったスコープより長くは生きられないからです)。そこでスレッド間でデータを共有するときは、すでに知っている2つのものを組み合わせます。共有所有権のための Arc(スマートポインタのページで紹介しました)と、データに触れるスレッドを一度に1つだけにする Mutex です。

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("結果: {}", *counter.lock().unwrap());
}

実行結果:

結果: 5
▶ 実際に試してみよう

counter.lock() は、値を独占して安全に使えるようになるまで待ち、更新が終わると自動的にロックを解放します。5つのスレッドが同じカウンターを1ずつ増やしますが、Mutex のおかげでお互いの邪魔をすることはありません。更新が失われて小さな数になることはなく、必ず 5 になります。

データにロックをかけて共有するのは、スレッド同士が協力する方法の1つです。もう1つのよくある方法は、そもそも共有せずに、スレッド同士でメッセージを送り合うことです。それについてはチャネルのページで説明します。

Rust がこれを「恐れるな!並行性」と呼ぶ理由

Section titled “Rust がこれを「恐れるな!並行性」と呼ぶ理由”

ほとんどの言語では、Mutex のロックをかけ忘れたり、共有してはいけない参照をうっかりスレッド間で共有したりしても、誰も止めてくれません。Rust ではコンパイラがこれを実際に強制します。スレッド間でデータ競合(data race)を起こすようなコードは、そもそもコンパイルできないのです。これは、このコースで学んできた所有権と借用の仕組みそのものです。対象が1つのスコープからスレッドに広がっただけなのです。

2つのスレッドを使って、1から100までの数を足し合わせてください。一方のスレッドが1から50まで、もう一方が51から100までを足し、最後に main で2つの答えを足します。ヒント:スレッドのクロージャは値を返せます。handle.join().unwrap() でその値を受け取れます。

解答を見る
use std::thread;
fn main() {
let first = thread::spawn(|| {
let total: u32 = (1..=50).sum();
total
});
let second = thread::spawn(|| {
let total: u32 = (51..=100).sum();
total
});
let a = first.join().unwrap();
let b = second.join().unwrap();
println!("{a} + {b} = {}", a + b);
}

実行結果:

1275 + 3775 = 5050
▶ 実際に試してみよう

これらのクロージャは外側の変数を何も使っていないので、move は必要ありません。2つのスレッドは同時に動き、join() がそれぞれの終了を待って結果を返してくれます。

スレッドを3つ起動してください。それぞれのスレッドが、共有された1つの Vec<String> に "スレッド2が記帳しました" のような行を追加します。すべてのスレッドが終わったら、行を並べ替えて表示します。ヒント:カウンターの例と同じように Arc<Mutex<Vec<String>>> を使います。スレッドはどの順番で終わるかわからないので、最後にコピーを並べ替えましょう。

解答を見る
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let book = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for id in 1..=3 {
let book = Arc::clone(&book);
handles.push(thread::spawn(move || {
book.lock().unwrap().push(format!("スレッド{id}が記帳しました"));
}));
}
for handle in handles {
handle.join().unwrap();
}
let mut lines = book.lock().unwrap().clone();
lines.sort();
for line in lines {
println!("{line}");
}
}

実行結果:

スレッド1が記帳しました
スレッド2が記帳しました
スレッド3が記帳しました
▶ 実際に試してみよう

lines.sort() の行を消して何度か実行してみてください。順番が変わることがあります。毎回同じ出力にするために、並べ替えているのです。

  • thread::spawn(クロージャ) はクロージャを新しいスレッドで実行し、.join() はその終了を待ちます。
  • スレッドに渡すクロージャには、たいてい move が必要です。使うデータの所有権を完全に受け取るためです。
  • スレッド間でデータを共有するには、Arc(共有所有権)と Mutex(排他的なアクセス)を組み合わせます。counter.lock() で、一度に1つのスレッドだけが安全に値を使えます。
  • データ競合を起こすようなコードは、実行時ではなくコンパイル時にコンパイラがはじいてくれます。

理解度チェック

1. thread::spawn に渡すクロージャに、たいてい move キーワードが必要なのはなぜですか?

2. Arc<Mutex<T>> は何のために使いますか?

3. スレッドのハンドルで .join() を呼び忘れると、何が起こりうるでしょうか?

4. 同じプログラムなのに、「メイン:」と「スレッド:」の行の順番が実行するたびに変わるのはなぜですか?

スコア: 0 / 4