並行の落とし穴
同じ変数を同時に触ると壊れる
Go
total := 0
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
total++
}()
}
wg.Wait()
fmt.Println(total)5000 が出ると思いますが、実際は 4873 や 4991 になります。しかも毎回変わります。
なぜ数が合わないのか
total++ は1行ですが、機械の側では3段階に分かれています。
プレーンテキスト
1. total を読む
2. 1 を足す
3. total に書き戻す2つのゴルーチンが同時にこれをやると、こうなります。
プレーンテキスト
A: total を読む (5)
B: total を読む (5) ← A がまだ書き戻していない
A: 6 を書き戻す
B: 6 を書き戻す ← A の分が消えた2回足したのに1しか増えていません。これが データ競合 です。
見つけにくいのが厄介
この手のバグには、たちの悪い性質があります。
プレーンテキスト
毎回起きるわけではない たまに合ってしまう
数が少ないと再現しない 負荷が高いときだけ出る
エラーが出ない 静かに数字が狂う数件を並行に処理する程度では、まず起きません。 手元では正しく動き、本番で数字が合わなくなる、という現れ方をします。第6章のポインタレシーバも「静かに効かない」問題でしたが、こちらは毎回結果が違うぶん、さらに厄介です。
Go には検出する道具があります。
プレーンテキスト
go run -race main.go-race を付けると、危ない同時アクセスを見つけて教えてくれます。数が少なくて表面化しない場合でも指摘してくれるので、並行処理を書いたら一度は通す価値があります。
Mutex で守る
直し方は、1人ずつしか触れないようにすることです。
Go
var mu sync.Mutex
go func() {
defer wg.Done()
mu.Lock()
total++
mu.Unlock()
}()Lock から Unlock までの間は、他のゴルーチンが待たされます。1人が終わるまで次が入れないので、さっきの割り込みが起こりません。
Unlock の呼び忘れは、全員が待ち続けて止まる原因になります。defer mu.Unlock() と書くのが安全です。
そもそも共有しない
もっとよい解決があります。共有しなければ守る必要もありません。
前のレッスンでやった形がそれです。
Go
results[idx] = v * v // 添字が違うので衝突しない別々の場所に書けば、Mutex は要りません。待ちが発生しないぶん速くもなります。
Go にはこういう言い回しがあります。
メモリを共有して通信するな。通信してメモリを共有せよ。
チャネルで値を渡す設計にすれば、そもそも同じ変数を奪い合わないという意味です。Mutex は最後の手段で、まず分けるか渡すを考えるのが Go の流儀です。
要件
- 5000個のゴルーチンをそれぞれ
goで走らせる sync.WaitGroupで全部の完了を待つcount++をsync.Mutexで守るUnlockの呼び忘れが起きない書き方にする- 戻り値がちょうど 5000 になる
入出力例
raceCount(5000) → 5000
raceCount(10000) → 10000
raceCount(1) → 1
raceCount(0) → 0