LoginSignup
1
1
個人開発エンジニア応援 - 個人開発の成果や知見を共有しよう!-

[Rust] if let と mutex lock の組み合わせでハマった

Last updated at Posted at 2023-09-30

(前段)複数スレッドから1つの変数を編集する

Rustでマルチスレッドのプログラムを書く場合
ArcMutexを組み合わせることで、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がついたもの
aatomicの意味らしい
データベースの話でACID特性というものがありますが、そのAとニュアンスが近いのかもしれない

次から本題

この記事は

最近の出来事で
マルチスレッドのプログラムにおいて、Mutexのロックをうまく使いこなせず
1つのスレッドの待ち状態が起き、原因がよくわからなかった

いくつかの検証コードを書いて、それぞれの挙動を観察する

結論

if let Some(T) =の右辺にMutexのロックを置くと
後続のelse文でもロックは継続している

しかもドロップのタイミングは不定

if let Someの右辺でロックは書かない(パターン1)
なるべくifの外でロックを書く(パターン3)
そしてロックは不要となった所で明示的にドロップする(パターン3)

ドロップタイミングが不定:メソッドチェーンの中のlock

mut参照の前に書かれたロックは、どこでドロップするかわからないので
別途letで束縛するようコンパイラから指摘される

image.png

2023年10月27日_追記

@gorilla0513さんからコメント&深掘り検証をしていただきました

if let ... elsematchのシンタックスシュガーであり
デシュガーすると、ロックは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テーブルに有るか無いかによって
処理を分けるコードを書いてました

パターン1のコード
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が交互に現れて、息継ぎするようにストップアンドゴーで画面が更新される

パターン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 letcounterのロックを
持ち続けていると予想される

パターン2:if

if letifに書き換えた

パターン2のコード
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回ループを一瞬で消化する

パターン2の結果
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する

パターン3のコード
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回ループを終えられる

パターン3の結果
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のスリープが終わるまでロックが継続するのがわかりやすい

パターン4のコード
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のループはなかなか終わらない

パターン4の結果
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でもいいのかなー、と
最近よんだ記事で知った

1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1