Go メモリモデル

2022年6月6日版

はじめに

Go メモリモデルは、あるゴルーチンでの変数への読み込みが、別のゴルーチンでの同じ変数への書き込みによって生成された値を確実に観測するための条件を規定しています。

アドバイス

複数のゴルーチンから同時にアクセスされるデータを変更するプログラムは、そのようなアクセスを直列化する必要があります。

アクセスを直列化するには、チャネル操作や、sync および sync/atomic パッケージにあるような他の同期プリミティブを使用してデータを保護してください。

プログラムの動作を理解するためにこのドキュメントの残りを読む必要がある場合、あなたは賢くなりすぎている可能性があります。

賢くなりすぎないでください。

非公式の概要

Go は、言語の他の部分とほぼ同じ方法でメモリモデルに取り組み、セマンティクスをシンプルで理解しやすく、役に立つものに保つことを目指しています。このセクションでは、アプローチの一般的な概要を説明し、ほとんどのプログラマーにとって十分なはずです。メモリモデルは、次のセクションでより正式に規定されています。

データ競合は、sync/atomic パッケージによって提供されるアトミックデータアクセスを含むすべてのアクセスがアトミックデータアクセスである場合を除き、メモリロケーションへの書き込みがその同じロケーションへの別の読み込みまたは書き込みと並行して発生することと定義されます。すでに述べたように、プログラマーはデータ競合を避けるために適切な同期を使用することを強く推奨されます。データ競合がない場合、Go プログラムはすべてのゴルーチンが単一のプロセッサに多重化されているかのように動作します。このプロパティは、DRF-SC: データ競合のないプログラムは順次一貫した方法で実行される、と参照されることがあります。

プログラマーはデータ競合なしで Go プログラムを作成すべきですが、Go の実装がデータ競合に対応できることには限界があります。実装は常にデータ競合を報告し、プログラムを終了することによってデータ競合に対応できます。そうでなければ、単一ワードサイズまたはサブワードサイズのメモリロケーションへの各読み込みは、実際にそのロケーションに書き込まれた(おそらく並行して実行中のゴルーチンによって)まだ上書きされていない値を観測しなければなりません。これらの実装上の制約により、Go は Java や JavaScript に似ており、ほとんどの競合には限られた数の結果があり、C や C++ のように競合のあるプログラムの意味が完全に未定義であり、コンパイラが何をしてもよいわけではありません。Go のアプローチは、競合がエラーであり、ツールがそれらを診断して報告できることを主張しながら、誤ったプログラムをより信頼性が高く、デバッグしやすくすることを目的としています。

メモリモデル

Go のメモリモデルに関する以下の形式的な定義は、Hans-J. Boehm と Sarita V. Adve が PLDI 2008 で発表した「Foundations of the C++ Concurrency Memory Model」で提示されたアプローチに厳密に従っています。データ競合のないプログラムの定義と、競合のないプログラムに対する順次一貫性の保証は、その論文のものと同等です。

メモリモデルは、プログラム実行に対する要件を記述します。プログラム実行はゴルーチン実行から構成され、ゴルーチン実行はメモリ操作から構成されます。

メモリ操作は、4つの詳細によってモデル化されます

一部のメモリ操作は読み込み様であり、読み込み、アトミック読み込み、ミューテックスロック、チャネル受信などが含まれます。その他のメモリ操作は書き込み様であり、書き込み、アトミック書き込み、ミューテックスアンロック、チャネル送信、チャネルクローズなどが含まれます。アトミック比較交換などの一部は、読み込み様と書き込み様の両方です。

ゴルーチン実行は、単一のゴルーチンによって実行されるメモリ操作の集合としてモデル化されます。

要件 1: 各ゴルーチンにおけるメモリ操作は、メモリから読み込まれた値とメモリに書き込まれた値が与えられた場合、そのゴルーチンの正しい逐次実行に対応しなければなりません。その実行は、Go の制御フロー構成に対するGo 言語仕様によって定められた部分順序要件、および式の評価順序として定義される順序付けられた前関係と一致しなければなりません。

Go のプログラム実行は、ゴルーチン実行の集合と、各読み込み様操作がどの書き込み様操作から読み込むかを指定するマッピングWでモデル化されます。(同じプログラムの複数の実行は、異なるプログラム実行を持つことができます。)

要件 2: 所与のプログラム実行において、マッピングWは、同期操作に限定される場合、それらの操作によって読み書きされた値とシーケンスと整合性のある、同期操作のいくつかの暗黙的な全順序によって説明可能でなければなりません。

同期された前関係は、Wから導出される同期メモリ操作の部分順序です。同期読み込み様メモリ操作rが同期書き込み様メモリ操作wを観測する場合(つまり、W(r) = wの場合)、wrより前に同期されます。非公式には、同期された前関係は、前の段落で述べられた暗黙の全順序の部分集合であり、Wが直接観測する情報に限定されます。

発生前関係は、順序付けられた前関係と同期された前関係の結合の推移閉包として定義されます。

要件 3: メモリロケーション x に対する通常の(非同期の)データ読み込み r について、W(r) は r可視な書き込み w でなければなりません。ここで可視とは、以下の両方が成り立つことを意味します。

  1. wr より前に発生する。
  2. wr より前に発生する他の書き込み w'x への)よりも前に発生しない。

メモリ位置 x における読み書きデータ競合は、x に対する読み込み様メモリ操作 r と、x に対する書き込み様メモリ操作 w で構成され、その少なくとも一方が非同期であり、発生前関係によって順序付けられていないもの(つまり、rw より前に発生することも、wr より前に発生することもない)です。

メモリ位置 x における書き書きデータ競合は、x に対する2つの書き込み様メモリ操作 ww' で構成され、その少なくとも一方が非同期であり、発生前関係によって順序付けられていないもの(つまり、ww' より前に発生することも、w'w より前に発生することもない)です。

メモリロケーション x に読み書きまたは書き書きデータ競合がない場合、x に対する読み込み r は、発生前順序で直前に先行する単一の w であるという、唯一の可能な W(r) を持つことに注意してください。

より一般的に、読み書きまたは書き書きデータ競合のないプログラム実行がない、つまりデータ競合のないGoプログラムは、ゴルーチン実行のいくつかの逐次一貫したインターリーブによって説明される結果しか持ち得ないことが示せます。(証明は、前述のBoehmとAdveの論文のセクション7と同じです。)この性質はDRF-SCと呼ばれます。

この形式的な定義の意図は、C、C++、Java、JavaScript、Rust、Swift などの他の言語が競合のないプログラムに提供する DRF-SC 保証と一致させることです。

ゴルーチン作成やメモリ割り当てといった特定のGo言語操作は同期操作として機能します。これらの操作が同期済み前の部分順序に与える影響については、以下の「同期」セクションで説明されています。個々のパッケージは、自身の操作についても同様のドキュメントを提供することに責任を負います。

データ競合を含むプログラムに対する実装制限

前節では、データ競合のないプログラム実行の形式的な定義を与えました。本節では、競合を含むプログラムに対して実装が提供しなければならないセマンティクスについて、非公式に説明します。

あらゆる実装は、データ競合を検出した場合、その競合を報告し、プログラムの実行を停止することができます。ThreadSanitizerを使用する実装(「go build -race」でアクセス)は、まさにこれを行います。

配列、構造体、または複素数の読み取りは、各個々のサブ値(配列要素、構造体フィールド、または実数/虚数成分)の読み取りとして、任意の順序で実装されることがあります。同様に、配列、構造体、または複素数の書き込みは、各個々のサブ値の書き込みとして、任意の順序で実装されることがあります。

マシンワードより大きくない値を保持するメモリロケーション x に対する読み込み r は、rw より前に発生せず、かつ ww' より前に発生し、かつ w'r より前に発生するような書き込み w' が存在しない、という何らかの書き込み w を観測しなければなりません。つまり、各読み込みは先行する、または並行する書き込みによって書き込まれた値を観測しなければなりません。

さらに、因果関係のない、あるいは「何もないところからの」書き込みの観測は禁止されています。

シングルマシンワードより大きいメモリ位置の読み取りは、単一の許可された書き込み w を観測するなど、ワードサイズのメモリ位置と同じセマンティクスを満たすことが推奨されますが、必須ではありません。パフォーマンス上の理由から、実装は代わりに、より大きな操作を、指定されていない順序で個々のマシンワードサイズの操作のセットとして扱うことがあります。これは、マルチワードデータ構造での競合が、単一の書き込みに対応しない矛盾した値につながる可能性があることを意味します。Goのほとんどの実装でインターフェース値、マップ、スライス、文字列の場合のように、値が内部の(ポインタ、長さ)または(ポインタ、型)ペアの一貫性に依存する場合、そのような競合は任意のメモリ破損につながる可能性があります。

誤った同期の例は、以下の「誤った同期」セクションで示されています。

実装の制限の例は、以下の「誤ったコンパイル」セクションで示されています。

同期

初期化

プログラムの初期化は単一のゴルーチンで実行されますが、そのゴルーチンは他のゴルーチンを作成することができ、それらは並行して実行されます。

パッケージ p がパッケージ q をインポートする場合、qinit 関数の完了は、p のいずれかの init 関数の開始より前に発生します。

すべての init 関数の完了は、関数 main.main の開始よりも前に同期されます。

ゴルーチン作成

新しいゴルーチンを開始するgo文は、そのゴルーチンの実行開始より前に同期されます。

例えば、このプログラムでは

var a string

func f() {
	print(a)
}

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

hello を呼び出すと、将来のある時点で(おそらく hello が戻った後で)"hello, world" が出力されます。

ゴルーチンの破棄

ゴルーチンの終了は、プログラム内のどのイベントよりも前に同期されるとは限りません。例えば、このプログラムでは

var a string

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

a への代入には同期イベントが続かないため、他のゴルーチンによって観測される保証はありません。実際、積極的なコンパイラは 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 からの対応する受信が完了するより前に同期され、それは 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 への対応する送信が完了するより前に同期され、それは print より前に順序付けられます。

チャネルがバッファされている場合(例: c = make(chan int, 1))、このプログラムは "hello, world" を出力する保証はありません。(空文字列が出力されたり、クラッシュしたり、別の動作をしたりする可能性があります。)

容量 C のチャネルからの k 番目の受信は、そのチャネルへの k+C 番目の送信の完了より前に同期されます。

このルールは、前のルールをバッファ付きチャネルに一般化します。これにより、バッファ付きチャネルによってカウンティングセマフォをモデル化できます。チャネル内のアイテムの数はアクティブな使用数に対応し、チャネルの容量は同時使用の最大数に対応します。アイテムを送信するとセマフォが獲得され、アイテムを受信するとセマフォが解放されます。これは並行性を制限するための一般的なイディオムです。

このプログラムはワークリストのすべてのエントリに対してゴルーチンを開始しますが、ゴルーチンは limit チャネルを使用して、一度に最大3つのワーク関数が実行されるように調整します。

var limit = make(chan int, 3)

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

ロック

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

任意の sync.Mutex または sync.RWMutex 変数 ln < m について、l.Unlock()n 回目の呼び出しは、l.Lock()m 回目の呼び出しが戻る前に同期されます。

このプログラムは

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 内)は、l.Lock() への2番目の呼び出し(main 内)が戻る前に同期され、それは print より前に順序付けられます。

sync.RWMutex 変数 l に対する l.RLock の呼び出しについては、l.Unlockn 回目の呼び出しが l.RLock からの戻りより前に同期され、対応する l.RUnlock の呼び出しが l.Lockn+1 回目の呼び出しからの戻りより前に同期されるような n が存在します。

l.TryLock(または l.TryRLock)の成功した呼び出しは、l.Lock(または l.RLock)の呼び出しと同等です。失敗した呼び出しは、同期効果を一切持ちません。メモリモデルに関する限り、l.TryLock(または l.TryRLock)は、ミューテックス l がアンロックされている場合でも false を返すことができると見なされます。

一度

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 を呼び出すと、setup が厳密に1回呼び出されます。setup 関数は、どちらの print 呼び出しよりも前に完了します。その結果、"hello, world" が2回出力されます。

アトミック値

sync/atomic パッケージの API は、まとめて「アトミック操作」と呼ばれ、異なるゴルーチンの実行を同期するために使用できます。アトミック操作 A の効果がアトミック操作 B によって観測される場合、AB より前に同期されます。プログラムで実行されるすべてのアトミック操作は、ある順次一貫した順序で実行されたかのように動作します。

上記の定義は、C++の順次一貫性のあるアトミック操作とJavaのvolatile変数と同じセマンティクスを持ちます。

ファイナライザ

runtime パッケージは、特定のオブジェクトがプログラムから到達不能になったときに呼び出されるファイナライザを追加する SetFinalizer 関数を提供します。SetFinalizer(x, f) の呼び出しは、ファイナライザ呼び出し f(x) より前に同期されます。

追加のメカニズム

sync パッケージは、条件変数ロックフリーマップアロケーションプールウェイトグループなどの追加の同期抽象化を提供します。これらの各ドキュメントは、同期に関する保証を規定しています。

同期抽象化を提供する他のパッケージも、それらが提供する保証を文書化する必要があります。

誤った同期

競合のあるプログラムは不正であり、順次一貫性のない実行を示す可能性があります。特に、読み込み r は、r と並行して実行される任意の書き込み w によって書き込まれた値を観測する可能性があることに注意してください。たとえこれが起こったとしても、r の後に発生する読み込みが w の前に発生した書き込みを観測することを意味するわけではありません。

このプログラムでは

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つのスレッド間に同期イベントがないため、done への書き込みが main によって観測される保証すらありません。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 の初期化された値を観測する保証はありません。

これらすべての例において、解決策は同じです。明示的な同期を使用することです。

誤ったコンパイル

Go メモリモデルは、Go プログラムと同様にコンパイラ最適化も制限します。単一スレッドプログラムでは有効な一部のコンパイラ最適化は、すべての Go プログラムでは有効ではありません。特に、コンパイラは元のプログラムに存在しない書き込みを導入してはならず、単一の読み込みが複数の値を観測することを許可してはならず、単一の書き込みが複数の値を書き込むことを許可してはなりません。

以下のすべての例では、`*p` と `*q` が複数のゴルーチンからアクセス可能なメモリ位置を参照していると仮定します。

競合のないプログラムにデータ競合を導入しないということは、書き込みをそれが現れる条件文の外に移動させないことを意味します。例えば、コンパイラはこのプログラムで条件を反転させてはなりません。

*p = 1
if cond {
	*p = 2
}

つまり、コンパイラはプログラムをこれに書き換えてはなりません。

*p = 2
if !cond {
	*p = 1
}

cond が false で、別のゴルーチンが *p を読み取っている場合、元のプログラムでは、他のゴルーチンは *p の以前の値と 1 のみを観測できます。書き換えられたプログラムでは、他のゴルーチンは 2 を観測できますが、これは以前は不可能でした。

データ競合を導入しないということは、ループが終了すると仮定しないことでもあります。例えば、コンパイラは一般的に、このプログラムで *p または *q へのアクセスをループより前に移動させてはなりません。

n := 0
for e := list; e != nil; e = e.next {
	n++
}
i := *p
*q = 1

list が循環リストを指していた場合、元のプログラムは *p*q にアクセスすることはなかったでしょうが、書き換えられたプログラムはアクセスすることになります。(コンパイラが *p がパニックを起こさないことを証明できれば、*p を前に移動させるのは安全でしょう。*q を前に移動させるには、コンパイラが他のゴルーチンが *q にアクセスできないことを証明することも必要です。)

データ競合を導入しないということは、呼び出された関数が常に返る、または同期操作がないと仮定しないことも意味します。例えば、コンパイラはこのプログラムで関数呼び出しの前に *p または *q へのアクセスを移動させてはなりません(少なくとも、f の正確な動作を直接知らない限りは)。

f()
i := *p
*q = 1

もし呼び出しが戻らなかった場合、元のプログラムは再度 *p または *q にアクセスすることはなかったでしょうが、書き換えられたプログラムはアクセスすることになります。そして、もし呼び出しが同期操作を含んでいた場合、元のプログラムは *p*q へのアクセスに先行する発生前エッジを確立できたでしょうが、書き換えられたプログラムはそうはならなかったでしょう。

単一の読み込みが複数の値を観測することを許可しないということは、共有メモリからローカル変数を再ロードしないことを意味します。例えば、コンパイラはこのプログラムで i を破棄して *p から2回目に再ロードしてはなりません。

i := *p
if i < 0 || i >= len(funcs) {
	panic("invalid function index")
}
... complex code ...
// compiler must NOT reload i = *p here
funcs[i]()

複雑なコードが多くのレジスタを必要とする場合、シングルスレッドプログラム用のコンパイラは、コピーを保存せずに i を破棄し、funcs[i]() の直前に i = *p を再ロードすることができました。Go コンパイラはそうしてはなりません。なぜなら *p の値が変更されている可能性があるからです。(代わりに、コンパイラは i をスタックに退避させることができます。)

単一の書き込みが複数の値を書き込むことを許可しないということは、書き込み前にローカル変数が書き込まれるメモリを一時記憶領域として使用しないことも意味します。例えば、コンパイラはこのプログラムで *p を一時記憶領域として使用してはなりません。

*p = i + *p/2

つまり、プログラムをこれに書き換えてはなりません。

*p /= 2
*p += i

i*p が最初は2に等しい場合、元のコードは *p = 3 を実行するため、競合するスレッドは *p から2または3しか読み取ることができません。書き換えられたコードは *p = 1 を実行し、その後 *p = 3 を実行するため、競合するスレッドは1も読み取ることができます。

これらの最適化はすべてC/C++コンパイラで許可されていることに注意してください。C/C++コンパイラとバックエンドを共有するGoコンパイラは、Goでは無効な最適化を無効にするように注意する必要があります。

データ競合の導入禁止は、コンパイラが対象プラットフォームでの正しい実行に競合が影響しないことを証明できる場合には適用されないことに注意してください。例えば、ほとんどすべてのCPUにおいて、次の書き換えは有効です。

n := 0
for i := 0; i < m; i++ {
	n += *shared
}
n := 0
local := *shared
for i := 0; i < m; i++ {
	n += local
}

これは、追加される可能性のある読み取りが既存の並行読み取りまたは書き込みに影響を与えないため、*shared へのアクセスで障害が発生しないことを証明できる場合に限ります。一方で、この書き換えはソースコードトランスレータでは有効ではありません。

まとめ

データ競合のないGoプログラムを記述するプログラマーは、実質的に他のすべての最新プログラミング言語と同様に、それらのプログラムの順次一貫した実行に依拠することができます。

競合のあるプログラムに関しては、プログラマーもコンパイラも「賢くなりすぎない」というアドバイスを心に留めておくべきです。