Go言語でdata raceが起きるときに起きる(かもしれない)こと
はじめに
この記事は、プログラミングにおいて特に難しいことの1つである「並行処理」に関する記事です。特に、「並行処理」を行うときに意図せず発生させてしまいやすい「data race」について書きます。data raceがどのような驚くべき問題を引き起こすかを、簡単に動かせるサンプルコードで具体的に見ていきます。
すべてのサンプルコードにplaygroundがついていますから、とりあえず気軽に動かしてみるだけみたいな読み方もできます。むしろそれがおすすめかもしれません。
プログラム言語としてGoを使いますが、内容的にはGoに限らず当てはまると思います。ただし、data raceに関してはプログラム言語ごとに微妙なアプローチの違いがあるので、それについては最後に少しだけ補足します。
ところで、ソフトウェア開発では、data raceを一切発生させない状態を目指すべきだと筆者は考えています。Data Race Detectorを使って十分なテストを行えば、そのような状態に近づくことができます。
しかし、実際にdata raceが存在するとどのようなことが起こりうるのかを詳しく知っている人は少ないのではないでしょうか。そこで、data raceによって起こる「驚くような動き」をいくつも挙げることで、data raceをなくすことへのモチベーションを高めたいと思います。
注意: 誤解してほしくないポイント2つ
記事が長くなるので、誤解してほしくないポイントを最初に2つ書いておきます。
data raceとは何か
「data race」について、この記事を読むのに必要十分な程度に説明します。
A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.
これを訳すと概ね次のようになります:
data raceは、あるメモリー位置への書き込みであって、その同じ位置に対する他の読み込みまたは書き込みと並行に起きるものとして定義されます。 ただし、すべてのアクセスがsync/atomic
パッケージで提供されるアトミックなデータアクセスである場合を除きます。
もっと簡単に言ってしまうと、次の2つのいずれかに当てはまるものはdata raceです。
- 同一のメモリー位置に対する並行な2つの書き込み
- 同一のメモリー位置に対する並行な読み込みと書き込み
それぞれについて、シンプルな例を挙げておきます。
// 同一メモリー位置に対する並行な2つの書き込み
package main
var x int
func main() {
go func() {
x = 1 // 書き込み1
}()
x = 2 // 書き込み2
}
// 同一メモリー位置に対する並行な読み込みと書き込み
package main
import "fmt"
var x int
func main() {
go func() {
x = 1 // 書き込み
}()
fmt.Println(x) // 読み込み
}
この記事を読むにはこの2つがdata raceであることがわかれば十分です。一応細かい補足をいくつか書いておきます。
data raceと間違われやすいもの
並行な2つの読み込み
次の2つの読み込みは並行ですが、2つの読み込みの組み合わせはdata raceにはなりません。
// 同一メモリー位置に対する並行な2つの読み込み
package main
import "fmt"
var x int
func main() {
x = 1
go func() {
fmt.Println(x) // 読み込み1
}()
fmt.Println(x) // 読み込み2
}
「競争」しているけどdata raceではない例
次の2つの書き込みはどちらが先に行われるかわかりませんが、data raceではありません。
package main
import (
"sync"
)
var x int
var mu sync.Mutex
func main() {
go func() {
mu.Lock()
x = 2 // 書き込み1
mu.Unlock()
}()
mu.Lock()
x = 1 // 書き込み2
mu.Unlock()
}
data raceによってデータの一貫性が壊れる例
データの一貫性が壊れるとは、総じていえば、次のような代入文の結果を意図した通りに読み取れないことです。
variable = value
私たちが普通にプログラミングするとき、代入文の前後の変数variable
は「全く代入がされていないか、完全に代入が終わっているか」のどちらかであることを期待すると思います。
当たり前すぎて何を言っているかわからないかもしれませんが、 「誰かがvariable
を読み取ったとき、上記の代入が中途半端に行われた状態を観測することはないだろう」と期待している という意味です。
私たちが当たり前に依拠しているこの前提は、data raceのあるプログラムでは必ずしも成り立ちません。そのことを具体的に見ていきましょう。
中途半端に書き込まれた構造体を読み取る
次の関数を見てください。構造体型Pair
の変数p
があります。また、メインのgoroutineとgo
文で起動されるもう1つのgoroutineがあります。片方のgoroutineでp
に書き込み、メインのgoroutineでp
を読み取っています。
func structCorruption() string {
type Pair struct {
X int
Y int
}
arr := []Pair{{X: 0, Y: 0}, {X: 1, Y: 1}}
var p Pair // 共有変数
// writer
go func() {
for i := 0; ; i++ {
// 代入するのは{X: 0, Y: 0}, {X: 1, Y: 1}のどちらかのみ
p = arr[i%2]
}
}()
// reader
for {
read := p
switch read.X + read.Y {
case 0, 2:
// {X: 0, Y: 0}, {X: 1, Y: 1}のどちらかならば、
// このケースに入るので何も起きない。
default:
return fmt.Sprintf("struct corruption detected: %+v", read)
}
}
}
このサンプルに限らず、この記事のサンプルコードでは2つのgoroutineを使い、片方で書き込み、もう片方で読み込みを行います。そこで書き込む方をwriter、読み込む方をreaderと呼ぶことにしましょう。
writerがp
に代入するのはPair{X: 0, Y: 0}
かPair{X: 1, Y: 1}
のどちらかです。readerはこれ以外の値を観測したときにメッセージを返して終了するようになっています。
readerのforループが終了しない限り関数全体も終了しないようになっていますから、writerが書き込む2通りの値だけがreaderによって読まれている限り、このプログラムは無限ループするでしょう。実際にはどうなるでしょうか?
次のplaygroundを動かしてみてください。
メッセージを返して終了したと思います。
struct corruption detected: {X:0 Y:1}
Program exited.
readerが読み取った値は驚くべきことに{X:0, Y:1}
というものです。
このサンプルコードには何の害もありませんが、構造体の意味によっては、そもそも存在してはいけない状態というものがあって、それを意図せず読み取ってしまうかもしれません。
print
するとpanic
する
文字列をこの例はbudougumi0617さんのブログ[Go] stringの比較でヌルポのpanicが発生する(こともある) #横浜Go読書会で説明されているものを参考に作成しました。
package main
import "fmt"
func main() {
var s string
// writer
go func() {
arr := [2]string{"", "hello"}
for i := 0; ; i++ {
s = arr[i%2]
}
}()
// reader
for {
fmt.Println(s)
}
}
上記のPlaygroundで実行すると、次のようにpanic
することがあります。
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x45d33c]
goroutine 1 [running]:
fmt.(*buffer).writeString(...)
string
型の値は複数の部分からなっており、文字列の長さを表す部分とバイト列の先頭へのポインタを持っています。
長さを表す部分とそのポインタ部分が一緒に更新されれば問題ないのですが、reader側から中途半端に片方だけ更新された状態を観測してしまうと、nil pointer dereferenceが発生します。
len
とcap
が中途半端に更新される
スライスの次のプログラムで、writerは常にlenとcapが等しいようなスライスをsに代入しています。sの初期値(nil
)もlen(s) == cap(s) == 0
ですから、一見するとこのプログラムの全体にわたってlen(s) == cap(s)
になりそうです。
func sliceCorruption() {
underlying := [5]int{1, 2, 3, 4, 5}
var s []int
go func() { // writer
for i := 0; ; i++ {
// rは1から5までの整数
r := i%5 + 1
// len == capであるようなスライスを新たに作り、
// sに代入する
s = underlying[:r:r]
}
}()
// reader
for {
// len(s) == cap(s)は常に成り立つと期待する?
if len(s) != cap(s) {
panic(fmt.Sprintf("len(s) == %d and cap(s) == %d", len(s), cap(s)))
}
}
}
次のPlaygroundでこの関数を実行してみます。
panic: len(s) == 2 and cap(s) == 1
goroutine 1 [running]:
main.sliceCorruption()
/tmp/sandbox2438933514/prog.go:27 +0x139
main.main()
/tmp/sandbox2438933514/prog.go:6 +0xf
具体的な実行結果は毎回変わりますが、len
がcap
と異なるばかりか、len
がcap
よりも大きい状態(!)をreaderが観測しました。
補足
sliceの実装を見ると、3つのフィールドからなるstructになっています。sliceの要素を保存する配列(underlying arrayと言います)とlen
とcap
です。
これらが別々のメモリー位置にあることから、data raceが起きているときにはその一部のフィールドだけが更新された状態を観測する可能性があることがわかります。
型assertしたはずのinterfaceの動的値がおかしい
inteface型の例として、any
型の変数の例をあげます。writer側では、異なる型の値を交互に代入してみましょう。reader側では型スイッチ文を使って動的型を確かめてから、動的値が期待通りかどうかチェックします。
func interfaceCorruption() string {
var x any
go func() { // writer
arr := []any{1, "hello"}
for i := 0; ; i++ {
x = arr[i%2]
}
}()
// reader
for {
read := x
switch r := read.(type) {
case int:
if r != 1 {
return fmt.Sprintf("unexpected int value: %d", r)
}
case string:
if len(r) != 5 {
return fmt.Sprintf("unexpected string length :%d", len(r))
}
case nil:
default:
return fmt.Sprintf("strange type detected: %+v", read)
}
}
}
int
型の1
とstring
型の"hello"
だけを交互に代入しているのですから、reader側でint
と判定すれば値は1
だし、string
型と判定すれば長さは5
になりそうなものですが、次のPlaygroundで実行するとそうならないケースがレポートされます。
unexpected string length :-9223372036854775808
interface型の値には「型の情報(動的型など)」と「値の情報(動的値)」の2つの部分があります。この2つの部分を中途半端に更新した状態をreaderが観測することによって、このような結果が起こります。
補足
Goのruntimeにおけるinterface型の実装はおそらく次の箇所にあります。
data
の部分が動的値に対応して、tab
の部分が型に関する情報になっています。
panic
するようになっている?
mapのdata raceは最後にmap
型を扱います。実はmap
型は少し特別で、race detectorを使うまでもなく、data raceが発生したらその時点でpanic
するようになっています。
例えば、次の関数を実行するとpanic
します。
func mapCorruption() {
// 共有変数
m := map[int]int{}
// writer
go func() {
for i := 0; ; i++ {
m[i] = i
}
}()
// reader
for {
if m[len(m)] > 10000 {
break
}
}
}
ただし、readerからのmap
へのアクセスの仕方を変えて、要素へのアクセスm[key]
を行わずに、m
の大きさであるlen(m)
にのみアクセスした場合は、panic
しませんでした。
func mapCorruption2() {
// 共有変数
m := map[int]int{}
// writer
go func() {
for i := 0; ; i++ {
m[i] = i
}
}()
// reader
for {
// len(m)にだけアクセスする
// 要素にはアクセスしない
if len(m) > 10000 {
break
}
}
}
これもdata raceであることに変わりはなく、-race
つきでローカル実行するとdata raceが報告されます。
len(m)
とm
の中身で矛盾があるようなサンプルコードを書こうと思ったのですが、m
の中身にアクセスしようとするとpanic
してしまいますから、そのようなコードは書けませんでした。
その他直感に反する結果
このセクションでは一貫性とは別な観点で直感に反する結果をもたらすdata raceサンプルコードを挙げます。
それぞれのサンプルにはよく使われる名前がついているので、その名前を見出しにしています。興味があれば調べてみてください。
Store Buffering
// メモリーモデル上はpanicする可能性があり実際panicすることがある
func storeBuffer() {
var eg errgroup.Group
// 共有変数
x, y := 0, 0
r1WasZero, r2WasZero := false, false
eg.Go(func() error {
x = 1
r1 := y
r1WasZero = r1 == 0
return nil
})
eg.Go(func() error {
y = 1
r2 := x
r2WasZero = r2 == 0
return nil
})
eg.Wait() // エラー処理略
if r1WasZero && r2WasZero {
panic("Store Buffer Test Failed")
}
}
素直に考えると、r1 == 0
だったならy = 1
よりも先にx = 1
の書き込みをしていると考えるので、r2 := x
の時点でx == 1
になっているはずだと思えます。しかし、次のPlaygroundでこの関数を繰り返し呼び出すと、panicします。
panic: Store Buffer Test Failed
goroutine 1 [running]:
main.storeBuffer()
/tmp/sandbox1591362111/main.go:29 +0x192
main.main()
/tmp/sandbox1591362111/main.go:7 +0xf
まとめと開発上の個人的な考え方
この記事では、data raceが存在するときには通常のプログラマーの自然な期待を裏切るような結果が起こりうることを見てきました。
最初に述べたように、これはあくまでdata raceが存在するときにのみ起こりうることです。並行処理を使っていても、data raceが起きないようにしていれば、この記事で挙げたような不思議な事象は起こりません。それでは、data raceが起きないようにするにはどうすれば良いでしょうか?
data raceが起きていないことについて自信を持つには、Race Detectorを使ったテストをするのが有効です。ただし、Race Detectorは静的解析ではなく、動的にdata raceを検知する技術です。つまり、実際にプログラムを動かして、実際に起きたメモリー読み書きがdata raceであればそれを報告します。ですから、テストがdata raceを引き起こすようなシナリオをカバーしていなければ、Race Detectorはそれを見逃してしまいます。
個人的には、data raceを引き起こすかもしれないようなテストケース・テストシナリオをすべてカバーするというのは簡単ではないと思います。ですから、例えばチーム開発であれば、どのようなコードがdata raceになりうるかを理解したメンバーがレビューやモブプロに参加するといった地道な取り組みも重要だと思います。
ところで、data raceは絶対に避けるべきものなのでしょうか?Go言語に関する限り、絶対に避けるべきだとは言い切れないと思っています。Go言語のメモリーモデルにおいてdata raceは未定義動作ではなく、起こりうる結果は有限通りのパターンしかないとされているので、原理的にはすべての起こりうるパターンをプログラマーが確認できるからです。
しかし、個人的にはdata raceは極力見つけ次第解消したいと思っています。実践的には、data raceは無条件でバグとして取り扱う、くらいのスタンスが良いのではないでしょうか。というのも、この記事で挙げたような短い関数でも驚くような挙動があるので、現実的な大きさのソースコードにdata raceが紛れこんでいるとき、それが「無害なdata race」であることを確信するのは非常に難しいと思うからです。
以上をまとめると、個人的にはdata raceについて次のように考えています:
- data raceがあるプログラムはとても理解が難しくなるので、data raceは極力完全に無くした方が良い
- data raceをなくすには、Race Detectorを活用し、goroutineに慣れているメンバーを含むレビューやモブプロも行うのが良い
補足: 他言語におけるdata race発生時の取り扱い
ついさきほど、「並行処理を使っていても、data raceが起きないようにしていれば、この記事で挙げたような不思議な事象は起こりません。」と書きました。この性質をより専門的には、"DRF-SC"と呼んでいます。もちろん、DRF-SCにはもっと正確な定義がありますが、とりあえず「data raceさえなければ素直な動きをするという性質」くらいに捉えて構わないと思います。
多くの現代的プログラム言語(のメモリーモデル)がDRF-SCを満たしていて、例えばGo, C, C++, Rust, Java, JavaScript(ECMAScript)が当てはまります。
一方で、「data raceが起きた場合に何が起こりうるか」の部分は、DRF-SCを満たす言語の間でも違いがあります。
例えばC, C++などはdata raceが発生した場合の動きは未定義動作で、「何が起きてもおかしくない」と言えます。
一方、Go, Java, JavaScriptはそうではなく、data raceが発生した場合の動きは有限通りのパターンとして定義されています。非常に理解が難しいとはいえ、徹底分析すれば起こりうる可能性は列挙できるはずだと言えます。
補足: 他言語におけるdata race検出
GoのData Race Detectorが動的な検査であることは前述のとおりです。コンパイル時の静的な検査によりdata raceを防ぐ仕組みを持っている言語もあり、Rustがその一例です。
筆者はRustに慣れていないので詳しく解説できないのですが、Rust Atomics And Locksによれば、不変借用と可変借用の仕組みがあることで、コンパイル時にdata raceの発生可能性を除外できるとのことです。
参考資料
筆者が参考にした資料と、参考になりそうな資料を挙げておきます。
タイトルとリンク | 概要 |
---|---|
The Go Memory Model - The Go Programming Language | Goのメモリーモデルです。メモリーモデルとはメモリーへの並行アクセスをしたときに起きることを定めた言語仕様のことで、この記事で扱った内容の基礎となるドキュメントです。 |
research!rsc: Memory Models | GoのメンバーであるRuss Cox氏による、Goに限らないメモリーモデル全般についての解説・論文です。2022年に行われたGoメモリーモデルのアップデートのために書かれたものなのですが、その意義を理解するために必要な前提知識から丁寧に説明しています。 |
データ競合と happens-before 関係 | uchan氏による、data race(データ競合)についての詳しい解説です。日本語です。 |
よくわかるThe Go Memory Model | 筆者によるGoメモリーモデル解説です。 |
Data Race Detector - The Go Programming Language | Go公式によるData Race Detectorの解説です。 |
Looking inside a Race Detector | Race Detectorの仕組みであるVector Clockについての非常にわかりやすい解説です。 |
Go Slices: usage and internals - The Go Programming Language | Go公式によるスライスの使い方と内部の解説です。 |
The Laws of Reflection - The Go Programming Language | Go公式によるreflectionの解説なのですが、interface型についての解説も含んでいます。 |
research!rsc: Go Data Structures: Interfaces | GoのメンバーであるRuss Cox氏によるinterface型についての解説です。 |
最後に
サンプルコードの動作確認をしつつ正確を期して記述しましたが、data raceというテーマ自体がかなり難しいものなので、記述に誤りがないとは言い切れません。何か気づいたことがありましたらGitHubリポジトリのIssueやPull Requestなどでご連絡いただけると助かります。
また、執筆にあたり次の方から情報やフィードバックをいただきました。ありがとうございます。
- DQNEOさん
- dalanceさん
もちろん、記述の誤りなどについてのすべての責任は筆者にあります。
Discussion
本記事はGoについての記事なので完全に蛇足ですが、Rustや(おそらく最近の)Swiftは静的解析によりコンパイル時にdata raceを検出するので、他言語のところで触れてもいいかな、と思いました。
個人的には気軽にgoroutineを作れるGoにこそ静的解析が欲しい気がしますが、今から導入するのはなかなか難しそうですかね…。
ありがとうございます!
仰っていることは具体的には https://marabos.nl/atomics/basics.html#borrowing-and-races に書かれているようなことであっているでしょうか?
Rustにあまり慣れておらずきちんと書くのは難しいのですが、ちょっと考えてみます!(もしうまく書けないと思ったら本文には反映しないかもしれません。。)