...
Run Format

Goのメモリモデル

バージョン 2014/5/31

はじめに

Goのメモリモデルは、あるゴルーチンで変数を読み取り中に他のゴルーチンから同じ変数に書き込みがあった場合、 書き込みされた結果を読み取り側から観測することが保証できる条件を指定します。

前事象

1つのゴルーチン中では、読み取りと書き込みはプログラムで指定された順序で実行されたかのように振る舞わなければいけません。 すなわち、1つのゴルーチン中での読み取りと書き込みの実行順の変更が言語仕様によって定義された振る舞いを変えない限り、 コンパイラとプロセッサは、読み取りと書き込みの実行順を変更する可能性があります。 そのため、それぞれのゴルーチンで観測される実行順は違うかもしれません。 例えば、あるゴルーチンでa = 1; b = 2;を実行するとしましょう。 他のゴルーチンでは、aの更新よりも先に、bの更新を観測することがあります。

読み取りと書き込みの要件を指定するため、前事象、つまりGoにおけるメモリ操作の半順序を定義します。 もし、イベントe1がイベントe2の前に起きる場合、 これをe2e1の後に起きると呼びます。 また、もしe1e2の前にも後にも起きない場合、 これをe1e2が同時に起きると呼びます。

1つのゴルーチン中では、前事象の順序はプログラムに記述された順序と同様となる。

次の条件が両方満たされる場合、変数vの読み込みrは、変数vへの書き込みwを観測することを許可されます。

  1. rは、wの前に起こらない。
  2. wrの間に、他のvへの書き込みw'がない。

変数vの読み込みrが特定の変数vの書き込みwを観測することを保証するためには、 rから観測できる書き込みがwだけであることを確認します。 すなわち、次の条件が両方満たされる場合、rwを観測することを保証します。

  1. wrの前に起こる。
  2. 共有変数vへの他のいかなる書き込みも、wの前かrの後に起こる。

この2つの条件は、最初の条件よりも強い制約です。 これは、wまたはrと同時に他の書き込みが行われないことを要求します。

1つのゴルーチン内では並行性がないので、以下の2つの定義は同値です。 読み込みrは、最も最近のvへの書き込みwによって書き込まれた値を観測します。 複数のゴルーチンから共有変数vにアクセスする場合、同期イベントを使用して望んだ書き込み結果を観測できるように前事象条件を満たす必要があります。

変数vv型のゼロ値で初期化されますが、これはメモリモデルの書き込みと同様に振る舞います。

マシンのワードサイズより大きい値の読み込みと書き込みは、複数のマシンワードサイズ単位の操作として処理されます。 その際、操作の実行順は規定されていません。

同期

初期化

プログラムの初期化は、1つのゴルーチンで実行されます。そのゴルーチンから他のゴルーチンを実行した場合は、並行に初期化が実行されます。

もし、パッケージpがパッケージqをインポートしているとすると、 qの初期化関数の実行が完了するまで、pのいかなる処理も実行されません。

main.main関数は、全てのinit関数が完了した後で実行されます。

ゴルーチンの起動

新たにゴルーチンを起動するgo文は、ゴルーチンの実行がはじまる前に起きます。

例として、次のプログラムを見て下さい。

var a string

func f() {
	print(a)
}

func hello() {
	a = "hello, world"
	go f()
}

hello関数を呼び出すと、いずれかの時点で"hello, world"がコンソールに出力されます (おそらく、hello関数の呼び出しが終了した後に出力されるでしょう)。

ゴルーチンの破棄

ゴルーチンの終了前に、プログラム中のどの処理も先に実行されることは保証されません。 例えば、次のプログラムを見て下さい。

var a string

func hello() {
	go func() { a = "hello" }()
	print(a)
}

同期イベントがないため、他のゴルーチンからaへの代入結果("hello")を観測できるかどうかは保証されません。 実際、コンパイラによってはgo文全体を削除することもあるでしょう。

ゴルーチン内での変更を他のゴルーチンからも観測したければ、ロックやチャネルのような同期メカニズムを使用して、 相対的な実行順を確立しましょう。

チャネルによる通信

ゴルーチン間の同期させるため、よく使われる方法はチャネルによる通信です。 特定のチャネルへの送信は、そのチャネルから受信に対応づけられます。 送信と受信は、通常は異なるゴルーチンで行われます。

チャネルへの送信は、チャネルからの受信が完了する前に起こる。

次のプログラムを見て下さい。

var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0
}

func main() {
	go f()
	<-c
	print(a)
}

上記プログラムは、"hello, world"が必ず表示されます。 変数aへの書き込みはチャネルcへの送信の前に起こります。 一方でチャネルcからの受信は、チャネルcへの送信が行われるまで完了しないため、 printは必ず実行されます。

チャネルの閉じられた後で、チャネルからチャネルが閉じられたことを示すゼロ値を受信する。

先ほどの例では、c <- 0close(c)に置き換えても、同じ結果になります。

バッファなしチャネルは、チャネルへの送信が完了する前に、チャネルからの受信が起こる。

次のプログラムを見て下さい(先ほどの例とほとんど同じですが、バッファなしチャネルを使用している点と、受信と送信の順番が入れ替わっている点が異なります)。

var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}
func main() {
	go f()
	c <- 0
	print(a)
}

上記プログラムも、必ず"hello, world"が表示されます。 変数aへの書き込みはチャネルcからの受信の前に起こります。 一方でチャネルcへの送信は、チャネルcからの受信が行われるまで完了しないため、 printは必ず実行されます。

チャネルにバッファがあると(例えば、c = make(chan, int, 1)に変更する)、 上記プログラムは常に"hello, world"を出力するとは限りません(空文字を出力したり、あるいは異常終了してしまうなど挙動が不安定になります)。

容量Cのチャネルからのk回目の受信は、そのチャネルへのk+C回目の送信が完了する前に起こる。

このルールは、先ほどのバッファつきチャネルのルールを一般化したものです。 バッファつきチャネルを使って、カウンタセマフォを実装できます。 具体的には、チャネル内のアイテム数が現在の使用数、チャネルの容量が最大同時使用数を表しているとし、 チャネルへの送信をセマフォの取得、チャネルからの受信をセマフォからの解放に対応させます。 これは、並行性を制限したいときによく使う書き方です。

次のプログラムはworkリストの要素毎にゴルーチンを実行します。 ゴルーチンの同時実行数は、limitチャネルを使って最大でも3つに収まるように調整されます。

var limit = make(chan int, 3)

func main() {
	for _, w := range work {
		go func() {
			limit <- 1
			w()
			<-limit
		}()
	}
	select{}
}

ロック

syncパッケージには、sync.Mutexsync.RWMutexの2つロックのデータ型が実装されています。

sync.Mutexまたはsync.RWMutex型の変数lがあって、n < mとする。 このとき、n回目のl.Unlock()呼び出しは、m回目のl.Lock()呼び出しから応答が返る前に起こる。

次のプログラムを見て下さい。

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()
	go f()
	l.Lock()
	print(a)
}

上記プログラムは、必ず"hello, world"を表示します。 最初のl.Unlock()呼び出し(関数f中)は、2つ目のl.Lock()呼び出し(関数main中)の前に起こるので、 printは必ず実行されます。

sync.RWMutex型の変数lへのl.RLock呼び出しはどれも、 以下のようなnが存在し、l.RLockn回目のl.Unlock呼び出しの後で起き、 l.RUnlockn+1回目のl.Lock呼び出しの前に起こる。

Once

syncパッケージは、Once型を使用した、複数のゴルーチンの下で安全に初期化を行う方法を提供します。 複数のスレッドから、特定のfに対してonce.Do(f)を実行することができます。 しかし、f()は1つのスレッドからしか実行されず、他の呼び出しはf()呼び出しが完了するまでブロックします。

once.Do(f)からのf()の唯一の呼び出しは、どのonce.Do(f)から呼び出し元に戻るよりも前に起こる。

次のプログラムを見て下さい。

var a string
var once sync.Once

func setup() {
	a = "hello, world"
}

func doprint() {
	once.Do(setup)
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

twoprintを呼び出すと、"hello, world"を2回表示します。 最初の呼び出しで、doprintsetupを一度だけ実行します。

誤った同期

読み込みrは、並行して実行された書き込みwを観測するかもしれないことに注意してください。 このような場合でも、これはwの前に起こることを、 rの後に起こることが観測可能だということは意味していません。

次のプログラムを見て下さい。

var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b)
	print(a)
}

func main() {
	go f()
	g()
}

上記プログラムは、g2を表示した後に、0を表示することがあります。

この事実により、いくつかの一般的によく使われる書き方は意図した通りに動作しません。

ダブルチェックロッキングは、同期のオーバーヘッドを回避しようとする試みです。 例えば、twoprintプログラムは、以下のように誤った書き方をされることがあります。

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func doprint() {
	if !done {
		once.Do(setup)
	}
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

doprint中で、doneへの書き込みが観測できれば、 暗黙的にaへの書き込みも観測できるだろうと考えてはいけません。 このようなことは、保証されていません。 このプログラムは、"hello, world"ではなく空文字列を表示することがあります。

別の誤った書き方は、以下のようにビジーループを使用することです。

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

これも、mainからdoneへの書き込みが観測できれば、 暗黙的にaへの書き込みも観測できるだろうと考えてはいけません。 このようなことは、保証されていません。 このプログラムも同様に、空文字列を表示することがあります。 さらに悪いことに、2つのスレッド間で同期をとっていないため、 maindoneへの書き込みを観測することも保証されません。 そのため、main中のループは終了しないかもしれません。

このテーマには、次のプログラムのように微妙なパターンがあります。

type T struct {
	msg string
}

var g *T

func setup() {
	t := new(T)
	t.msg = "hello, world"
	g = t
}

func main() {
	go setup()
	for g == nil {
	}
	print(g.msg)
}

たとえmaing != nilを観測してループを終了したとしても、 g.msgの初期値を観測できる保証はありません。

全ての例における解決策は、明示的な同期方式を使用することです。