Goは言語機能として並列実行をサポートしているけど、Goで書いたからといって自動的にデータ構造がスレッドセーフになるわけではないので、スレッド安全性を気にしなければならないはこれまでの言語と変わらない。どういうケースが良くてどういうケースがダメなのかを理解していないと安全なプログラムは書けない。それについて説明をしよう。
まず第一にEffective Goのこの一文は覚えておこう。
Do not communicate by sharing memory; instead, share memory by communicating.
メモリを共有することで通信しようとしないこと。代わりに通信することでメモリを共有すること。
変数の値を変更したあとにチャネルなどを使わずに、おもむろに別のgoroutineからその変数の値を読み書きしてはいけない。そういうやり方だと読み書き操作の前後関係がきちんと決まらないので、変更している途中の中途半端な値を偶然読んでしまうと、わけのわからないエラーが起こってしまう。更新しかけのデータ構造はどういうふうに解釈されるか予測がつかないので、アクセスしたタイミングでpanicすればマシなほうで、ありえない値が返ってきたり、アクセスしてないはずの別のデータを破壊してしまったり、無限ループになって返ってこなかったり、あるいは即座にプロセスが落ちたりするかもしれない。複数の書き込みが競合すると、両者が混ぜ合わさった矛盾したデータ構造になってしまって、あとでそれを読むところでプログラムが落ちるかもしれない。
一方、変数の値を変更したあとに「変数の値を変更しましたよ」ということをチャネルで別のgoroutineに通知して、通知されたgoroutineからその変数を読み書きするのは良い。こういうやり方だと、別のgoroutineでの変数の読み書きはかならず元のgoroutineの書き込みが完了した後にしか起こりえないので、書き込み途中の状態が見えてしまう心配はない。
あるいは単純に、チャネル経由でローカルな値やそのポインタそのものを送っても良い。こういうやり方だと値を受け取るまではその値にアクセスする方法がそもそもないので、そこでデータ競合が発生する可能性はない。
"Do not communicate by sharing memory; instead, share memory by communicating"とはそういうことだ。
チャネル以外の同期メカニズムを使う
安全にgoroutine間で情報をやりとりするには、チャネルを使って同期をとって共有変数へのアクセスを排他制御すればよい、ということはわかったと思う。とはいえそれが唯一のやり方というわけではない。Goでは普通のロック(sync.Mutex)を使って排他制御することもできる。グローバル変数へのアクセスを排他制御するときはMutexを使ったほうが簡単なことが多いようだ。
var m = make(map[string]string)
var mu sync.Mutex
たとえば上のコードではグローバル変数mがあって、別にミューテックスmuがある。mに複数のgoroutineから書き込むのは安全ではないが、まずmu.Lockを呼んでから読み書きすると安全になる。(なお、sync.RWMutexを使うとさらに効率的にできることもある。)
sync.WaitGroupも同期メカニズムの一種だ。WaitGroup.Waitはすべての対応するDoneを待つので、Doneを呼ぶより前に行った変更はWaitの後にはすべて反映されている。
マルチワードの値
単純な値を更新するのも同期メカニズムを使わなければ安全ではない。例えば次のような変数iがあって、これをあるgoroutineから更新しているとしよう。
var i interface{}
go func() {
i = some_var;
}()
別のgoroutineからiを見たとき、iは以前の値か新しい値かのどちらかだろう、と仮定するのは間違いだ。interfaceというのは内部的には2つのポインタから構成されている。1つはinterfaceが指している値の型へのポインタで、もう1つは実際の値へのポインタだ。1つのポインタだけが更新された直後を偶然観測してしまうと、interfaceが指している型と値の実際の型が一致しないので、おかしなことが起こる。2つの連続したポインタ書き込みの間に別のgoroutineがそれを読み込むのはわずかな確率かもしれないが、ゼロではないし、アクセスパターンによってはかなり大きい確率になりえる。interfaceに限らず他のデータ型でもこういった危険性は同じだ。
Goのメモリモデル
「でもデータ構造といえるような複雑なデータではなければ同期する必要はないのでは?」と思うかもしれない。「例えば下のようなプログラムでnowが常に現在時刻なのは保証されているのでは?」
var now int64
go func() {
for {
now = time.Now().Unix()
time.Sleep(1 * time.Second)
}
}()
x86-64ならint64の読み書きは機械語1命令でできるアトミックな操作なので、nowに書き込んでいる途中の状態が他のgoroutineから見えてしまうということはない。それでもこのプログラムは正しくない。
なぜかというと、同期メカニズムが使われていない範囲のコードでは、コードが効率的になるように、コンパイラが勝手にメモリ読み書きの順番を入れ替えたり削除してしまったりする可能性があるからだ。上のプログラムでは同期メカニズムが一切使われていないので、ループの先頭でnowを更新するコードが生成される保証はない。例えばコンパイラはnowの値をレジスタに持っておいて、ループを抜けたあとで一度だけnowを更新するようなコードを生成することができる。
順序が入れ替わるとさらにややこしいことになる。下のコードをあるgoroutineが実行していて、別のgoroutineからその値を見るとき、同期メカニズムを使わなくても、doneが真ならmは問題なくアクセス可能(nilや中途半端な状態ではない)と思うかもしれない。
var m map[int]int
done := false
m = make(map[int]int)
done = true
でもコンパイラがdoneへのtrueの書き込みをmへの書き込みより前に入れ替えてしまうとその保証はなくなる。そしてコンパイラにはそういう最適化が認められている。
そういうコンパイラの最適化はアグレッシブすぎるのではないか?と思うかもしれない。そういう最適化を認めない言語の仕様というのは確かにアリなのだけど、そうするとすべてのステップでメモリに律儀に読み書きに行かなければいけなくなるので、プログラムがかなり遅くなってしまう。メモリはCPUに比べて相当遅いデバイスだ。しかもシングルスレッドならプログラムの意味が変わらない範囲でメモリへの読み書きを適当に入れ替えたりサボったりできるというのに(他に同時に動いているスレッドがなければ、おかしなメモリ読み書き順でも観測されようがないから、意味が変わらない範囲で読み書き順を変更できる)、マルチスレッドだとそういう最適化が許されないということにしてしまうと、並列化することでむしろ性能が大幅に下がってしまう。そこで、同期メカニズムが使われていない範囲であれば、コンパイラはシングルスレッドで許されている通常の最適化を行う、ということになっているのだ。(これはどんな言語でも大抵そう。)
従って、データを更新した時は必ずチャネルやロックなどの同期メカニズムを使って、コンパイラに「ここまでの変更を別のgoroutineからすべて見えるようにすること」と指示してやる必要があるのだ。
ちなみにこういうメモリがどのようなデバイスとして見えるか、ということを、メモリモデルという。普段はメモリがデバイスであるということすらあまり意識していないかもしれないが、Goのメモリモデルはきちんと仕様になっているので見てみると面白いと思う。 なおJavaやC++でもメモリモデルはきちんと定義されている。
メモリモデルはかなり詳細に記述されているが、基本的にはメモリ読み書きの前後関係を細かく定義しているだけだ(たとえばチャネルから値を受け取ってそのあとにメモリを読み込んでいる場合、チャネルにその値を送信するより前のメモリ書き込みはすべて観測可能になっていることが保証される、というような。)
コンパイラの挙動をよく理解していれば、言語仕様的にはスレッドセーフではないけれど、実際は問題の起こらないコードを書くことはできる。しかしそういうコードは将来に渡って安全である保証がない。他のコンパイラを使った場合、今日でも安全ではないかもしれない(Goはgcとgccgoの2種類の実装がある)。実装依存の「賢いやり方」を試みようとはしないこと。チャネルやロックを使わずに共有変数を読み書きしているコードはすべてなにか間違っていると考えたほうがよい。
まとめ
チャネルやロックなどを使わずに複数のgoroutineから共有変数を読み書きしているとしたら、なにかが間違っている。共有変数が単純な型やただの真偽値フラグだったとしても、そういうプログラムは正しくない。そういうコーディング習慣は理解不能なエラーの原因になりえる。
起こりえる問題はややこしいが、対策は簡単だ。変数への書き込みは、どのようなケースであれ、それだけでは他のgoroutineから正しく読めるとは思わないようにしよう。変更をメモリに反映してほかのgoroutineから一貫した状態で見えるようにするために、チャネルやロックを使って、それを読むgoroutineと同期を取ってから読むようにしよう。