2022年6月6日のバージョン
はじめに
Goのメモリモデルは、あるゴルーチンでの変数の読み取りが、別のゴルーチンでの同じ変数への書き込みによって生成された値を観察する条件を指定します。
アドバイス
複数のゴルーチンによって同時にアクセスされるデータを変更するプログラムは、そのアクセスを直列化する必要があります。
アクセスを直列化するには、データをチャネル操作や、sync
やsync/atomic
パッケージのような他の同期プリミティブで保護してください。
プログラムの動作を理解するためにこの文書の残りを読む必要がある場合、あなたはあまりにも賢すぎます。
非公式の概要
Goは、言語の他の部分と同様にメモリモデルにアプローチし、意味論をシンプルで理解しやすく、役立つものに保つことを目指しています。このセクションでは、アプローチの一般的な概要を示し、ほとんどのプログラマーにとって十分であるべきです。メモリモデルは次のセクションでより正式に指定されています。
データ競合は、すべてのアクセスがsync/atomic
パッケージによって提供される原子データアクセスでない限り、同じメモリ位置への書き込みが他の読み取りまたは書き込みと同時に発生することとして定義されます。すでに述べたように、プログラマーはデータ競合を避けるために適切な同期を使用することを強く推奨されます。データ競合がない場合、Goプログラムはすべてのゴルーチンが単一のプロセッサに多重化されているかのように動作します。この特性は時々DRF-SCと呼ばれます:データ競合のないプログラムは逐次的に一貫した方法で実行されます。
プログラマーはデータ競合のないGoプログラムを書くべきですが、Goの実装がデータ競合に応じてできることには限界があります。実装は、データ競合を検出した場合、競合を報告し、プログラムを終了することができます。そうでなければ、単一の単語サイズまたはサブ単語サイズのメモリ位置の各読み取りは、その位置に実際に書き込まれた値(おそらく同時に実行されているゴルーチンによって)を観察し、まだ上書きされていない必要があります。これらの実装制約により、GoはJavaやJavaScriptに似ており、ほとんどの競合には限られた数の結果があり、CやC++のように競合のあるプログラムの意味が完全に未定義であり、コンパイラが何でもできるわけではありません。Goのアプローチは、誤ったプログラムをより信頼性が高く、デバッグしやすくすることを目指しながら、競合はエラーであり、ツールがそれを診断し報告できることを主張しています。
メモリモデル
Goのメモリモデルの以下の正式な定義は、PLDI 2008で発表された「C++の同時メモリモデルの基礎」でHans-J. BoehmとSarita V. Adveによって提示されたアプローチに密接に従っています。データ競合のないプログラムの定義と、競合のないプログラムに対する逐次的一貫性の保証は、その研究のものと同等です。
メモリモデルは、ゴルーチンの実行から構成されるプログラム実行に対する要件を記述します。
メモリ操作は、次の4つの詳細によってモデル化されます:
- その種類、通常のデータ読み取り、通常のデータ書き込み、または原子データアクセス、ミューテックス操作、またはチャネル操作などの同期操作であるかを示します。
- プログラム内のその位置、
- アクセスされるメモリ位置または変数、
操作によって読み取られたまたは書き込まれた値。
一部のメモリ操作は読み取りのようなものであり、読み取り、原子読み取り、ミューテックスロック、チャネル受信が含まれます。他のメモリ操作は書き込みのようなものであり、書き込み、原子書き込み、ミューテックスアンロック、チャネル送信、チャネルクローズが含まれます。原子比較とスワップのようなものは、読み取りのようでもあり、書き込みのようでもあります。
ゴルーチン実行は、単一のゴルーチンによって実行されるメモリ操作のセットとしてモデル化されます。
要件1:各ゴルーチン内のメモリ操作は、メモリから読み取られた値と書き込まれた値を考慮して、そのゴルーチンの正しい逐次実行に対応しなければなりません。その実行は、Go言語仕様で定義された前に順序付けられた関係と、式の評価順序と一貫している必要があります。
Goのプログラム実行は、ゴルーチン実行のセットとしてモデル化され、各読み取りのような操作がどの書き込みのような操作から読み取るかを指定するマッピングWが含まれます。(同じプログラムの複数の実行は異なるプログラム実行を持つことができます。)
要件2:特定のプログラム実行に対して、同期操作に制限されたマッピングWは、順序付けとそれらの操作によって読み取られたおよび書き込まれた値と一貫した、同期操作のいくつかの暗黙の全順序によって説明可能でなければなりません。
同期前関係は、Wから導出された同期メモリ操作の部分的な順序です。同期読み取りのようなメモリ操作rが同期書き込みのようなメモリ操作wを観察する場合(すなわち、W(r) = wの場合)、wはrの前に同期されます。非公式には、同期前関係は前の段落で言及された暗黙の全順序のサブセットであり、Wが直接観察する情報に制限されています。
発生前関係は、順序付けられた前と同期前関係の和の推移的閉包として定義されます。
要件3:メモリ位置xに対する通常の(非同期)データ読み取りrに対して、W(r)はrに対して可視な書き込みwでなければなりません。可視とは、次の2つの条件が両方とも成り立つことを意味します:
1. wはrの前に発生します。
2. wは、rの前に発生する他の書き込みw’の前に発生しません。
メモリ位置xに対する読み取り-書き込みデータ競合は、xに対する読み取りのようなメモリ操作rと書き込みのようなメモリ操作wから構成され、少なくとも1つは非同期であり、発生前によって順序付けられていません(すなわち、rはwの前に発生せず、wはr*の前に発生しません)。
メモリ位置xに対する書き込み-書き込みデータ競合は、xに対する2つの書き込みのようなメモリ操作wとw’から構成され、少なくとも1つは非同期であり、発生前によって順序付けられていません。
メモリ位置xに対して読み取り-書き込みまたは書き込み-書き込みデータ競合がない場合、xに対する任意の読み取りrは、発生前の順序でそれに直前の単一のwのみを持つ可能性があります。
より一般的には、データ競合のないGoプログラムは、すなわち読み取り-書き込みまたは書き込み-書き込みデータ競合を持たないプログラムは、ゴルーチン実行の逐次的一貫したインタリーブによって説明される結果のみを持つことが示されます。(証明は、上記のBoehmとAdveの論文のセクション7と同じです。)この特性はDRF-SCと呼ばれます。
正式な定義の意図は、C、C++、Java、JavaScript、Rust、Swiftなどの他の言語によって競合のないプログラムに提供されるDRF-SC保証と一致させることです。
ゴルーチンの作成やメモリの割り当てなどの特定のGo言語操作は、同期操作として機能します。これらの操作が同期前の部分的な順序に与える影響は、以下の「同期」セクションに記載されています。個々のパッケージは、自分の操作に対して同様の文書を提供する責任があります。
データ競合を含むプログラムの実装制限
前のセクションでは、データ競合のないプログラム実行の正式な定義を示しました。このセクションでは、データ競合を含むプログラムに対して実装が提供しなければならない意味論を非公式に説明します。
どの実装も、データ競合を検出した場合、競合を報告し、プログラムの実行を停止することができます。ThreadSanitizerを使用する実装(「go
build
-race
」でアクセス)は、まさにこれを行います。
配列、構造体、または複素数の読み取りは、各個別のサブ値(配列要素、構造体フィールド、または実数/虚数成分)の読み取りとして、任意の順序で実装される可能性があります。同様に、配列、構造体、または複素数の書き込みは、各個別のサブ値の書き込みとして、任意の順序で実装される可能性があります。
メモリ位置xに保持されている値がマシンワードより大きくない場合の読み取りrは、rがwの前に発生せず、wがw’の前に発生しないような書き込みwを観察しなければなりません。すなわち、各読み取りは、前または同時の書き込みによって書き込まれた値を観察しなければなりません。
さらに、因果関係のない書き込みや「空から出てきた」書き込みの観察は許可されていません。
単一のマシンワードより大きいメモリ位置の読み取りは、単一の許可された書き込みwを観察するという同じ意味論を満たすことが奨励されますが、必須ではありません。パフォーマンスの理由から、実装は代わりに大きな操作を未指定の順序での個々のマシンワードサイズの操作のセットとして扱うことができます。これは、マルチワードデータ構造に対する競合が、単一の書き込みに対応しない不整合な値をもたらす可能性があることを意味します。値が内部(ポインタ、長さ)または(ポインタ、型)ペアの一貫性に依存する場合、ほとんどのGo実装におけるインターフェース値、マップ、スライス、文字列などのように、そのような競合は恣意的なメモリ破損を引き起こす可能性があります。
不正な同期の例は、以下の「不正な同期」セクションに示されています。
実装の制限に関する例は、以下の「不正なコンパイル」セクションに示されています。
同期
初期化
プログラムの初期化は単一のゴルーチンで実行されますが、そのゴルーチンは他のゴルーチンを作成することができ、それらは同時に実行されます。
パッケージp
がパッケージq
をインポートする場合、q
のinit
関数の完了は、p
のいずれかの開始の前に発生します。
すべてのinit
関数の完了は、main.main
関数の開始の前に同期されます。
ゴルーチンの作成
新しいゴルーチンを開始するgo
文は、ゴルーチンの実行の開始の前に同期されます。
たとえば、このプログラムでは:
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
hello
を呼び出すと、"hello, world"
が将来のある時点で印刷されることが保証されます(おそらくhello
が返された後)。
ゴルーチンの破棄
ゴルーチンの終了は、プログラム内の任意のイベントの前に同期されることは保証されていません。たとえば、このプログラムでは:
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
ゴルーチンの効果が他のゴルーチンによって観察される必要がある場合は、ロックやチャネル通信などの同期メカニズムを使用して相対的な順序を確立してください。
<a name="chan"></a>
### チャネル通信
チャネル通信は、ゴルーチン間の同期の主な方法です。特定のチャネルへの各送信は、そのチャネルからの対応する受信にマッチし、通常は異なるゴルーチンで行われます。
チャネルへの送信は、そのチャネルからの対応する受信の完了の前に同期されます。
このプログラム:
``````bash
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
への送信は、c
での対応する受信の完了の前に同期され、print
の前に順序付けられます。
チャネルのクローズは、チャネルが閉じたためにゼロ値を返す受信の前に同期されます。
前の例では、c <- 0
をclose(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
での受信は、c
への対応する送信の完了の前に同期され、print
の前に順序付けられます。
チャネルがバッファ付きであった場合(例:c = make(chan int, 1)
)、プログラムが"hello, world"
を印刷することは保証されません。(空の文字列を印刷するか、クラッシュするか、他の何かをするかもしれません。)
k番目の受信は、容量Cのチャネルに対して、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.Mutex`````または`````sync.RWMutex`````変数`````l`````に対して、*n* < *m*の場合、`````l.Unlock()`````の*n*回の呼び出しは、`````l.Lock()`````の*m*回の呼び出しが返る前に同期されます。
このプログラム:
``````bash
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
の前に順序付けられます。
任意のl.RLock
への呼び出しは、sync.RWMutex
変数l
に対して、nが存在し、n回目のl.Unlock
への呼び出しは、l.RLock
からの戻りの前に同期され、対応するl.RUnlock
への呼び出しは、呼び出しn+1のl.Lock
からの戻りの前に同期されます。
<a name="once"></a>
### 一度だけ
`````sync`````パッケージは、`````Once`````型を使用して、複数のゴルーチンが存在する場合の初期化のための安全なメカニズムを提供します。複数のスレッドが特定の`````f`````に対して`````once.Do(f)`````を実行できますが、1つだけが`````f()`````を実行し、他の呼び出しは`````f()`````が返るまでブロックされます。
`````once.Do(f)`````からの`````f()`````の単一の呼び出しの完了は、`````once.Do(f)`````の任意の呼び出しの戻りの前に同期されます。
このプログラム:
``````bash
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によって観察される場合、AはBの前に同期されます。プログラム内で実行されるすべての原子操作は、いくつかの逐次的一貫した順序で実行されたかのように振る舞います。
前述の定義は、C++の逐次的一貫した原子およびJavaのvolatile
変数と同じ意味論を持ちます。
ファイナライザ
runtime
パッケージは、特定のオブジェクトがプログラムによってもはや到達できなくなったときに呼び出されるファイナライザを追加するSetFinalizer
関数を提供します。SetFinalizer(x, f)
への呼び出しは、ファイナライザ呼び出しf(x)
の前に同期されます。
追加のメカニズム
同期抽象を提供する他のパッケージも、提供する保証を文書化する必要があります。
<a name="badsync"></a>
## 不正な同期
競合のあるプログラムは不正であり、非逐次的一貫した実行を示す可能性があります。特に、読み取り*r*は、*r*と同時に実行される任意の書き込み*w*によって書き込まれた値を観察する可能性があります。これが発生しても、*r*の後に発生する読み取りが*w*の前に発生した書き込みを観察することを意味するわけではありません。
このプログラムでは:
``````bash
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
`
この事実は、いくつかの一般的なイディオムを無効にします。
ダブルチェックロッキングは、同期のオーバーヘッドを回避しようとする試みです。たとえば、`````twoprint`````プログラムは次のように不正に書かれる可能性があります:
``````bash
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
への書き込みを観察することを意味する保証はありませんので、このプログラムも空の文字列を印刷する可能性があります。さらに悪いことに、done
への書き込みがmain
によって観察されることが保証されていないため、2つのスレッド間に同期イベントがありません。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)
}
main
がg != nil
を観察し、そのループを終了しても、g.msg
の初期化された値を観察する保証はありません。
これらのすべての例において、解決策は同じです:明示的な同期を使用してください。
不正なコンパイル
Goのメモリモデルは、コンパイラの最適化をGoプログラムと同様に制限します。単一スレッドプログラムで有効なコンパイラの最適化のいくつかは、すべてのGoプログラムでは有効ではありません。特に、コンパイラは、元のプログラムに存在しない書き込みを導入してはならず、単一の読み取りが複数の値を観察することを許可してはならず、単一の書き込みが複数の値を書き込むことを許可してはなりません。
以下のすべての例は、*p
と*q
が複数のゴルーチンにアクセス可能なメモリ位置を指すことを前提としています。
競合のないプログラムにデータ競合を導入しないことは、書き込みをそれが現れる条件文から移動しないことを意味します。たとえば、コンパイラはこのプログラムの条件を反転させてはなりません:
*p = 1
if cond {
*p = 2
}
すなわち、コンパイラはプログラムを次のように書き換えてはなりません:
*p = 2
if !cond {
*p = 1
}
データ競合を導入しないことは、ループが終了することを仮定しないことも意味します。たとえば、コンパイラは一般的にこのプログラムのループの前に`````*p`````または`````*q`````へのアクセスを移動してはなりません:
``````bash
n := 0
for e := list; e != nil; e = e.next {
n++
}
i := *p
*q = 1
`
データ競合を導入しないことは、呼び出された関数が常に戻るか、同期操作がないことを仮定しないことも意味します。たとえば、コンパイラはこのプログラムの関数呼び出しの前に`````*p`````または`````*q`````へのアクセスを移動してはなりません(少なくとも`````f`````の正確な動作を直接知っていない限り):
``````bash
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
を保存せずに破棄し、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プログラマーは、ほぼすべての他の現代のプログラミング言語と同様に、これらのプログラムの逐次的一貫した実行を信頼できます。
競合のあるプログラムに関しては、プログラマーとコンパイラの両方が次のアドバイスを覚えておくべきです:賢くならないでください。