goroutine で複数コアを使った並列・並行処理を行う場合、変数の取り扱いに気をつける必要があります。
ここでは複数の goroutine でひとつの数値を 10000 回カウントアップをするだけの実装を複数比較します.
TL;DR
数値のカウントアップを安全に行うには sync/atomic
パッケージの atomic.AddUint32()
等の関数を使う。
ベンチマーク環境
以下のような環境で雑に計測します。
CPU は 4 コアです。
比較するコード
何も考えずに ++ 演算子を使う
func useIncrementOperator() uint32 {
var cnt uint32
var wg sync.WaitGroup
for i := 0; i < times; i++ {
wg.Add(1)
go func() {
cnt++
wg.Done()
}()
}
wg.Wait()
return cnt
}
sync/atomic パッケージの atomic.AddUint32() を使う
func useAtomicAddUint32() uint32 {
var cnt uint32
var wg sync.WaitGroup
for i := 0; i < times; i++ {
wg.Add(1)
go func() {
atomic.AddUint32(&cnt, 1)
wg.Done()
}()
}
wg.Wait()
return cnt
}
sync パッケージの sync.Mutex を使う
func useSyncMutexLock() uint32 {
var cnt uint32
var wg sync.WaitGroup
mu := new(sync.Mutex)
for i := 0; i < times; i++ {
wg.Add(1)
go func() {
mu.Lock()
defer mu.Unlock()
cnt++
wg.Done()
}()
}
wg.Wait()
return cnt
}
実行してみる
では実際にこのコードを実行して結果を比較してみます。
コード全体はこちらにあります。
$ go run main.go
GOMAXPROCS: 1
useIncrementOperator(): 10000
useAtomicAddUint32(): 10000
useSyncMutexLock(): 10000
どの実装も 10,000 になりました。
一見問題なさそうですが、GOMAXPROCS を設定して複数コアを使うようにしてみます。
$ GOMAXPROCS=4 go run main.go
GOMAXPROCS: 4
useIncrementOperator(): 9637
useAtomicAddUint32(): 10000
useSyncMutexLock(): 10000
単純に ++
演算子を使った実装は不整合を起こしてしまいました。
並列・並行処理を行う場合に ++
演算子によるカウントアップを行ってはいけないことがわかります。
この問題は -race
オプションを使うことでも検出できます。
$ go run -race main.go
GOMAXPROCS: 1
==================
WARNING: DATA RACE
Read by goroutine 6:
main.func·001()
/Users/yuya/src/github.com/yuya-takeyama/go-practice/sync/counter/main.go:19 +0x43
Previous write by goroutine 5:
main.func·001()
/Users/yuya/src/github.com/yuya-takeyama/go-practice/sync/counter/main.go:19 +0x57
Goroutine 6 (running) created at:
main.useIncrementOperator()
/Users/yuya/src/github.com/yuya-takeyama/go-practice/sync/counter/main.go:21 +0x14d
main.main()
/Users/yuya/src/github.com/yuya-takeyama/go-practice/sync/counter/main.go:69 +0x12a
Goroutine 5 (finished) created at:
main.useIncrementOperator()
/Users/yuya/src/github.com/yuya-takeyama/go-practice/sync/counter/main.go:21 +0x14d
main.main()
/Users/yuya/src/github.com/yuya-takeyama/go-practice/sync/counter/main.go:69 +0x12a
==================
useIncrementOperator(): 10000
useAtomicAddUint32(): 10000
useSyncMutexLock(): 10000
Found 1 data race(s)
exit status 66
ベンチマークしてみる
次に atomic.AddUint32()
と sync.Mutex
どちらを使うのがより速いのかを比較します。
これもコード全体はこちらにあります。
$ GOMAXPROCS=1 go test -bench .
testing: warning: no tests to run
PASS
BenchmarkUseIncrementOperator 200 9204798 ns/op
BenchmarkUseAtomicAddUint64 200 9354682 ns/op
BenchmarkUseSyncMutexLock 100 12104668 ns/op
ok github.com/yuya-takeyama/go-practice/sync/counter 6.453s
GOMAXPROCS=1
の状態だと sync.Mutex
を使った方法より atomic.AddUint32()
を使った方法の方が 1.3 倍ほど速いことがわかりました。
次に GOMAXPROCS=4
で実行してみます。
$ GOMAXPROCS=4 go test -bench .
testing: warning: no tests to run
PASS
BenchmarkUseIncrementOperator-4 300 4242947 ns/op
BenchmarkUseAtomicAddUint64-4 300 4207403 ns/op
BenchmarkUseSyncMutexLock-4 200 6745847 ns/op
ok github.com/yuya-takeyama/go-practice/sync/counter 5.496s
当然実行時間は速くなりますが、1.3 倍だった実行時間の開きが 1.6 倍にもなりました。
並列度が高いときほどロックによる待ち時間が長くなってしまうのだと考えられます。