- 物理機リージョン/AZ
- ハイパーバイザVM exit で捕まえる
- VMゲストOS
- コンテナnamespace / cgroup
- プロセスアプリ / DB今ここ
第12週で、1つのCPUを順番待ちで分け合う仕組みを見ました。同時に動いて見えるということは、途中で切り替わるということです。今回はその帰結を見ます。
まず結論
`count += 1` は1つの動作に見えますが、読んで・変えて・書くの3段階に分かれています。分かれているから、途中に別の実行の流れが割り込めます。
そして割り込まれないまま正しい答えが出ることもあります。実測では競合するコードが3回に1回ちょうど正しい値を返しました。「動いたから正しい」が最も通用しない領域です。
2つのスレッドが共有の変数に `counter = counter + 1` を100万回ずつ実行しました。最後の値はどうなるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
1行に見えるものが、3つの段階に分かれている
第12週で、1つのCPUを順番待ちで分け合う仕組みを見ました。同時に動いて見えるのは、短く区切って切り替えているからです。切り替わる場所を、書いた人は選んでいません。
count = count + 1 という1行は、ソースコード上は1つの動作ですが、機械が行うのは次の3つです。
- 読む — メモリから現在の値を取ってくる
- 変える — 取ってきた値に1を足す
- 書く — 結果をメモリに書き戻す
この形を read-modify-write (RMW) と呼びます。Documentation/atomic_t.txt は冒頭で "The atomic type provides an interface to the architecture's means of atomic RMW operations between CPUs" と述べ、この語を操作の分類に使っています。
分かれているということは、間があるということです。読んだ後・書く前に別の流れが同じ値を読むと、両方が同じ古い値を元に同じ新しい値を書きます。2回足したのに1しか増えません。
- ・消えるのは「読んでから書くまでの間」に割り込まれたときだけ
- ・割り込まれなければ正しい値になる。毎回は壊れない
3段階であることを、出力で確かめる
「3段階に分かれる」は比喩ではなく、そのまま出力に見えます。Python 3.12.14 の結果です。
0 0 RESUME 01 2 LOAD_NAME 0 (x)4 LOAD_CONST 0 (1)6 BINARY_OP 13 (+=)10 STORE_NAME 0 (x)12 RETURN_CONST 1 (None)
LOAD_NAME(読む)・BINARY_OP(変える)・STORE_NAME(書く)。**バイトコードは版で変わります。**上は 3.12 の出力で、3.10 以前は INPLACE_ADD になり RESUME もありません。
C でも同じです。long c; への c = c + 1; を gcc 13 で -O1 付きにした出力です。
plain_inc:.LFB0:.cfi_startprocadrp x1, .LANCHOR0ldr x0, [x1, #:lo12:.LANCHOR0]add x0, x0, 1str x0, [x1, #:lo12:.LANCHOR0]ret
ldr(load = 読む)・add(変える)・str(store = 書く)。最適化を付けても3命令のままです。
`counter = counter + 1` の途中に他の実行の流れが割り込めるのは、なぜですか。
壊れることより、壊れないことのほうが厄介
競合状態の難しさは、壊れることではなく壊れたり壊れなかったりすることにあります。
加算が消えるのは、割り込みが「読んでから書くまでの間」に入ったときだけです。入らなければ正しい値になります。どちらになるかはその時のCPUの空き具合で変わります。
帰結はこうです。正しい値が出たことは、競合が無いことの証拠になりません。 序章『生成されたコードの読み方 — 「動く」と「正しい」の差』で扱った差が、最も鋭い形で現れるのがこの領域です。
判断材料は実行結果ではなくコードの構造です。複数の流れから触られる値が、分割されうる形で更新されていないか。ここだけを見ます。
カーネルの Documentation/memory-barriers.txt も、非アトミックな RMW が並行アルゴリズムを壊すことを anti-guarantees の節で述べています。
These guarantees do not apply to bitfields, because compilers often generate code to modify these using non-atomic read-modify-write sequences. Do not attempt to use bitfields to synchronize parallel algorithms.
実測 — 半分が消え、しかし3回に1回は正しい
2スレッド × 各100万回、合計200万回の加算です。環境は aarch64 の Linux(6.10.14-linuxkit)上の gcc 13、-O1、nproc は 10。
=== WITHOUT LOCK (5 runs) ===lock=no expected=2000000 actual=1075486 lost=924514lock=no expected=2000000 actual=1025922 lost=974078lock=no expected=2000000 actual=1034678 lost=965322lock=no expected=2000000 actual=1009796 lost=990204lock=no expected=2000000 actual=1034516 lost=965484=== WITH MUTEX (3 runs) ===lock=yes expected=2000000 actual=2000000 lost=0lock=yes expected=2000000 actual=2000000 lost=0lock=yes expected=2000000 actual=2000000 lost=0
**約半分が消えています。**失われる量は毎回違うので、特定の数値を期待値として扱えません。ミューテックスを足すと3回とも 2000000 ちょうどでした。
ここまでなら「競合するコードは壊れる」で済みます。ところが別のプログラムで同じ形の加算を回すと、こうです。
=== plain RMW vs atomic_fetch_add (3 runs) ===expected=2000000 plain=1081150 atomic=2000000expected=2000000 plain=1643779 atomic=2000000expected=2000000 plain=2000000 atomic=2000000
**3回目の plain が、ちょうど 2000000 になっています。**競合するコードが正しい答えを返しました。
この1行が最も重要です。この3回目だけを見て「動いた」と判断していたら、壊れたコードを正しいと結論していました。「必ず壊れる」と説明する教材やAIの回答は、この観測を説明できません。
一方 atomic の列は3回とも 2000000 です。plain が壊れた回も atomic は合っています。
「Python の threading では競合が再現しなかったので、Python では競合状態は起きない」という結論の評価はどれですか。
plain な加算のまま(C / 2スレッド)
`plain = plain + 1` を2スレッドで各100万回。読んだ値を元に書き戻すため、間に割り込まれると加算が消えます。3回の結果は 1081150・1643779・2000000 で、3回目はたまたま消えませんでした。
atomic_fetch_add に変えただけ(同じループ内)
同じプログラムの同じループで `atomic_fetch_add(&atom, 1)` も同時に回しています。スレッド数もループ回数も変えていません。結果は3回とも 2000000 でした。
plain の3回目も 2000000 でした。それでも両者は同じ品質のコードとは言えません。分けている構造は何でしょうか。
「結果が同じだった回」ではなく「結果が何によって決まるか」を比べてください。
対比例 — 同じ Python が、壊れなかったり壊れたりする
同じ加算を Python の threading で試した結果です。
python threading: expected=2000000 actual=2000000 lost=0python threading: expected=2000000 actual=2000000 lost=0python threading: expected=2000000 actual=2000000 lost=0
**再現しませんでした。**3回とも正しい値です(3.13.15 と 3.14.0rc3 でも同様で、いずれも GIL enabled: True)。ここで「Python では競合状態は起きない」と結論したくなります。しかし同じ Python 3.12 で、スレッドをプロセスに変えて共有メモリ上の値を加算すると、こうなります。
multiprocessing lock=False expected=400000 actual=207681 lost=192319multiprocessing lock=True expected=400000 actual=400000 lost=0multiprocessing lock=False expected=400000 actual=229831 lost=170169multiprocessing lock=True expected=400000 actual=400000 lost=0multiprocessing lock=False expected=400000 actual=271528 lost=128472multiprocessing lock=True expected=400000 actual=400000 lost=0
**40万のはずが約21〜27万です。**言語も版も同じで、+= が3バイトコードに分かれる事実も同じです。
この章のまとめ
count += 1は 読む・変える・書く(read-modify-write)に分かれる。Python 3.12 では3バイトコード、aarch64 の C ではldr/add/strの3命令。- 分かれているから間に割り込める。実測では200万が約101〜108万になった。消える量は毎回違う。
- ただし必ず壊れるわけではない。 競合するコードが3回中1回ちょうど 2000000 を返した。正しい値は競合が無いことの証拠にならない。
atomic_fetch_addに置き換えると3回とも一致した。変わったのは速さではなく分割されうるかどうか。- ミューテックスは順序を決める道具。カーネルのドキュメントは mutex を "enforces serialization" と説明している。
- 同じ Python でも threading では再現せず、multiprocessing の共有メモリでは壊れた。再現しなかったことを言語の性質に一般化しない。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
AIが書いた集計コードを2スレッドで動かしたところ、3回試して3回とも正しい合計が出ました。この結果から言えることはどれですか。
用語集(4語)
- read-modify-write (RMW)
- 値を読み、それを元に新しい値を計算し、書き戻す一連の操作。`count += 1` はこの形になる。カーネルの `atomic_t.txt` はこの語を操作の分類に使っている。
- 競合状態 (race condition)
- 複数の実行の流れが同じ値を触り、結果が割り込みのタイミングに左右される状態。壊れるとは限らない。
- アトミック操作
- 途中で分割されずに完了する操作。`atomic_fetch_add` など。本教材は一般的な説明としてこう述べており、厳密な定義文を一次ソースから確認してはいない。
- ミューテックス
- 同時に1つの実行の流れだけが区間に入れるようにする仕組み。カーネルのドキュメントは "enforces serialization" と説明している。