xhs_am

xhs_am

「Go言語のメモリモデル」読書と要約

背景紹介:(見なくても大丈夫です、スキップしても構いません)


最近、オペレーティングシステムのコースを再学習しており、オペレーティングシステムにおける並行プログラムについて一定の理解を得ましたが、オペレーティングシステムのコースのコードはすべて C 言語に関するものでした。コースでは、オペレーティングシステムは結局のところ並行プログラムに過ぎない👍(例えば、並行性の正しさを保証するために条件変数やセマフォを提供しています)と言われており、この並行プログラムはほぼ C 文で書かれています。そこで私は非常に興味を持ちました。Go 言語が提供する並行プログラミングモデルはどのようなもので、並行性の正しさを保証するためにどのような並行原語を提供しているのでしょうか?それでは、Go のメモリモデルを見てみましょう。

この記事を書く前、私は心の中でずっと「jyy yyds!!!」と唱えていました。


本記事はほぼ Go 言語メモリモデルの翻訳版です。もし英語が得意であれば、ぜひ原文を直接読んでみることをお勧めします。翻訳版よりも確実に面白いです~

公式ドキュメントのリンク:go memory model

data race(データ競合)#

メモリ位置への書き込みと同じ位置への読み取りまたは書き込みが同時に発生する場合、sync/atomicパッケージでデータを保護し、アクセス操作を原子化しない限り、データ競合が発生します。

データ競合は、データ操作の順序を保証できないことが主な問題です。

例えば、goroutine1が変数 a に書き込みを行い、goroutine2が変数 a を読み取る場合です。

package main

import (
  "fmt"
  "time"
)

var a = 3

func g1() { // goroutine 1 実行
  a *= 2
  fmt.Println(a)
}

func g2() { // goroutine2 実行
  fmt.Println(a)
}

func main() {
  fmt.Println("init value: ", a)
  go g1()
  go g2()

  time.Sleep(time.Second)
}

もしgo build -race hello.goを使用すると、実行時にバイナリファイルが警告を出します:

WARNING: DATA RACE
Write at 0x000104d946d0 by goroutine 7:
  main.g1()
      hello.go:11 +0x3c

Previous read at 0x000104d946d0 by goroutine 8:
  main.g2()
      hello.go:16 +0x2c

Goroutine 7 (running) created at:
  main.main()
      hello.go:21 +0xa4

Goroutine 8 (finished) created at:
  main.main()
      hello.go:22 +0xb0
==================
6
Found 1 data race(s)

g1 が先に実行される可能性もあれば、g2 が先に実行される可能性もあります。この時、変数 a の結果は不確定になります。

Go はプログラマーに適切な同期を使用してデータ競合を避けることを推奨しています。データ競合がない場合、Go プログラムの動作はすべての Goroutine が 1 つのプロセッサ上で再利用されているかのように振る舞います。この動作は時々DRF-SC(データ競合のないプログラムが順序一貫性のある方法で実行される)と呼ばれます。データ競合のないプログラムは順序一貫性のある方法で実行されます。

プログラマーはデータ競合のない Go プログラムを書くべきですが、Go 言語はデータ競合に対して一定の制限を設けています。Go はデータ競合を報告することでプログラムを終了させることができます。さもなければ、Go は現在のメモリ位置に実際に書き込まれた値(この時、複数の goroutine が並行して書き込む可能性があります)を観察し、上書きされていないことを確認します。Go は Java や JS のようにデータ競合に制約を設けていますが、データ競合は常に有限であり、C や C++ のようにコンパイラが何でもできるわけではありません。

Go はデータ競合を診断し報告するためのツールを提供し、データ競合をエラーと見なしています。

メモリモデル#

Go 言語のメモリモデルの定義は、Hans-J. Boehm と Sarita V. Adve による「Foundations of the C++ Concurrency Memory Model」に続いています。これは PLDI 2008 で発表されました。

メモリモデルはプログラムの実行に対する要件を記述しており、プログラムの実行は goroutine の実行から成り立っており、goroutine はメモリ操作から成り立っています。
メモリ操作は 4 つの詳細によって模擬されます。

  • 種類:通常のデータ読み取り、通常のデータ書き込み、または同期操作(原子データアクセス、排他操作、またはチャネル操作など)を示します。
  • プログラム内の位置
  • アクセスされるメモリまたは変数の位置
  • 読み取られるまたは書き込まれる値

読み取りに関連するメモリ操作には、読み取り、原子読み取り、排他ロック、チャネルの送信および受信が含まれます。一部の操作は、読み取りと書き込みを同時に行います。例えば、原子的比較と交換(atomic compare and swap)「atomic CAS」です。

1 つの goroutine の実行は、メモリに対する一連の操作としてモデル化されます。

要件 1
各 goroutine 内のメモリ操作は、その goroutine の順序実行と一致しなければなりません。この実行順序は先行関係に従い、Go 言語が定める部分的順序関係および式の順序に従う必要があります。

要件 2
同期操作に制限される場合、与えられたプログラムの実行に対して、W(書き込み)のマッピングは、ある種の暗黙的な同期操作の全順序を通じて解釈されなければなりません。この順序は、一貫したメモリアクセスを保証する正しい順序を満たす必要があります。

並行プログラムを書く際には、共有メモリの読み書き操作が関与します。この場合、同じ変数への複数のアクセス間に正しい順序が存在することを保証する必要があります。2 つの goroutine が同期操作を介して happens-before の関係を確立していない場合、実行順序は未定義です。


したがって、並行プログラムを書く際には、あるマッピングがメモリ内の実際の状況を正しく反映できるようにし、読み書き操作は正しい順序で実行される必要があります。同期原語を使用して、共有変数へのアクセス順序が正しい順序で行われることを保証できます。

synchronized beforeは、W(書き込み)から派生した同期メモリ操作の偏序関係です。もし同期の読み取り操作 r が同期の書き込み操作 w を観測した場合、「つまり、w (r) = w であれば、書き込み操作は読み取りの前に同期されている」と言えます。簡単に言えば、synchronized beforeは同期の全順序の部分集合であり、同期操作の偏序であり、W が直接観測した結果に限られます。

happens before関係は、sequenced beforesynchronized beforeの結合関係として定義されます。

ある操作 A が別の操作 B の前に発生する場合、この関係は->記号で表されます。

例えば、以下のコード:

package main

import (
  "fmt"
)

var x, y int

func main() {
  go func() {
      y = 1
      fmt.Println(x)
  }()
  x = 1
  fmt.Println(y)
}

happens-before を次のように定義できます:

goroutine -> y = 1 -> fmt.Println(x)
main goroutine -> x = 1 -> fmt.Println(y)

次のコードのように:

package main

import (
  "fmt"
  "os"
  "sync"
  "time"

)

var x, y int
var mu sync.Mutex

func write_y() {
  mu.Lock()
  defer mu.Unlock()
  fmt.Fprintln(os.Stdout, "call func in main goroutine get lock")
  y = 1
  fmt.Fprintf(os.Stdout, "write y = %d, and x in func call: %d\n", y, x)
}

func main() {
  fmt.Fprintf(os.Stdout, "init ==> x: %d and y: %d\n", x, y)
  go func() {
      mu.Lock()
      defer mu.Unlock()
      x = 1
      fmt.Fprintln(os.Stdout, "g1 get lock")
      fmt.Fprintf(os.Stdout, "write x = %d and y in g1: %d\n", x, y)
  }()

  write_y()
  time.Sleep(time.Second)
  fmt.Println("x in main goroutine: ", x)
}

上記のコードを実行すると、結果は次のようになります:

$go run main.go

init ==> x: 0 and y: 0
call func in main goroutine get lock
write y = 1, and x in func call: 0
g1 get lock
write x = 1 and y in g1: 1
x in main goroutine:  1

まず、主 goroutine の関数呼び出しがロックを取得し、その後 y に書き込みを行います。この時、x = 0、y = 1 となり、その後 g1 がロックを取得し、x に書き込みを行います。x = 1 となります。

要件 3
普通のデータ x に対する読み取り操作(r)「非同期」の場合、W (r) は r に対して可視な書き込み w でなければなりません。可視な書き込みは以下の 2 つの条件が成立することを意味します:

  • w happens before r

  • w の前に r の前に他の x への書き込み操作 w' が存在しないことを保証します。

メモリ上の x に対する読み書き操作のデータ競合が発生した場合、少なくとも 1 つの未同期操作が存在し、happens-before を保証できません。

メモリ上の変数 x に対する操作にデータ競合や書き込みデータ競合が存在しない場合、x に対する任意の読み取りは唯一の W (r) を持ち、happens-before 順序の中で、その前にある唯一の w が続きます。

より一般的に言えば、任意の Go プログラムがデータ競合のないことは、プログラムに読み書きデータ競合や書き込みデータ競合が存在しないことを意味します。その結果は、いくつかの goroutine の実行順序によって保証される順序一貫性を持つことができます。つまり、プログラムの実行結果は唯一予測可能であり、順序一貫性モデルを満たします。

データ競合を含むプログラムの実装上の制限#

まず、データ競合が検出された場合、データ競合を報告し、プログラムの実行を停止できます。これは(go build -race)ThreadSanitizer を使用して実現できます。

メモリ位置 x の読み取りがマシンのワード長を超えない場合、この読み取り操作は以前の書き込みまたは同時に行われた書き込み操作を観察する必要があります。

具体的には、メモリ位置 x に対して、ある読み取り r が w の前に発生せず、かつ w の後に w' という書き込み操作が存在しない場合、この読み取り r は以前または同時に行われた書き込み操作によって生成された値を観察できる必要があります。

以下の状況は存在しません
case 1:
  r --> w
case 2:
  w --> w' --> r

因果関係のない観察や無中生有の書き込み操作を禁止します。

複数バイトメモリの読み取りおよび書き込み操作において、Go 言語は単一バイトメモリ位置操作と同じ意味を保証できない場合があります。つまり、この操作が原子的であるか、順序を持つかどうかなどです。

性能を向上させるために、Go 言語の実装は複数バイト操作を一組の個別のマシンワード操作として扱う可能性がありますが、順序は未指定です。

これは、複数バイトデータ構造の競合が異なる値を引き起こす可能性があることを意味します。これらの値は単一の書き込み操作に対応しない可能性があり、複数の goroutine が同時に同じメモリ位置に対して読み取りまたは書き込み操作を行う際に、一貫性のない結果が生じる可能性があります。

特に、インターフェース値、マップ、スライス、文字列などの特定のデータ型に対して、このような競合はメモリデータの破損を引き起こす可能性があります。

同期#

初期化 (init)#

すべての初期化操作は 1 つの goroutine 内で実行されますが、この goroutine は他の同時に実行される goroutine を作成する可能性があります。

もしパッケージ p がパッケージ q をインポートした場合、q の init 機能は p の操作が始まる前に実行されます。

main.main が始まる前に、すべての init 関数は同期を完了します。

goroutine の作成#

go文を使用して新しい goroutine を起動すると、この文は goroutine の実行前に同期を行います。

つまり、go 文の前のコードは新しい goroutine が実行される前に実行を完了します。この同期メカニズムは、goroutine を起動する前に、すべての必要な変数と状態が正しく初期化および設定されていることを保証し、競合条件や他の潜在的な問題を回避します。

var a string

func f() {
  print(a)
}

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

上記のコードでは、hello関数を呼び出すと、go f()の前に a が "hello, world" に設定されているため、f () 関数では "hello, world" が印刷されます。しかし、f () 関数は新しい goroutine 内で実行されるため、f () 関数の実行と hello () 関数の実行は並行しており、実行順序は未定義です。したがって、hello () 関数が返された後に "hello world" が印刷される可能性があります。

goroutine の破棄#

goroutine の終了は、プログラムの任意のイベントとの同期を保証するものではありません。例えば、以下のプログラムでは:

var a string

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

a への代入の後に同期イベントがないため、他の goroutine が観察できることは保証されません。実際、コンパイラは go 文全体を削除する可能性があります。もし goroutine の効果が他の goroutine に観察される必要がある場合は、ロックやチャネルなどの同期メカニズムを使用して相対的な順序を確立してください。

チャネル通信#

チャネル通信は goroutine 間の同期の主要な方法です。特定のチャネル上の各送信は、そのチャネルの対応する受信と一致し、通常は異なる goroutine 内で行われます。

👍 チャネル上の送信は、そのチャネルの対応する受信が完了する前に同期されます(同期は即時実行を意味しません)。

ある goroutine がチャネルにデータを送信すると、他の goroutine がそのチャネルからデータを受信しない限り、その goroutine はブロックされます。

受信操作が完了する前に、送信操作は完了しません。受信操作が完了した後にのみ、送信操作が成功し、チャネルのデータが受信者に取得されます。

同様に、チャネルからデータを受信する場合、受信操作も即時に完了するわけではなく、別の goroutine がそのチャネルにデータを送信するまでブロックされます。

つまり:送信と受信操作は同期的であり、相互に待機し、同期の関係があります。

この同期メカニズムはチャネルの特性の 1 つであり、チャネルの安全性を確保し、データ競合条件や他の並行問題を回避します。
例えば:

var c = make(chan int, 10)
var a string

func f() {
  a = "hello, world"
  c <- 0 // close(c)
}

func main() {
  go f()
  <-c
  print(a)
}

上記のコードは "hello, world" を印刷することを保証します。a への書き込みはチャネル c への送信の前に発生し、チャネル c への送信はチャネル c の受信が完了する前に同期され、チャネル c からデータを受信する操作は印刷の前に発生します。

a="hello, world" ==> c<-0 ==> <-c ==> print(a)

チャネルの閉鎖は受信操作の前に同期されます。この受信はチャネルの閉鎖によってゼロ値を返します。

Go 言語では、チャネルを閉じることと、閉じたチャネルからデータを受信することは同期操作です。チャネルが閉じられると、そのチャネル上の送信操作は panic を引き起こしますが、受信操作は引き続き実行できます。すべての値が受信されるまで続行されます。

閉じたチャネルからデータを受信すると、受信操作は即座にゼロ値を返します。このゼロ値はチャネル要素型のデフォルト値を表します。

チャネルを閉じる操作とゼロ値を返す受信操作は同期的です。

つまり、ある goroutine がチャネルから受信操作をブロックしているとき、チャネルが閉じられると、受信操作は即座にブロックを解除し、ゼロ値を返します。

上記の例では、c <- 0close(c)に変更すると同じ動作になります。

👍 バッファなしのチャネルの場合、チャネルからデータを受信する操作は、対応する送信操作の前に同期されます。

つまり、送信操作を実行する前に、受信データの操作が受信を待つ準備が整っています(同期の準備ができています)。

バッファなしのチャネルは、goroutine 間で同期し、データを伝達するメカニズムです。容量が 0 のため、送信操作と受信操作は同期的です。

ある goroutine がバッファなしのチャネルにデータを送信すると、別の goroutine がそのバッファなしのチャネルからデータを受信するまでブロックされます。

同様に、ある goroutine がバッファなしのチャネルからデータを受信すると、別の goroutine がそのチャネルにデータを送信するまでブロックされます。

この場合、受信操作と送信操作の間は同期的です。

上記のコードをバッファなしのチャネルに書き換えると、次のようになります。

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 への書き込みはチャネルから受信する前に発生し、チャネルからの受信はチャネルにデータを送信する前に同期され、送信操作は印刷の前に行われます。

a = "hello, world" ==> c <- 0 ==> <-c ==> print(a)  

しかし、チャネルがバッファ付きの場合、「例えばc = make(chan int, 1)」プログラムは "hello, world" を印刷することを保証できません。

👍 バッファ付きチャネルの場合、容量が C であれば、ある goroutine が k 回目にそのチャネルからデータを受信すると、k+C 回目の送信操作と同期されます。

つまり、k 回目の受信操作は、k+C 回目の送信操作が完了するまで完了(ブロック)しません。

バッファ付きチャネルの特性は、カウンティングセマフォ(counter semaphore)を実現できます。

🎈チャネル内の要素数はアクティブな数に対応し、チャネルの容量は同時に使用される最大数に対応します。チャネルに要素を送信することは、信号を取得すること(P 操作、信号量の取得を待つ)に相当し、チャネルから要素を受信することは、信号を解放すること(V 操作、信号量を解放する)に相当します。チャネル内の要素数が容量に達すると、新しい送信操作はブロックされ、他の受信操作がいくつかのスペースを解放するまで待機します。これにより、同時に実行される操作の数がチャネルの容量を超えないように制限され、並行性が制限されます。これは一般的な並行プログラミングパターンです。

このプログラムは、作業リスト内の各エントリに対して 1 つの goroutine を起動しますが、これらの goroutine は limit という名前のチャネルを使用して調整し、同時に最大 3 つの goroutine のみが実行されることを保証します。

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またはsunc.RWMutexの変数 l と n < m に対して、n のl.Unlock()の呼び出しは m のl.Lock()の呼び出しの前に同期されます。

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 内)は、2 番目のl.Lock()の呼び出し(main 関数内)が戻る前に同期され、印刷操作の前に実行されます。

任意のsync.RWMutex変数 l に対するl.RLockの呼び出しには n があり、l.Unlockの n 回目の呼び出しはl.RLockの戻り前に同期され、l.RUnlockの対応する呼び出しはl.Lockの n+1 回目の呼び出しの戻り前に同期されます。

l.TryLock(またはl.TryRLock)の成功した呼び出しは、l.Lock(またはl.RLock)の呼び出しと同等です。ロックの取得を試みて失敗した場合、同期効果はなく、false が返されます。メモリモデルの観点から、l.TryLock(またはl.TryRLock)は軽量な特性を持ち、ロックがロックされていない場合に false を返すことができ、プログラムの並行性能を向上させることができます。

Once#

syncパッケージは、Once型を使用して、複数の goroutine が存在する場合の初期化に安全なメカニズムを提供します。複数のスレッドが特定の f に対してonce.Do(f)を実行できますが、実行されるのは 1 つのスレッドだけで、他の呼び出しは f () が返されるまでブロックされます。

sync.OnceDoメソッドを呼び出すと、同じsync.Onceインスタンスと同じ関数 f に対して、Do (f) メソッドの呼び出しは、以前の呼び出しが完了するのを待ってから続行されます。

var a string
var once sync.Once

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

func doprint() {
  once.Do(setup)
  print(a)
}

func kprint(k int) {
  for i:=0; i < k; i++ {
    go doprint()
  }
}

kprint を呼び出すと、setup は 1 回だけ呼び出され、print の前に setup 関数が完了します。その結果、"hello, world" が k 回印刷されます。

原子値#

sync/atomicパッケージの API は「原子操作」と呼ばれ、異なる goroutine の実行を同期するために使用できます。原子操作 A の効果が原子操作 B によって観察される場合、A は B の前に同期されます。

この定義は、C++ の順序付き原子および Java のvolatile変数と同じ意味を持ちます。

ファイナライザ#

runtimeパッケージは、オブジェクトがプログラムによってもはや参照されなくなったときに呼び出されるファイナライザを追加するSetFinalizer関数を提供します。SetFinalizer(x, f)の呼び出しは、f(x)が終了する前に同期的に行われます。

Go 言語のメモリモデルにおけるファイナライザは、オブジェクトがガベージコレクションされる際に特定のクリーンアップ操作を実行するためのメカニズムを指します。

具体的には、Go 言語では、オブジェクトが到達不可能になると、ガベージコレクタによって回収されます。このオブジェクトがファイナライザ関数を定義している場合、ガベージコレクタはこのオブジェクトを回収する前にこのファイナライザ関数を呼び出してクリーンアップ操作を行います。

ファイナライザ関数は、オブジェクトを引数として受け取り、値を返さないメソッドとして定義できます。この関数は通常、ファイルディスクリプタのクローズやネットワーク接続の解放など、リソースを解放するために使用されます。

ファイナライザ関数の実行時間は不確定であることに注意が必要です。なぜなら、それはガベージコレクタの動作メカニズムに依存するからです。また、ファイナライザ関数はガベージコレクタの性能に影響を与える可能性があります。なぜなら、ガベージコレクタがオブジェクトを回収する前に実行される必要があるからです。

したがって、ファイナライザ関数を使用する際には注意が必要であり、性能に影響を与えないようにし、ファイナライザ関数が他のメモリ管理の問題(例えばメモリリークなど)を引き起こさないことを確認する必要があります。

その他のメカニズム#

syncパッケージは、条件変数(condition variables)、ロックフリーのマップ(lock-free maps)、アロケーションプール(allocation-pools)、待機グループ(wait groups)など、追加の同期抽象を提供します。各パッケージのドキュメントには、同期に関する保証が記載されています。

不正な同期#

データ競合のあるプログラムは不正であり、非順序一貫性を示します。特に注意すべきは、読み取り操作 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()
}

上記のコードの実行結果は不確定であり、"00" が印刷される可能性もあれば、"01" が印刷される可能性もあります。

二重チェックは、同期化のオーバーヘッドを避けるために行われます。例えば、kprintプログラムは誤って次のように書かれる可能性があります:

var a string
var done bool

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

func doprint() {
  if !done {
    once.Do(setup)
  }
  print(a)
}

func kprint(k int) {
  for i := 0; i < k; i++ {
    go doprint()
  }
}

上記のコードは、doprint内で done と変数 a への書き込み操作が同時に観察されることを保証できませんが、doprint 関数内で done への書き込み操作を観察したからといって、a への書き込み操作を観察したことを意味するわけではありません。したがって、このバージョンは空の文字列を誤って印刷する可能性があり、"hello, world" ではありません。

一般的に、コンパイラと CPU はプログラムを最適化して偏序操作を行い、多スレッド環境ではスレッドの実行順序が不確定であるため、1 つの命令が複数の命令に分割される可能性があります。したがって、プログラミング言語のレベルでは 1 つの操作に見えるものが、コンパイラや CPU によって命令が分割される可能性があるため、並行プログラムを書く際には、適切な同期メカニズムを使用してプログラムの正確性を保証することが重要です。

決して小賢しいことをせず、コードを次のように書き換えないでください。

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)
}

たとえ main が g != nil を観察し、そのループを抜けたとしても、g.msg の初期値を観察できる保証はありません。

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

不正なコンパイル#

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

つまり、マルチスレッド(複数の goroutine)が存在する場合、コンパイラは単一スレッドのようにプログラムを最適化することはありません。

このルールの目的は、複数の goroutine 間で共有変数の正確性と予測可能性を確保することです。コンパイラが最適化を許可すると、共有変数の値が不一致になる可能性があるため、読み書き操作の実行順序が不確定であるため、コンパイラはルールを遵守する必要があります。

以下のすべての例は、*p*qが複数の goroutine がアクセスできるメモリ位置を指していると仮定しています。

🎈 データ競合のないプログラムを書く際には、データ競合を避けるために、書き込み操作を条件文の外に移動させてはいけません。

例えば、コンパイラはこのプログラム内の条件を反転させることはできません。

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

つまり、コンパイラはプログラムを次のように書き換えることはできません。

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

条件が成立せず、別の goroutine がp を読み取る場合、元のプログラムでは別の goroutine は以前のp の値が 1 であることしか観察できませんが、書き換え後のプログラムでは、別の goroutine が 2 を観察できる可能性があります。これは元のプログラムでは不可能です。

🎈 データ競合を引き起こさないことは、ループが終了することを仮定しないことも意味します。

例えば、一般的に、コンパイラはこのプログラム内のループの前に対して*pまたは*qへのアクセスを移動させることはできません。

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

もしlistが循環リストを指している場合、元のプログラムは決して*pまたは*qにアクセスしませんが、書き換え後のプログラムはそれらにアクセスします。(もしコンパイラが*pが panic を引き起こさないことを証明できれば、*pを前に移動させることは安全です。*qを前に移動させるためには、他の goroutine が*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]()の前にちょうど再読み込みすることができます。しかし、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 プログラマーは、これらのプログラムの順序一貫性のある実行に依存できます。ほとんどすべての他の現代のプログラミング言語と同様です。

データ競合のあるプログラムを扱う際、プログラマーとコンパイラはこのアドバイスを覚えておくべきです:小賢しいことをしないこと

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。