(前段)複数スレッドから1つの変数を編集する
Rustでマルチスレッドのプログラムを書く場合
Arc
とMutex
を組み合わせることで、1つの変数を複数のスレッドから参照・変更することができる
// 複数のスレッドでカウントアップできるカウンター変数
// clone してからスレッドにmoveして持ち込む
let counter = Arc::new(Mutex::new(0));
// スレッドの中では、ロックしてから使う
let mut num = counter.lock().unwrap();
*num += 1;
リスト16-15: Arcを使用してMutexをラップし、所有権を複数のスレッド間で共有できるようにする
本題の前に、Arc
って何?
Rc<T>
: reference counting(参照カウント)の省略形
参照の数が0になったら、その値は片づけられる
Arc<T>
はRc<T>
の頭にa
がついたもの
a
はatomic
の意味らしい
データベースの話でACID特性というものがありますが、そのA
とニュアンスが近いのかもしれない
次から本題
この記事は
最近の出来事で
マルチスレッドのプログラムにおいて、Mutex
のロックをうまく使いこなせず
1つのスレッドの待ち状態が起き、原因がよくわからなかった
いくつかの検証コードを書いて、それぞれの挙動を観察する
結論
if let Some(T) =
の右辺にMutexのロックを置くと
後続のelse
文でもロックは継続している
しかもドロップのタイミングは不定
if let Some
の右辺でロックは書かない(パターン1)
なるべくif
の外でロックを書く(パターン3)
そしてロックは不要となった所で明示的にドロップする(パターン3)
ドロップタイミングが不定:メソッドチェーンの中のlock
mut参照の前に書かれたロックは、どこでドロップするかわからないので
別途let
で束縛するようコンパイラから指摘される
2023年10月27日_追記
@gorilla0513さんからコメント&深掘り検証をしていただきました
if let ... else
はmatch
のシンタックスシュガーであり
デシュガーすると、ロックはmatch
を抜けるまで(else
を抜けるまで)つづく
ということがわかりました🙌
詳細は下記記事にて。
基本のコード
- スレッドを2つ生成する
- 各スレッドで、
println!
を1,000回出力する -
println!
出力のあと、4パターンの検証コードにしたがって、スレッドをスリープさせる
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::{thread, time};
fn main() {
let counter: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));
let mut handles = vec![];
for i in 0..2 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for j in 0..1000 {
println!("thread {}, loop {}", i, j);
// ---- ここから
// ★4パターンのお話をします★★★
// ---- ここまで
}
}));
}
for handle in handles {
handle.join().unwrap();
}
}
パターン1:if let Some
この記事を書くきっかけになったコードがこちら
カウンターをロックして、Some()
で結果を取り出して
if文の中で処理をおこなう
ただし、ロックした中身がNone
であれば、一定時間待つ
説明を簡単にするために、わざと無意味なコードを書いてます
実際はARPテーブルのように、所望のIPアドレスがARPテーブルに有るか無いかによって
処理を分けるコードを書いてました
if let Some(_) = counter.lock().unwrap().checked_div(i) {
// i = 1 のときは何もしない
} else {
// i = 0 のときは、100ms待つ
thread::sleep(time::Duration::from_millis(100));
}
期待していた結果はスレッド0が待ち状態にある間に、スレッド1が1,000回ループを終えることだった
しかし、結果はスレッドは0と1を交互に実行している様子だった
スレッド1が連続してループを消化する期間もあるが
0,1が交互に現れて、息継ぎするようにストップアンドゴーで画面が更新される
thread 1, loop 771
thread 1, loop 772
thread 1, loop 773
thread 0, loop 21
thread 1, loop 774
thread 0, loop 22
thread 1, loop 775
thread 0, loop 23
thread 1, loop 776
thread 0, loop 24
thread 1, loop 777
1,000回ループするのに100msは短し時間ではない
つまりスレッド0がロックを占有して、スレッド1が待ち状態になっていることを意味する
スレッド0はelse
に進んで100ms待つが、待っている間もif let
でcounter
のロックを
持ち続けていると予想される
パターン2:if
if let
をif
に書き換えた
if counter.lock().unwrap().checked_div(i).is_some() {
// i = 1 のときは何もしない
} else {
// i = 0 のときは、100ms待つ
thread::sleep(time::Duration::from_millis(100));
}
この場合は、スレッド1が1,000回ループを一瞬で消化する
thread 1, loop 992
thread 1, loop 993
thread 1, loop 994
thread 1, loop 995
thread 1, loop 996
thread 1, loop 997
thread 1, loop 998
thread 1, loop 999
thread 0, loop 2
thread 0, loop 3
thread 0, loop 4
thread 0, loop 5
thread 0, loop 6
スレッド1はスレッド0が100msスリープしている間に何かロックを待つことはない
if
の右辺を評価し終えたあと、スレッド0はカウンターとは無縁になる
パターン3:if let Some
+ drop
パターン1と同様にif let Some
を使う
ただし、lock
変数はelse
に入ったところでdrop
する
let lock = counter.lock().unwrap();
if let Some(_) = lock.checked_div(i) {
// i = 1 のときは何もしない
} else {
// i = 0 のときは、ロックをドロップしてから、100ms待つ
drop(lock);
thread::sleep(time::Duration::from_millis(100));
}
lockはelseに入ったところでドロップされるので
スレッド0が100ms待っている間に、スレッド1は1,000回ループを終えられる
thread 1, loop 995
thread 1, loop 996
thread 1, loop 997
thread 1, loop 998
thread 1, loop 999
thread 0, loop 2
thread 0, loop 3
thread 0, loop 4
thread 0, loop 5
thread 0, loop 6
thread 0, loop 7
thread 0, loop 8
thread 0, loop 9
パターン4:if let Some
+ drop
しない
これはパターン1と同じか・・・
lock
のスコープがelse
のあとまでつづくのが明確なので
スレッド0のスリープが終わるまでロックが継続するのがわかりやすい
let lock = counter.lock().unwrap();
if let Some(_) = lock.checked_div(i) {
// i = 1 のときは何もしない
} else {
// i = 0 のときは、ロックをドロップしてから、100ms待つ
// drop(lock); <<<--- コメントアウト
thread::sleep(time::Duration::from_millis(100));
}
スレッド1はスレッド0のスリープに影響を受けている様子がわかる
処理を何もしないスレッド1のループはなかなか終わらない
hread 0, loop 13
thread 1, loop 231
thread 0, loop 14
thread 1, loop 232
thread 0, loop 15
thread 1, loop 233
thread 0, loop 16
thread 1, loop 234
thread 0, loop 17
thread 1, loop 235
パターン1よりもスレッド1のループの周回速度が遅くなっているのは
スコープが広くなっているからか・・・?
おわりに
よくできたライフタイムの仕組みに乗っかっているのに
明示的にdrop
する必要が生じるってことは
なにか根本的に間違っている気がしている
余談1:DiscordがGoからRustに切り替えた
ガベージコレクションがないRustの良さが書かれた記事
余談2:RwLockっていうものがあってな
ARPテーブルのような、だいたいは読み取りだけで、たまにテーブル更新(書き込み)するなら
Mutex
ではなく、std::sync::RwLock
でもいいのかなー、と
最近よんだ記事で知った