並行の落とし穴
同じ変数を同時に触ると壊れる
Go
stock := 5000
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
stock--
}()
}
wg.Wait()
fmt.Println(stock)在庫を5000件から1件ずつ減らす例です。残り0を期待しても、127や9のように残ることがあります。競合は毎回表面化するとは限りません。 0が出ても安全とは判断できません。
なぜ数が合わないのか
stock-- は1行ですが、機械の側では3段階に分かれています。
1. stock を読む
2. 1 を引く
3. stock に書き戻す2つのゴルーチンが同時にこれをやると、こうなります。
A stock を読む (5)
B stock を読む (5) ← A がまだ書き戻していない
A 4 を書き戻す
B 4 を書き戻す ← A の分が消えた2回減らしたのに1しか減っていません。これが データ競合 です。
見つけにくいのが厄介
この手のバグには、たちの悪い性質があります。
毎回起きるわけではない たまに合ってしまう
少数でも競合し得る 実行のタイミングに左右される
エラーが出ない 静かに数字が狂う少数でも同じ変数へ同時に書けば競合し得ます。 手元で正しい数が出ても、別の実行で数が合わなくなることがあります。件数や1回の結果だけで安全とは判断できません。
Go には検出する道具があります。
go run -race main.go-race を付けると、危ない同時アクセスを見つけて教えてくれます。数が少なくて表面化しない場合でも指摘してくれるので、並行処理を書いたら一度は通す価値があります。
Mutex で守る
直し方は、1人ずつしか触れないようにすることです。
共有する予定の一覧に追加する、別の題材で示します。
Go
var mu sync.Mutex
go func() {
defer wg.Done()
mu.Lock()
plans = append(plans, "散歩")
mu.Unlock()
}()Lock から Unlock までの間は、他のゴルーチンが待たされます。1人が終わるまで次が入れないので、さっきの割り込みが起こりません。
Unlock の呼び忘れは、全員が待ち続けて止まる原因になります。Lock の直後に defer mu.Unlock() を置くと、関数を抜けるときに解除できます。
Go
// 共有する予定の一覧に1件追加する例
mu.Lock()
defer mu.Unlock()
plans = append(plans, "散歩")演習では、ゴルーチンの起動と待ち合わせは用意済みです。共有する Mutex を1つ用意し、保護した範囲で数え上げます。
そもそも共有しない
もっとよい解決があります。共有しなければ守る必要もありません。
前のレッスンでやった形がそれです。
Go
results[idx] = v * v // 添字が違うので衝突しない結果スライスの別々の要素に書き、読み取りは完了を待ってから行えば、Mutex は要りません。
Go にはこういう言い回しがあります。
メモリを共有して通信するな。通信してメモリを共有せよ。
チャネルで値を渡す設計にすれば、そもそも同じ変数を奪い合わないという意味です。Mutex は最後の手段で、まず分けるか渡すを考えるのが Go の流儀です。
要件
- 用意された n 個のゴルーチンと待ち合わせを使う
- ループの外で共有する
sync.Mutexを宣言する count++を Lock と defer による Unlock で守る- n が0なら0、それ以外も n と同じ数を返す