<- home

Goのメモリモデルのアップデート

2022/06/12

Goのメモリモデルのページは長らくMay 31, 2014バージョンだったんだけど、つい最近June 6, 2022バージョンに更新されていた。 これまでのざっくりしたものとは違い、かなり厳密にリライトされているようなので、少し中身を詳しく読んでみようと思う。

メモリモデルに馴染みのない人は、筆者が以前書いた記事メモリモデルとはなにかも併せて読んでもらえると良いと思う。

これまでのメモリモデル

これまでのGoのメモリモデルは、内容としては上記のMay 31, 2014のものを読むのが一番良いのだけど、かなりざっくりしたものであった。 Happens-Before関係を (改めて) 説明したあとに、Goroutineやチャネル、init関数といったものがランタイムでどのように順序保証されるかと、間違った同期のやり方はどんなものかという例を挙げるに留まっている。 すなわち、データレースに関する詳しい説明や、データレースを起こさないためのセマンティクス、データレース時の挙動についてはあまり書かれていなかった。

Goでデータレース発生時にどうなるかは、Benign Data Race and Undefined BehaviourDoes Go have undefined behaviour ?、あるいはBenign data races: what could possibly go wrong?を読むと、「おそらく (C/C++のような) Undefined Behaviorになるのかな?」といった感じに見える。しかし、Goのドキュメントに直接書かれていないので、正直なところ筆者はよくわかっていなかった。ただ、別にデータ競合のあるプログラムを書きたいわけではないので、ロックやatomicはもちろん使っていたし、ThreadSanitizerも積極的に利用する、といった感じであった。

これは、別にGoのメモリモデルが本質的に曖昧だったわけでは別になく、単に古くて十分に書かれていなかったということだと認識している。Updating the Go Memory Modelにもそんなことが書かれているし、GitHub Discussionでの議論も進んでいたようだ。doc: define how sync/atomic interacts with memory model #5045では、2013年からこの辺について会話しているようである。

アップデートされたメモリモデル

アップデートされたメモリモデルでは、いきなりとても重要なことが書かれているのでそのまま引用する (「Informal Overview」より) 。

While programmers should write Go programs without data races, there are limitations to what a Go implementation can do in response to a data race. An implementation may always react to a data race by reporting the race and terminating the program. Otherwise, each read of a single-word-sized or sub-word-sized memory location must observe a value actually written to that location (perhaps by a concurrent executing goroutine) and not yet overwritten. These implementation constraints make Go more like Java or JavaScript, in that most races have a limited number of outcomes, and less like C and C++, where the meaning of any program with a race is entirely undefined, and the compiler may do anything at all. Go’s approach aims to make errant programs more reliable and easier to debug, while still insisting that races are errors and that tools can diagnose and report them.

ここに書かれているのは、「Goではデータレースはエラーであって、Goはそれを報告しプログラムを終了させることができるよ」ということである。 すなわちGoでは、いわゆるC++の「DRF-SC or Catch Fire」のように、プログラマが正しく同期しないと未定義動作が引き起こされなにが起こるか全く不明、ということではないという表明だと思われる。

これについて詳しいことが、「Implementation Restrictions for Programs Containing Data Races」に書かれているのでこちらも引用する。

First, any implementation can, upon detecting a data race, report the race and halt execution of the program. Implementations using ThreadSanitizer (accessed with “go build -race”) do exactly this.

Otherwise, a read r of a memory location x that is not larger than a machine word must observe some write w such that r does not happen before w and there is no write w’ such that w happens before w’ and w’ happens before r. That is, each read must observe a value written by a preceding or concurrent write.

Reads of memory locations larger than a single machine word are encouraged but not required to meet the same semantics as word-sized memory locations, observing a single allowed write w. For performance reasons, implementations may instead treat larger operations as a set of individual machine-word-sized operations in an unspecified order. This means that races on multiword data structures can lead to inconsistent values not corresponding to a single write. When the values depend on the consistency of internal (pointer, length) or (pointer, type) pairs, as can be the case for interface values, maps, slices, and strings in most Go implementations, such races can in turn lead to arbitrary memory corruption.

1つ目に関して。Benign data races: what could possibly go wrong?では、「プログラマがデータレースを起こすと未定義動作が引き起こされaccidental nuclear missile launchが発生するかもしれないよ」と冗談ぽく書かれていた。Goではプログラムを停止させることがOKなので、そういったリスクは幾分低減されているようである。ただし、これはあらゆるGoの実装がデータレースに際して必ずプログラムを停止させなければいけないという意味ではないと思われるので、プログラマは依然としてデータレースのないプログラムを書かなければいけないし、 -race なども使用すべきである。

2つ目に関して。データレースによってプログラムの停止が引き起こされない場合、マシンワードサイズ以下のメモリへの読み書きは、rやwといった言葉で説明されているが、これはすなわち逐次一貫した、プログラマの期待通りの動きとなると思われる。 これが明言されたのは重要なことで、CやC++のようにデータレースがUndefined Behaviorを引き起こすプログラミング言語では、データレースが発生すると全く何が起きるか不明なためマシンワードサイズの (一見アトミックに思われる) メモリの読み書きすら失敗しうる。Goではデータレース時に、プログラムの停止こそあり得るが、停止しないのであれば最低限の正しい動作保証を試みるという意味でワードサイズ以下のメモリの読み書きは失敗しないことが明言された。このおかげでおそらく、本番環境で実行時にまれにしか現れないようなバグを作り込む機会が減るのではないかなと考えられ、バランス感覚として優れているのではないかなと思う。

3つ目に関して。当然のことだが、メモリ読み取りのサイズがシングルワードを超えてしまうと、それらは順序不定な複数回のマシンワードサイズの読み取りになる。例えば64ビットマシンでメモリから128ビット読みたいとき、それは64ビットの読み取り2回で実現される。これまでこのことは簡単にしか明記されていなかったが、このドキュメントから詳しく書かれている。 マルチワード変数へのアクセスは自動ではアトミックにならないことはとても重要で、Goではインタフェースやマップ、スライス、あるいは単なる文字列であっても内部構造はマルチワードになる。このことはIce cream makers and data racesに詳しく書かれていて、手元でも簡単に試すことができるのでやってみると良いのではないかなと思う。

終わりに

アップデートされたメモリモデルでは、「データレース時の実装の制約によってGoのメモリモデルはJavaやJavascriptに近づいた」とあるが、どちらかというとCやC++っぽさが減ったのがポイントかなと思っている。ここで紹介した内容以外にも、データレースのないプログラムのセマンティクスについてかなり厳密に書かれていたり、コンパイラ開発者向けの最適化に関するガイドも書かれていたりするので、興味があれば最初から最後まで読んでみると良いのではないかなと思う。

Tweet