Open6

「詳解 Rust アトミック操作とロック」 の読書メモ

AtokAtok

手元の本は初版第一刷となっています.
以下の誤植を見つけたので,今後の忘備録も兼ねて読書メモを残していこうかなと思っています.

p4, move クロージャの解説

use std::thread;

fn main() {
    let numbers = vec![1, 2, 3];
    thread::spawn(|| {
        for n in numbers {
            println!("{n}");
        }
    }).join().unwrap();
}

上記のコード例 (本文中のコード例から move を取り除いたもの) がコンパイルエラーになると記載されていますが,実際には Rustc 1.66.0 ではエラーになりませんでした.
なお,原文では以下のコード例が示されており,こちらから move を取り除くと,記載通りスレッドのライフタイムが借用されている numbers よりも長い可能性がある旨のエラーメッセージが表示されるので,日本語版の誤植と判断しています.

use std::thread;

fn main() {
    let numbers = vec![1, 2, 3];
    thread::spawn(move || {
        for n in &numbers {
            println!("{n}");
        }
    }).join().unwrap();
}

確認したエラーメッセージは以下.

error[E0373]: closure may outlive the current function, but it borrows `numbers`, which is owned by the current function
AtokAtok

ドキュメント を読んだ感じだと,上記は numbers の所有権が自動的に移譲されているのでエラーにならないということかな.
クロージャの中で使用される numbers が参照の場合は,キャプチャも参照として行われるけどライフタイムの問題が発生するという例だと理解.

クロージャのキャプチャ周りの理解も怪しかった...
関数では環境をキャプチャしないという違いをあまり意識していなかったかもしれない.

AtokAtok

第一章まで読んだ感想

第一章は必要な前提知識の確認ということで,スレッドや共有変数とロックなどの説明になっていた.
Rust で並行処理を記述したことがないので,そもそもスレッドを複数起動するとか,変数を共有するとか基本的なところを確認できてよかった.
完全に理解できたかはわからないけど,進めていってわからなければ再度もどってくる感じで良さそうかな.

本文の内容と関係ないけれど,rustc を使って以下のようにアセンブリ出力ができることを確認した.
src/main.rs は適当なパスに置き換えて利用していく.

 rustc -C opt-level=3 --emit asm src/main.rs
AtokAtok

第二章 (アトミック操作)

2.2 読み込み更新操作

AtomicI32 型の読み込み更新操作について,オーバーフローした際の挙動について本文を読んでちょっと理解できなかったので動作確認.
インクリメントして最大値を超えると,ラップアラウンドしてその型の最小値となるとのことだけど,どれだけ最大値を超えても符号付 32 bit 整数の最小値になるってことかと思って確認した.

fn main() {
    let a = AtomicI32::new(1000000000);
    let b = a.fetch_add(2000000000, Relaxed);
    let c = a.load(Relaxed);

    assert_eq!(b, 1000000000);
    assert_eq!(c, i32::min_value());
}
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `-1294967296`,
 right: `-2147483648`', src/main.rs:21:5

違和感を感じていた通り読み方の問題だったようで,超えた分だけインクリメントされているみたい.

AtokAtok

2.2.1 例:複数スレッドからの進捗レポート

複数のスレッドの進捗管理をする AtomicUsize で進捗レポートを実装するには store メソッドは使えない.
load → 加算 → store という順番で各スレッドでの操作が非同期に発生するので,store により他のスレッドの進捗を上書きする可能性がある.

これを防ぐために fetch_add メソッドが使える.
一方で,複数のアトミック変数を更新する場合は依然として正確な処理とならない可能性がある.
これを避けるためにはそれぞれのアトミック変数を Mutex のなかに入れて,Mutex をロックしてからそれぞれを更新すればよいが,Mutex のロック・アンロックのコストと,スレッドを一時的にブロックするオーバーヘッドがコストとなる.

AtokAtok

第三章 (メモリオーダリング)

先行発生関係の話.
正直ちゃんと理解できたかは怪しい.
全ての操作に順序がつくわけではないので,半順序として定義するっぽい.
ちゃんとした形式的な定義をあたってみる方がむしろ早そうなので,Lamport の論文 を読んでみようかな.

メモリオーダリングについては以下のように理解した.

  • 共有された変数についてグローバルな操作順序を先行発生関係で定義
  • 先行発生で定義されるため半順序になる
  • Relaxed は先行発生関係を作らない (一つのアトミック変数に関しては,任意のスレッドで成立する操作順序が定義される → 全変更順序の保証)
  • Release/Acquire は対で利用され,Release ストアで変数に書き込む操作 (と,それに先行発生する操作) はAcquire ロードで対応する変数を読み込む操作よりも先行発生する
  • Sequential Consistent はロードに対する Acquire オーダリングとストアに対する Release オーダリングによるすべての保証に加えて,Sequential Consistent な操作すべてについての任意のスレッドから見て成立する操作順序を保証する

いまいち理解しきれているか自信がないけれど, メモリモデルとして順序が保証されていない操作は,別のスレッドから見ると操作順序が変わっている可能性があるということか...?

つまり抽象モデルとしてのメモリモデルは,機械語命令,キャッシュ,バッファ,タイミング,命令のリオーダ,コンパイラの最適化などについては何も言っておらず,あることがべつのことよりも先行発生する (先におこる) ことだけを保証し,それ以外の順番は未定義としているということだ.

基本的な先行発生関係として,同一スレッド内の操作はプログラムに記述された通りの順序になるとのことなので,コンパイラに最適化されたとしてもそのスレッドでは元々の順序で操作されたものと考えると理解したけど合っているか自信ないですね...
で,別のスレッドから見るとその操作順序が入れ替わっている可能性もある...?

fn f(a: &mut i32, b: &mut i32) {
  *a += 1;          // *a += 2;
  *b += 1;          // *b += 1;
  *a += 1;          //  ↑ コンパイラにより最適化された結果
}

上記のような例だと,f を実行しているスレッドからは a をインクリメント → b をインクリメント → a をインクリメント という操作順序が定義される.
一方で,a, b を観測する (アトミック型でないので厳密には無理かもだけど) 別のスレッドからは,a に 2 加算 → b をインクリメント という操作順序になっているように見えても (未定義なので) 矛盾はしない...?

メモリオーダリングはスレッドを跨いだ共有変数への操作に (先行発生関係によって) 順序を与えるのが役割で,先行発生関係がない操作間では順序が未定義となるというのが現状の理解.
重要な部分だと思うので,後でまた別の文献とかを参照して理解を深めたいな.