Go

atomicパッケージが必要な理由と使い方

More than 1 year has passed since last update.

以下のコードは通常分かりづらいバグを持っています。

atomic1.go
package main

import (
    "fmt"
    "runtime"
    "sync"
)

type Counter int32

func (c *Counter) Inc() {
    *c++
}

func main() {
    var c Counter
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            c.Inc()
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("Count =", c, "GOMAXPROCS =", runtime.GOMAXPROCS(0))
}

このコードは、GOMAXPROCS の値が2以上の場合、最後にプリントされるカウンタが1000にならない場合があります。CPUのコア数が1の場合は再現しませんので、以下に手元で動作させた時の実行結果を載せておきます。

$ go run atomic1.go
Count = 969 GOMAXPROCS = 4
$ go run atomic1.go
Count = 959 GOMAXPROCS = 4
$ go run atomic1.go
Count = 962 GOMAXPROCS = 4
$ GOMAXPROCS=1 go run atomic1.go
Count = 1000 GOMAXPROCS = 1
$ GOMAXPROCS=1 go run atomic1.go
Count = 1000 GOMAXPROCS = 1

何が起こっているのかを見るために、アセンブリコードにしてみましょう。

# *c++に関連する行だけ抽出
$ go tool compile -S atomic1.go | grep atomic1.go:1[12] | egrep -v '(NOP|FUNCDATA)'
    0x0000 00000 (atomic1.go:11)    TEXT    "".(*Counter).Inc(SB), $0-8
    0x0000 00000 (atomic1.go:11)    MOVQ    "".c+8(FP), CX
    0x0005 00005 (atomic1.go:12)    MOVL    (CX), BP
    0x0007 00007 (atomic1.go:12)    INCL    BP
    0x0009 00009 (atomic1.go:12)    MOVL    BP, (CX)

4つ目の列(TEXT等)が命令で、そのあとに操作される対象が続きます。

TEXT命令は関数やメソッドのシンボルです。だいたいここからCounter.Inc()メソッドが始まっていると読んでください。MOVQとMOVL命令は、整数の大きさ(64bitまたは32bit)の違いはありますが、どちらも左の値を右へコピーする命令です。

BP, FP, CXといった名前はレジスタで、(CX) は、「レジスタCXの値をメモリのアドレスとして参照」します。例えばCXの値が0xfff0なら、(CX)は0xfff0のメモリアドレスが表す値を参照します。8(CX)のように書くと、CXからのオフセットを意味し、0xfff8を参照します。

こうして分解すると、*c++ という式は、計算機によって多少異なりますがおおむね以下の順に処理されます。

  1. レジスタに現在のカウンタ値をメモリ(c)からロードする(MOVQ+MOVL)
  2. レジスタのカウンタ値をインクリメントする(INCL)
  3. インクリメントした結果をメモリ(c)に書き出す(MOVL)

そうして、このコードが複数のゴルーチンから実行される場合、それぞれの命令は別の命令として処理されるため、以下の順で処理されてしまう可能性があります。

  1. 1つ目のゴルーチン(以下A)はレジスタに現在のカウンタ値をメモリ(c)からロードする
  2. 2つ目のゴルーチン(以下B)も、レジスタに現在のカウンタ値をメモリ(c)からロードする
  3. Aはレジスタのカウンタ値をインクリメントする
  4. Aはインクリメントした結果をメモリ(c)に書き出す
  5. Bも同様に、レジスタのカウンタ値をインクリメントする
  6. Bはインクリメントした結果をメモリ(c)に書き出す

本来は c + 2 された値が c に設定されているはずですが、ゴルーチンBが、まだインクリメントされていない値をレジスタにロードしているため、ゴルーチンAの計算結果が反映されなくなってしまいます。解決するために、「ロードして、演算して、ストアする」という一連の操作は、他のゴルーチンに割り込まれないようにしなければなりません。

sync.Mutexを使っても構いませんが、atomicパッケージを使うと、より軽量な演算として実装できます。

atomic2.go
package main

import (
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
)

type Counter int32

func (c *Counter) Inc() {
    atomic.AddInt32((*int32)(c), 1)
}

func main() {
    var c Counter
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            c.Inc()
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("Count =", c, "GOMAXPROCS =", runtime.GOMAXPROCS(0))
}

atomicパッケージを使うと、GOMAXPROCS が多くても加算の間に割り込まれなくなるため、計算間違いは起こりません。

今回の例では atomic.AddInt32 だけ使いましたが、atomicパッケージには、int32, uint32,int64, uint64, uintptr の5つの型それぞれに、Add, CompareAndSwap, Load, Store, Swapの5種類の操作を提供します。

atomic関数 だいたい同じ意味の式
c = atomic.AddInt32(&a, b) a += b; c = a
b = atomic.LoadInt32(&a) b = a
atomic.StoreInt32(&a, b) a = b
c = atomic.SwapInt32(&a, b) c = a; a = b

ただし、atomicは、1つの操作についてのみ保証します。
例えば複数の値をまとめて操作するような使い方はできません。