クイック検索

Go言語のメモリモデル

イントロダクション

Go言語のメモリモデルは、goroutineでの変数の更新結果が別のgoroutineでの同一変数の参照に認識される保証の条件を示します。

Happens-Before

単一のgoroutine内での参照および更新は、プログラムによって指定された順序通りに実行されるよう振る舞うでしょう。参照および更新の並べ替えが言語仕様で定義された挙動を変えない限りにおいては、コンパイラとプロセッサは単一のgoroutine内での並べ替えをおこなっても構いません。この並べ替えによって、goroutineが認識している実行順序が別のgoroutineが認識する順序と異なっていてもいいのです。例えば、goroutineが a = 1; b = 2; を実行する場合、別のgoroutineが a の前に b の更新結果を認識するかもしれません。

参照および更新の要求仕様を示すため、Go言語プログラムでのメモリ操作実行に関する部分的な順序関係、 happens-before を定義します。イベント e 1がイベント e 2より前に発生する場合、 e 2は e 1の後に発生すると言えます。また、 e 1が e 2の前に発生せず、 e 2の後に発生しない場合、 e 1と e 2は同時に発生すると言えます。

単一のgoroutine内では、このhappens-beforeの順序関係はプログラムで記述された通りとなります。

変数 v の更新 w を参照 r認識できる のは、以下の2つが成り立つ場合です。

  1. wr の前に発生する
  2. w の後、 r の前に発生するような、 v に対する別の更新 w’ が無い

変数 v の参照 r が特定の更新 w を認識することを保証するためには、 r が認識できる唯一の更新が w であることを確実にする必要があります。つまり、 rw を認識することが保証されるのは、以下の2つが成り立つ場合です。

  1. wr の前に発生する
  2. 共有変数 v への他の更新すべてが、 w の前に発生するか r の後に発生する

このペアの条件は最初のペアより優先されます。 w あるいは r と同時に発生する他の更新が無いことが必要です。

単一のgoroutine内では並列処理は無いため、この2つの定義は同じ意味になります。参照 rv に対する最新の更新 w の値を認識します。複数のgoroutineが共有変数 v にアクセスする場合、必ず同期イベントを使用して、望ましい更新結果を参照が確実に認識するよう「happens-before」の条件を成立させます。

変数 v をその型に合わせたゼロ値に初期化する処理は、メモリモデルの中では更新として振舞います。

そのマシンの1ワードより大きい値の参照および更新は、順不同の複数のマシンワードサイズの処理として振舞います。

同期

初期化処理

プログラムの初期化処理は単一のgoroutineで実行され、初期化処理が完了するまでは初期化処理中に生成された新しいgoroutineの実行は開始されません。

パッケージ p がパッケージ q をインポートする場合、 q init 関数の完了は p init 関数すべての開始の前に発生します。

関数 main.main の開始は、すべての init 関数の完了後に発生します。

init 関数実行中に生成されたすべてのgoroutineの実行は、すべての init 関数の完了後に発生します。

goroutineの生成

新たなgoroutineを開始する go ステートメントは、goroutineの実行の開始前に発生します。

var a string;

func f() {
    print(a);
}

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

例えばこのプログラムでは、 hello 関数の呼び出しは、未来のある時点(おそらく hello 関数から戻った後)で "hello, world" を印字します。

チャンネル通信

チャンネル通信はgoroutine間の同期のメインの手段です。特定チャンネル上のそれぞれの送信は、(通常は異なるgoroutineでの)該当チャンネルからの対応する受信とマッチします。

チャンネル上の送信は、該当チャンネルからの対応する受信が完了する前に発生します。

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 実行前に発生します。

バッファリング無しのチャンネルからの受信は、該当チャンネル上の送信完了前に発生します。

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" の印字を保証しないでしょう("hello, sailor" と印字したりクラッシュすることはありませんが、空文字列を印字するでしょう)。

ロック

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

sync.Mutex あるいは sync.RWMutex のすべての変数 l に対して、n < mが成り立つ場合に、 l.Unlock() のn回目の呼び出しは、 l.Unlock() の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" の印字を保証します。関数 fl.Unlock() の最初の呼び出しは、関数 mainl.Lock() の2回目の呼び出しのリターン前に発生します。その後、 print が実行されます。

sync.RWMutex の変数 l に対するすべての l.RLock の呼び出しについて、 l.RLock がn回目の l.Unlock の呼び出しの後に発生(つまりリターン)し、それと対応する l.RUnlock はn+1回目の l.Lock の呼び出しの前に発生します。

Once

once パッケージは複数goroutine環境下での初期化処理の安全なメカニズムを提供します。複数のスレッドは特定の関数 f に対する once.Do(f) を実行できますが、実際に f() を実行するのは唯一の呼び出しだけであり、他は f() のリターンまでブロックされます。

once.Do(f) からの f() の1回だけの呼び出しは、すべての once.Do(f) 呼び出しのリターンの前に発生(リターン)します。

var a string

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

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

func twoprint() {
    go doprint();
    go doprint();
}

このプログラムでは、 twoprint 呼び出しにより "hello, world" が2回印字されます。 twoprint の最初の呼び出しが、 setup を1回だけ実行します。

不正な同期

参照 r と同時に発生した更新 w によって書き込まれた値を、参照 r が認識するかもしれないことに注意してください。これが起こったとしても、必ずしも 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 を印字する事態が起こり得ます。

この事実は共通のイディオムのいくつかを無効にします。

2重チェックのロックは、同期のオーバヘッドを回避する試みです。例えば 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" の代わりに(不正に)空文字列を印字する可能性があります。

もう1つの不正なイディオムは、値に対するビジーウェイトです。

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つのスレッド間で同期イベントが無いため、関数 maindone 更新を認識する保証はありません。関数 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 の初期化された値が認識される保証はありません。

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