sync/atomic パッケージを使って数値を安全にカウントアップする


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 倍にもなりました。
並列度が高いときほどロックによる待ち時間が長くなってしまうのだと考えられます。