Dev Study
← OSとカーネルの土台
レッスン 6 / 10·約 9 分·第10期 OSとカーネルの土台

同じ値を2人が書く — 「1行」が1命令とは限らない

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / 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. 読む — メモリから現在の値を取ってくる
  2. 変える — 取ってきた値に1を足す
  3. 書く — 結果をメモリに書き戻す

この形を 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しか増えません。

2つの流れが同じ値を更新するとき、加算が1つ消える経路
スレッドA: 読むcount は 5
スレッドB: 読むcount は 5
どちらも 5 を手元に持っている
A: 変えて書く5 + 1 = 6 を書く
B: 変えて書く5 + 1 = 6 を書く
結果
count は 62回足したのに1しか増えていない
  • ・消えるのは「読んでから書くまでの間」に割り込まれたときだけ
  • ・割り込まれなければ正しい値になる。毎回は壊れない
具体実物

3段階であることを、出力で確かめる

「3段階に分かれる」は比喩ではなく、そのまま出力に見えます。Python 3.12.14 の結果です。

Python 3.12.14。$ python3 -c "import dis; dis.dis('x += 1')"コマンド出力
0 0 RESUME 0
 
1 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 付きにした出力です。

aarch64 / gcc 13 / -O1。$ gcc -O1 -S -o - inc.c(関数部分)コマンド出力
plain_inc:
.LFB0:
.cfi_startproc
adrp x1, .LANCHOR0
ldr x0, [x1, #:lo12:.LANCHOR0]
add x0, x0, 1
str 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。

実測。counter = counter + 1 を2スレッドで各100万回(生出力)コマンド出力
=== WITHOUT LOCK (5 runs) ===
lock=no expected=2000000 actual=1075486 lost=924514
lock=no expected=2000000 actual=1025922 lost=974078
lock=no expected=2000000 actual=1034678 lost=965322
lock=no expected=2000000 actual=1009796 lost=990204
lock=no expected=2000000 actual=1034516 lost=965484
=== WITH MUTEX (3 runs) ===
lock=yes expected=2000000 actual=2000000 lost=0
lock=yes expected=2000000 actual=2000000 lost=0
lock=yes expected=2000000 actual=2000000 lost=0

**約半分が消えています。**失われる量は毎回違うので、特定の数値を期待値として扱えません。ミューテックスを足すと3回とも 2000000 ちょうどでした。

ここまでなら「競合するコードは壊れる」で済みます。ところが別のプログラムで同じ形の加算を回すと、こうです。

実測。同一ループ内で plain と atomic_fetch_add を同時に回した(生出力・3回すべて)コマンド出力
=== plain RMW vs atomic_fetch_add (3 runs) ===
expected=2000000 plain=1081150 atomic=2000000
expected=2000000 plain=1643779 atomic=2000000
expected=2000000 plain=2000000 atomic=2000000

**3回目の plain が、ちょうど 2000000 になっています。**競合するコードが正しい答えを返しました。

この1行が最も重要です。この3回目だけを見て「動いた」と判断していたら、壊れたコードを正しいと結論していました。「必ず壊れる」と説明する教材やAIの回答は、この観測を説明できません。

一方 atomic の列は3回とも 2000000 です。plain が壊れた回も atomic は合っています。

✓ ここまでの確認

「Python の threading では競合が再現しなかったので、Python では競合状態は起きない」という結論の評価はどれですか。

⇄ 2つを見比べる
具体例 A

plain な加算のまま(C / 2スレッド)

`plain = plain + 1` を2スレッドで各100万回。読んだ値を元に書き戻すため、間に割り込まれると加算が消えます。3回の結果は 1081150・1643779・2000000 で、3回目はたまたま消えませんでした。

具体例 B

atomic_fetch_add に変えただけ(同じループ内)

同じプログラムの同じループで `atomic_fetch_add(&atom, 1)` も同時に回しています。スレッド数もループ回数も変えていません。結果は3回とも 2000000 でした。

plain の3回目も 2000000 でした。それでも両者は同じ品質のコードとは言えません。分けている構造は何でしょうか。

「結果が同じだった回」ではなく「結果が何によって決まるか」を比べてください。

具体実物

対比例 — 同じ Python が、壊れなかったり壊れたりする

同じ加算を Python の threading で試した結果です。

実測。Python 3.12.14 / threading / 2スレッド × 各100万回(生出力・3回すべて)コマンド出力
python threading: expected=2000000 actual=2000000 lost=0
python threading: expected=2000000 actual=2000000 lost=0
python threading: expected=2000000 actual=2000000 lost=0

**再現しませんでした。**3回とも正しい値です(3.13.15 と 3.14.0rc3 でも同様で、いずれも GIL enabled: True)。ここで「Python では競合状態は起きない」と結論したくなります。しかし同じ Python 3.12 で、スレッドをプロセスに変えて共有メモリ上の値を加算すると、こうなります。

実測。Python 3.12 / multiprocessing / 2プロセス × 各20万回(生出力・全6行)コマンド出力
multiprocessing lock=False expected=400000 actual=207681 lost=192319
multiprocessing lock=True expected=400000 actual=400000 lost=0
multiprocessing lock=False expected=400000 actual=229831 lost=170169
multiprocessing lock=True expected=400000 actual=400000 lost=0
multiprocessing lock=False expected=400000 actual=271528 lost=128472
multiprocessing 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 の共有メモリでは壊れた。再現しなかったことを言語の性質に一般化しない。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

AIが書いた集計コードを2スレッドで動かしたところ、3回試して3回とも正しい合計が出ました。この結果から言えることはどれですか。

用語集(4語)
read-modify-write (RMW)
値を読み、それを元に新しい値を計算し、書き戻す一連の操作。`count += 1` はこの形になる。カーネルの `atomic_t.txt` はこの語を操作の分類に使っている。
競合状態 (race condition)
複数の実行の流れが同じ値を触り、結果が割り込みのタイミングに左右される状態。壊れるとは限らない。
アトミック操作
途中で分割されずに完了する操作。`atomic_fetch_add` など。本教材は一般的な説明としてこう述べており、厳密な定義文を一次ソースから確認してはいない。
ミューテックス
同時に1つの実行の流れだけが区間に入れるようにする仕組み。カーネルのドキュメントは "enforces serialization" と説明している。