コンテンツにスキップ

Rustの共有状態:RwLock・アトミック・スコープ付きスレッド

並行処理のページでは、Arc<Mutex<T>> を使ってスレッド間でカウンターを共有しました。Arc は複数のスレッドが値を所有できるようにし、Mutex は一度にひとつのスレッドだけが値に触れるようにします。チャネルのページでは、メッセージを送ることで共有そのものを避けました。どちらもすばらしい道具で、これだけでほとんど何でも作れます。

でも、Mutex はオフィスのトイレの鍵がひとつしかない状態のようなものです。それでも用は足りますが、廊下の掲示板を読みたいだけなら大げさすぎます。Rustの標準ライブラリには、ほかにもいくつか道具があり、それぞれ特定の仕事にもっと向いています。このページでは、そのうちの4つを紹介します。スコープ付きスレッド、RwLock、アトミック(atomics)、そして OnceLock/LazyLock です。さらに、コンパイラが見つけてくれない唯一のバグ、デッドロック(deadlock)を避ける方法も学びます。

スコープ付きスレッド:ムーブせずに借用する

Section titled “スコープ付きスレッド:ムーブせずに借用する”

thread::spawn を使うときは、たいてい move が必要で、Arc もよく必要になります。生成されたスレッドは main が先に進んだあとも動き続けるかもしれないので、Rustはそのスレッドがローカル変数を借用することを許してくれないのです。

std::thread::scope は、ひとつの約束でこれを解決します。スコープの中で起動したスレッドは、すべてスコープが終わる前に終了する、という約束です。スレッドがデータより長生きできないので、ただ借用するだけでよくなります。Arc も move も、クローンもいりません。

use std::thread;
fn main() {
let shop = "街角ショップ";
let sales = vec![12, 7, 30, 5, 18, 22, 9, 14];
let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| {
let am = s.spawn(|| morning.iter().sum::<i32>());
let pm = s.spawn(|| afternoon.iter().sum::<i32>());
(am.join().unwrap(), pm.join().unwrap())
});
println!("{shop}: 午前 {am}、午後 {pm}、1日の合計 {}", am + pm);
println!("まだ自分のもの: {:?}", sales);
}

実行結果:

街角ショップ: 午前 54、午後 63、1日の合計 117
まだ自分のもの: [12, 7, 30, 5, 18, 22, 9, 14]
▶ 実際に試してみよう

何が起きているのかを見てみましょう。

  • thread::scope(|s| { ... }) でスコープを開きます。スレッドは thread::spawn(...) の代わりに s.spawn(...) で起動します。
  • 2つのクロージャは morning と afternoon を借用します。これらは sales のスライスにすぎないので、何もコピーされません。
  • スコープのクロージャが返したもの(ここでは2つの合計のタプル)が、thread::scope の値になります。
  • スコープが終わったあとも sales は main のものなので、そのまま使い続けられます。

join() を呼び忘れても、スコープは閉じかっこ } のところで、すべてのスレッドを待ちます。まさにこの約束があるから、借用が安全になるのです。

RwLock:読み手はたくさん、書き手はひとり

Section titled “RwLock:読み手はたくさん、書き手はひとり”

カフェのメニューボードを思い浮かべてください。たくさんのお客さんが同時に読んでも問題ありません。でもオーナーが値段を書き換えるときは、新しいボードが出るまで、みんな少し待たなければなりません。

それが RwLock(読み書きロック、read-write lock)です。入り方が2つあります。

  • read() は共有の読み取りガードを返します。たくさんのスレッドが同時に持てます。
  • write() は排他的な書き込みガードを返します。書き手が持っている間は、ほかの誰も読み書きできません。

Mutex はこの区別をしません。読み手が2人いるだけでも、順番を待たなければなりません。次の例は、いくつかのリクエストハンドラが読み、管理者が一度だけ更新する設定のキャッシュです。

use std::sync::{Arc, RwLock};
use std::thread;
struct Settings {
theme: String,
max_upload_mb: u32,
}
fn main() {
let settings = Arc::new(RwLock::new(Settings {
theme: String::from("light"),
max_upload_mb: 10,
}));
// 4つのリクエストハンドラが同時に設定を読む
let mut handles = Vec::new();
for id in 1..=4 {
let settings = Arc::clone(&settings);
handles.push(thread::spawn(move || {
let s = settings.read().unwrap(); // 共有: ほかのスレッドも読める
format!("リクエスト{id}: テーマ={}、上限={}MB", s.theme, s.max_upload_mb)
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
// 管理者が設定を一度だけ変更する
{
let mut s = settings.write().unwrap(); // 排他的: ほかのスレッドは全員待つ
s.theme = String::from("dark");
s.max_upload_mb = 25;
} // ここで書き込みガードがドロップされ、ロックが再び空く
let s = settings.read().unwrap();
println!("更新後: テーマ={}、上限={}MB", s.theme, s.max_upload_mb);
}

実行結果:

リクエスト1: テーマ=light、上限=10MB
リクエスト2: テーマ=light、上限=10MB
リクエスト3: テーマ=light、上限=10MB
リクエスト4: テーマ=light、上限=10MB
更新後: テーマ=dark、上限=25MB
▶ 実際に試してみよう

スレッドが終わる順番はばらばらですが、出力はいつも同じです。各スレッドは行を表示する代わりに返していて、main が join した順に表示しているからです。

RwLock が Mutex より有利なのはどんなとき? 読み取りが多く、書き込みがまれで、1回の読み取りに少し時間がかかる(たとえば大きなキャッシュを調べる)ときです。そういうときは、読み手が本当に並んで同時に動けます。

有利にならないのは? 読み取りがごく短いときや、書き込みがしょっちゅう起きるときは、RwLock はただの複雑な Mutex になってしまいます。余分な管理の手間がかかるうえに、結局は読み手と書き手がお互いを待つことがほとんどだからです。迷ったら Mutex から始めましょう。そのほうがシンプルですし、測ってみてロックがボトルネックだとわかったら、あとから切り替えればいいのです。

アトミック:ロックなしのカウンター

Section titled “アトミック:ロックなしのカウンター”

数値ひとつや、true/false のフラグひとつのためなら、ロックですら大げさです。AtomicUsize、AtomicI64、AtomicBool のようなアトミック型は、ロックをまったく使わずに、たくさんのスレッドから同時に安全に変更できます。CPUがそれぞれの操作を分割できない1ステップで行うので、更新が失われることはありません。

次の例では、8つのスレッドがそれぞれ1000回のページビューを記録します。さらに投票も数えます。賛成票1つ、反対票3つ、を繰り返します。投票の合計はマイナスになるので、符号なしの型ではなく AtomicI64 を使っています。

use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::thread;
fn main() {
let page_views = AtomicUsize::new(0);
let net_votes = AtomicI64::new(0);
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for n in 0..1000 {
page_views.fetch_add(1, Ordering::Relaxed);
if n % 4 == 0 {
net_votes.fetch_add(1, Ordering::Relaxed); // 賛成票
} else {
net_votes.fetch_sub(1, Ordering::Relaxed); // 反対票
}
}
});
}
}); // ここで8つのスレッドはすべて終わっている
println!("ページビュー: {}", page_views.load(Ordering::Relaxed));
println!("差し引きの票数: {}", net_votes.load(Ordering::Relaxed));
}

実行結果:

ページビュー: 8000
差し引きの票数: -4000
▶ 実際に試してみよう

page_views が mut ではないことに気づきましたか。アトミック型は共有参照を通して変更します。だからこそ、たくさんのスレッドが同時に使えるのです。よく使うメソッドは次のとおりです。

  • fetch_add(n, ordering)/fetch_sub(n, ordering) は足し算・引き算をして、変更前の値を返します。
  • load(ordering) は現在の値を読みます。
  • store(value, ordering) は値を上書きします。

アトミック操作には、どれも Ordering を指定します。これは、その操作が周りのメモリ操作との順序をどれだけ厳密に守らなければならないかを、CPUとコンパイラに伝えるものです。きちんと理解しようとすると奥が深いのですが、初心者のうちは2つのルールだけ覚えておけば十分です。

  • 迷ったら Ordering::SeqCst を使う。 いちばん厳密で、いちばん安全な選択です。
  • 最後に読むだけの単純なカウンターなら Ordering::Relaxed で大丈夫。 上の例がそうです。合計はちゃんと正確になります。

いつかもっと深く知りたくなったら、無料の本 Rust Atomics and Locks(ページの最後にリンクがあります)が、順序づけ(ordering)をわかりやすく説明しています。

AtomicBool は「止まってください」の合図にぴったりです。次の例では、main がフラグを切り替えるまで、バックグラウンドのワーカーが自動保存を続けます。

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop = AtomicBool::new(false);
let report = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::SeqCst) {
// ここでドキュメントを自動保存しているつもり
thread::sleep(Duration::from_millis(5));
}
"自動保存ワーカーはきれいに停止しました"
});
thread::sleep(Duration::from_millis(30)); // mainはほかの仕事をする
stop.store(true, Ordering::SeqCst); // ワーカーに停止を頼む
worker.join().unwrap()
});
println!("{report}");
}

実行結果:

自動保存ワーカーはきれいに停止しました
▶ 実際に試してみよう

ワーカーはループを回るたびにフラグを確認します。今やっているステップを終えてから、自分で抜けるのです。ファイルの保存の途中でスレッドを強制終了するより、ずっとやさしいやり方です。

OnceLock と LazyLock:一度だけ初期化する

Section titled “OnceLock と LazyLock:一度だけ初期化する”

どのスレッドからでも読める、グローバルな値がひとつ欲しいことがあります。対応表、アプリ名、起動時に読み込む設定などです。難しいのは、それを作る部分です。2つのスレッドがまったく同時に作ろうとしたらどうなるでしょう?

std::sync::LazyLock と std::sync::OnceLock がこれを解決します。値は、誰かが最初に必要としたときにちょうど一度だけ作られます。作っている途中にほかのスレッドが欲しがっても、そのスレッドはただ待ち、そのあとは全員が同じ値を共有します。

use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::thread;
// LazyLock: 作り方をここに書いておき、最初に使われたときに実行される
static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("通貨テーブルを作成中...");
HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])
});
// OnceLock: 空の状態で始まり、どう埋めるかは使う場所で決める
static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str {
STORE_NAME.get_or_init(|| {
std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Rusty Shop"))
})
}
fn main() {
let mut handles = Vec::new();
for country in ["US", "JP", "DE", "BR"] {
handles.push(thread::spawn(move || {
format!("{}({country})の支払い通貨は {}", store_name(), CURRENCIES[country])
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}

実行結果:

通貨テーブルを作成中...
Rusty Shop(US)の支払い通貨は USD
Rusty Shop(JP)の支払い通貨は JPY
Rusty Shop(DE)の支払い通貨は EUR
Rusty Shop(BR)の支払い通貨は BRL
▶ 実際に試してみよう

4つのスレッドがほぼ同時にテーブルを欲しがっているのに、「通貨テーブルを作成中…」は一度しか表示されません。しかも、これらはプログラムが終わるまで生きている static の値なので、スレッドに Arc はまったく必要ありません。

どちらを選べばいいでしょうか。

  • LazyLock:宣言する場所で値の作り方がわかっているとき。ふつうのグローバル変数のような感覚で使えます。
  • OnceLock:値があとで決まるとき。たとえばコマンドラインのフラグから決まる場合です。get_or_init(...) か set(...) で値を入れます(すでに値が入っていれば、set は行儀よく失敗します)。

どちらも標準ライブラリに入っています。LazyLock はRust 1.80で追加されたので、古いコードでは同じ目的で once_cell や lazy_static といったクレートがよく使われています。

コンパイラはデータ競合を防いでくれますが、並行処理のバグの中で、ひとつだけ見つけられないものがあります。それがデッドロックです。スレッド同士がお互いを永遠に待ち続け、プログラムがただ固まってしまう状態です。エラーも出ず、クラッシュもせず、何も起きません。

典型的なパターンは、2つのロックを違う順番で取ることです。ペンとノートの両方が必要な2人を想像してください。ひとりがペンを、もうひとりがノートを手に取り、それぞれ相手が手放すのを礼儀正しく待ちます。永遠に。

// スレッドA // スレッドB
let from = alice.lock().unwrap(); let from = bob.lock().unwrap();
let to = bob.lock().unwrap(); let to = alice.lock().unwrap();
// Aはaliceを持ち、bobを待つ // Bはbobを持ち、aliceを待つ。動けない!

次の3つの習慣を守れば安全です。

  1. ロックはいつも同じ順番で取る。 すべてのスレッドが bob より先に alice をロックするなら(たとえば口座IDの順に並べるなど)、循環は起こりません。
  2. ロックのスコープを短くする。 ロックは、データを読んだり変えたりするのに必要な間だけ持ちましょう。ネットワーク通信、ファイルの書き込み、長い計算のような時間のかかる作業の間は、決して持ったままにしないでください。その間ずっと、ほかのスレッドが全員待たされてしまいます。
  3. ガードは早めにドロップする。 ガードはドロップされたときにロックを解除します。内側のブロック { ... } で終わらせる(上の RwLock の書き手のように)か、自分で drop(guard) を呼びましょう。

習慣3を実際に見てみましょう。各ワーカーは共有のキューからジョブを1つ取り出し、キューのロックを解除して、それから時間のかかる部分に取りかかります。

use std::sync::Mutex;
use std::thread;
fn main() {
let queue = Mutex::new(vec!["写真のリサイズ", "メール送信", "レポート作成"]);
let finished = Mutex::new(Vec::new());
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let mut jobs = queue.lock().unwrap();
let job = jobs.pop();
drop(jobs); // 時間のかかる部分の「前」にキューのロックを解除する
if let Some(job) = job {
let result = format!("完了: {job}"); // これに何秒もかかると想像してください
finished.lock().unwrap().push(result);
}
});
}
});
// スコープは終わったので、もう`finished`を使っているスレッドはない
let mut done = finished.into_inner().unwrap();
done.sort(); // スレッドが終わる順番はばらばら
for line in done {
println!("{line}");
}
}

実行結果:

完了: メール送信
完了: レポート作成
完了: 写真のリサイズ
▶ 実際に試してみよう

sort は文字コードの順に並べるので、カタカナで始まるジョブが漢字で始まるジョブより先に来ます。ここで並べ替えるのは、毎回同じ出力にするためだけです。

drop(jobs) がないと、ガードはクロージャの最後まで生き続けます。すると「時間のかかる」作業がキューをロックしたまま行われ、3つのワーカーは同時に動く代わりに、ひとつずつ順番に動くことになってしまいます。

知っておきたい、気づきにくいケースがひとつあります。while let Some(job) = queue.lock().unwrap().pop() { ... } と書くと、ガードはループ本体の間ずっと生きています。つまり、ジョブを処理している間もキューはロックされたままです。上の例のように、まず別の let 文でジョブを取り出しましょう。

やりたいこと 使う道具
あるスレッドから別のスレッドへ、仕事や結果を送る チャネル(mpsc)
たくさんのスレッドから同じ値を読み書きする Arc<Mutex<T>>
よく読まれて、めったに変わらないデータを共有する Arc<RwLock<T>>
数を数える、または単純なオン/オフのフラグを共有する アトミック型(AtomicUsize、AtomicBool など)
グローバルな値を一度だけ作り、あとは読むだけにする LazyLock または OnceLock
Arc なしで、スレッドにローカルなデータを借用させる thread::scope

これらは自由に組み合わせられます。このページの例でも、thread::scope をアトミック型や Mutex と組み合わせていました。ぴったり合う中でいちばんシンプルな道具を選びましょう。「間違った」選択を心配する必要はありません。あとから切り替えるのは、たいてい小さな変更で済みます。

thread::spawn でローカル変数を借用する。これは一見、問題なさそうに見えます。

use std::thread;
fn main() {
let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| {
let total: i32 = sales.iter().sum();
total
});
println!("合計: {}", handle.join().unwrap());
}
error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `sales`
7 | let total: i32 = sales.iter().sum();
| ----- `sales` is borrowed here
|
help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keyword

これは「クロージャが現在の関数より長生きするかもしれないのに、その関数が所有する sales を借用している」というエラーです。help では、move キーワードを使ってクロージャに所有権を持たせるよう提案しています。

あなたは join() をすぐに呼ぶことを知っていますが、thread::spawn にはそれがわかりません。thread::spawn から見れば、スレッドは永遠に動き続けるかもしれないのです。直し方は2つあります。move を付ける(その場合、あとで main は sales を使えなくなります)か、借用ができる thread::scope を使うかです。

let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());

Arc を忘れる。Arc がないと、最初のスレッドの move クロージャが Mutex を持っていってしまい、次のスレッドには何も残りません。

use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..3 {
handles.push(thread::spawn(move || {
*counter.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..3 {
| ------------- inside of this loop
9 | handles.push(thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

これは「ムーブ済みの値 counter を使っている」というエラーです。ループの前の周回で、値がすでにクロージャの中にムーブされている、と教えてくれています。

並行処理のページと同じように、Arc::new(...) で包んで、ループの最初で Arc をクローンしましょう。または thread::scope を使って move を外します。Arc の代わりに Rc を使うと、error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely(スレッド間で安全に送れない)というエラーになります。Rc はひとつのスレッド専用です(スマートポインタを参照)。

1から1000までの数の Vec を作ってください。thread::scope を使って、それを250個ずつ4つのチャンクに分け、各チャンクの合計をそれぞれ別のスレッドで計算します。各チャンクの合計と、全体の合計を表示してください。ヒント: numbers.chunks(250) でスライスが手に入ります。ハンドルを Vec に集めてから、順番に join しましょう。

解答を見る
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in numbers.chunks(250) {
handles.push(s.spawn(move || chunk.iter().sum::<u64>()));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("チャンクごとの合計: {:?}", sums);
println!("全体の合計: {}", sums.iter().sum::<u64>());
}

実行結果:

チャンクごとの合計: [31375, 93875, 156375, 218875]
全体の合計: 500500
▶ 実際に試してみよう

ここでの move がムーブするのは chunk だけです。これは借用された小さなスライスにすぎません。ベクタそのものはムーブされずに借用されたままなので、スコープのあとで numbers をまた使うこともできます。

あるネットショップでは、すべての注文に1000から始まる番号を付けます。4つのスレッドを起動し、それぞれが共有の AtomicU64 ひとつから注文番号を3つずつ受け取るようにしてください。12個の番号をすべて集めて並べ替え、表示します。ヒント: fetch_add(1, Ordering::SeqCst) は足す前の値を返すので、それぞれの番号をちょうど一度だけ配れます。

解答を見る
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for _ in 0..4 {
handles.push(s.spawn(|| {
(0..3)
.map(|_| next_order.fetch_add(1, Ordering::SeqCst))
.collect::<Vec<u64>>()
}));
}
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
numbers.sort();
println!("注文{}件: {:?}", numbers.len(), numbers);
}

実行結果:

注文12件: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]
▶ 実際に試してみよう

どのスレッドがどの番号を受け取るかは実行するたびに変わりますが、同じ番号が二度配られたり、番号が飛ばされたりすることは決してありません。並べ替えることで、表示されるリストは毎回同じになります。

  • thread::scope は、スコープが終わる前にスレッドが終了することを保証します。だからスレッドは Arc や move なしでローカルなデータを借用できます。
  • RwLock は、たくさんの読み手か、ひとりの書き手を許します。読み取りが多いデータに向いていて、そうでなければ Mutex のほうがシンプルです。
  • アトミック型(AtomicUsize、AtomicI64、AtomicBool)は、カウンターやフラグをロックなしで扱えます。迷ったら SeqCst、単純なカウンターなら Relaxed を使いましょう。
  • LazyLock と OnceLock は、たくさんのスレッドが同時に欲しがっても、グローバルな値を安全にちょうど一度だけ作ります。
  • デッドロックを避けるには、ロックをいつも同じ順番で取り、ロックのスコープを短くし、ガードを早めにドロップしましょう。

理解度チェック

1. thread::scope の中で s.spawn を使って起動したスレッドが、ローカル変数を借用できるのはなぜですか?

2. RwLock について正しいのはどれですか?

3. たくさんのスレッドで共有するページビューのカウンターが必要です。いちばんシンプルで良い選択はどれですか?

4. スレッドAは alice、次に bob をロックします。スレッドBは bob、次に alice をロックします。何が起こりえますか?

スコア: 0 / 4