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 で発表した「C++ 並行メモリモデルの基礎」で示されたアプローチに厳密に従っています。データ競合のないプログラムの定義と、競合のないプログラムの順次一貫性の保証は、その研究のものと同等です。

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

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

読み取り、アトミック読み取り、ミューテックスロック、チャネル受信など、一部のメモリ操作は 読み取りのような 操作です。書き込み、アトミック書き込み、ミューテックスアンロック、チャネル送信、チャネルクローズなど、他のメモリ操作は 書き込みのような 操作です。アトミック compare-and-swap など、一部の操作は読み取りのような操作と書き込みのような操作の両方です。

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

要件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. w は、r の前に発生する他の書き込み w'x に対する)の前に発生しません.

メモリ位置 x に対する 読み取り - 書き込みデータ競合 は、x に対する読み取りのようなメモリ操作 rx に対する書き込みのようなメモリ操作 w で構成され、少なくとも一方が同期しておらず、前に発生するによって順序付けられていません(つまり、rw の前に発生せず、wr の前に発生しません)。

メモリ位置 x に対する 書き込み - 書き込みデータ競合 は、x に対する2つの書き込みのようなメモリ操作 ww' で構成され、少なくとも一方が同期しておらず、前に発生するによって順序付けられていません。

メモリ位置 x に読み取り - 書き込みまたは書き込み - 書き込みデータ競合がない場合、x に対する読み取り r は、可能な W(r) を1つだけ持ちます。前に発生する順序で直前にある単一の w です.

より一般的には、データ競合がない Go プログラム、つまり読み取り - 書き込みまたは書き込み - 書き込みデータ競合のないプログラム実行がない 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 関数の完了は、関数 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 の呼び出しについて、n 番目の l.Unlock の呼び出しが l.RLock からの戻りの前に同期され、対応する l.RUnlock の呼び出しが n+1 番目の l.Lock の呼び出しからの戻りの前に同期されるような 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()
}

しかし、doprintdone への書き込みを観測することが a への書き込みを観測することを意味するという保証はありません。このバージョンは、"hello, world" の代わりに空の文字列を(誤って)出力する可能性があります。

もう1つの誤ったイディオムは、値をビジー待機することです。次のようにです。

var a string
var done bool

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

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

前述のように、maindone への書き込みを観測することが 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 を読み取っている場合、元のプログラムでは、他のゴルーチンは *p1 の以前の値のみを観測できます。書き換えられたプログラムでは、他のゴルーチンは 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 へのアクセスに先行する happens before エッジを確立できますが、書き換えられたプログラムは確立できません。

単一の読み取りで複数の値を観測することを許可しないということは、共有メモリからローカル変数をリロードしないことを意味します。たとえば、コンパイラはこのプログラムで 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 をリロードできます。 *p の値が変更されている可能性があるため、Go コンパイラはそうしてはいけません。(代わりに、コンパイラは 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 プログラマーは、他のほとんどすべての最新のプログラミング言語と同様に、それらのプログラムの順次一貫性のある実行に依存できます。

競合状態のあるプログラムに関しては、プログラマーとコンパイラの両方がアドバイスを覚えておく必要があります。「賢くならないでください。」