Day 035 — メモリモデルとhappens-before関係

2026-09-01 🟡 上級者 / Phase 3 概念理解 メモリモデルとhappens-before関係

📚 背景知識(読んでから問題へ)

Day 034ではgo test -raceが「実際に起きたデータ競合」をどう検出するかを体験しました。しかし-raceは動的解析ツールであり、「そもそもなぜ同期なしのメモリアクセスが未定義動作になるのか」という理論的根拠までは教えてくれません。その理論的根拠を定義しているのが The Go Memory Model が規定するhappens-before関係です。

Go Memory Modelの核心は次の一文に集約されます。

📌
ある goroutine の書き込み W が、別の goroutine の読み込み R から確実に観測されるためには、W と R の間に happens-before 関係(W → R)が成立していなければならない。

ここで注意すべきは、happens-beforeは「実際に時間的に先に実行される」という意味ではないという点です。これはコンパイラやCPUが命令を並べ替えてよい範囲を制限し、可視性を保証する抽象的な順序関係です。単一goroutine内では、プログラムに書かれた順序(sequenced before)がそのままhappens-beforeになります。しかしgoroutineをまたぐ場合、happens-before関係は明示的な同期プリミティブを使わない限り一切成立しません。これが「goroutineを2つ起動して片方が書いた変数をもう片方が読むだけ」のコードが、たとえ-raceが偶然何も報告しなくても理論上は未定義動作である理由です。

Go 1.19(2022年)でメモリモデルはC/C++のメモリモデルに正式に整合する形で明文化され、sync/atomicパッケージの操作にも明確なhappens-before規則が定義されました。Day 035では、Go Memory Modelが保証する主要な同期プリミティブごとのhappens-beforeルールを正確に整理し、「-raceが黙っていても壊れているコード」を見分けられるようになることを目標にします。

📝 問題

以下は、2つのgoroutine(AとB)が共有変数dataと何らかの「合図」用の変数・プリミティブを使ってやり取りする7つのパターンです。goroutine Aがdataに書き込んだ後、goroutine Bがその書き込みを必ず観測できる(happens-before関係が成立している)かどうかを、それぞれ判定してください。

var data int
var mu sync.Mutex
var once sync.Once
var wg sync.WaitGroup
var flagPlain bool
var flagAtomic atomic.Bool
ch := make(chan struct{})
chBuf := make(chan struct{}, 1)

// (A) goroutine起動のみ(同期なし)
data = 1
go func() {
    fmt.Println(data) // 観測できるか?
}()

// (B) 素朴なbool変数によるスピン待ち
go func() {
    data = 2
    flagPlain = true // 合図
}()
go func() {
    for !flagPlain { // スピン待ち
    }
    fmt.Println(data) // 観測できるか?
}()

// (C) atomic.Boolによるスピン待ち
go func() {
    data = 3
    flagAtomic.Store(true)
}()
go func() {
    for !flagAtomic.Load() {
    }
    fmt.Println(data) // 観測できるか?
}()

// (D) unbuffered channelの送受信
go func() {
    data = 4
    ch <- struct{}{}
}()
go func() {
    <-ch
    fmt.Println(data) // 観測できるか?
}()

// (E) sync.Mutex
go func() {
    mu.Lock()
    data = 5
    mu.Unlock()
}()
go func() {
    mu.Lock()
    fmt.Println(data) // 観測できるか?
    mu.Unlock()
}()

// (F) sync.WaitGroup
wg.Add(1)
go func() {
    data = 6
    wg.Done()
}()
go func() {
    wg.Wait()
    fmt.Println(data) // 観測できるか?
}()

// (G) goroutineの終了だけを待つ(同期プリミティブなし)
go func() {
    data = 7
    // goroutine終了、明示的な合図なし
}()
time.Sleep(1 * time.Second) // 「十分待てば終わっているはず」という期待
fmt.Println(data) // 観測できるか?

要件

  1. (A)〜(G) それぞれについて「happens-before関係が成立するか(Yes/No)」と、成立する場合はGo Memory Modelのどのルールに基づくかを説明する
  2. 特に(B)と(C)の違いを説明する。両者は見た目がほぼ同じ「スピン待ち」だが、一方は未定義動作でもう一方は正しい。何が違うのか?
  3. (G)が危険な理由を、time.Sleepをどれだけ長くしても解決しない根本的な理由とともに説明する
  4. (B)のパターンをgo test -raceで検出できるテストコードを書き、実際に競合が報告される条件を示す

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Day 034で扱ったのは「同期なしの整数インクリメント」という分かりやすい競合でしたが、今回の(B)と(G)は一見「動きそう」に見えるコードです。実際、多くの環境で(B)は「たまたま」正しく動いてしまいます。重要なのは「動いた/動かなかった」という経験的事実ではなく、言語仕様(メモリモデル)が可視性を保証しているかどうかという規範的な問いです。

ヒント2 — アプローチ

Go Memory Modelが規定する主要なhappens-beforeルールは以下の通りです。

  • goroutine作成: go文の実行は、起動されるgoroutineの開始よりhappens-before
  • channel通信: 送信は対応する受信の完了よりhappens-before(bufferedの場合は容量Cに応じてk番目の受信がk+C番目の送信よりhappens-before、という規則もある)。close(ch)はそれによって完了する受信よりhappens-before
  • sync.Mutex/RWMutex: n回目のUnlock()は、n<mであるm回目のLock()の完了よりhappens-before
  • sync.Once: once.Do(f)内で実行されるf()は、他のすべてのonce.Do(f)呼び出しの完了よりhappens-before
  • sync/atomic(Go 1.19〜で明文化): あるatomic操作の効果が別のatomic操作から観測された場合、前者は後者よりhappens-before
  • プレーンな変数(boolなど)への読み書きにはこうした規則が一切存在しない。コンパイラは「ループ内で変化しない変数」と判断してループ自体を最適化除去する権利すら持つ
ヒント3 — コード骨格

判定表のフォーマット例:

パターンhappens-before成立?根拠ルール
(A)Nogoroutine作成は開始をhappens-beforeにするが、data=1の書き込みがgo func(){}より前にあるかどうかは保証しない特殊ケース。実際には go文の実行前に書かれているので…

(B)を検出するテストの骨格:

func TestSpinWaitPlainBool_Race(t *testing.T) {
    for i := 0; i < 100; i++ {
        var data int
        var flagPlain bool
        done := make(chan struct{})
        go func() {
            data = 2
            flagPlain = true
            close(done)
        }()
        for !flagPlain { // ここが競合ポイント
        }
        _ = data
        <-done
    }
}

-raceはこの「flagPlainへの書き込みと読み込みが同期されていない」ことを検出します。ループ回数を増やすことで検出確率を上げます。

✅ 模範解答

1. 判定表

パターンhappens-before成立?根拠ルール
(A)成立するがBの実行順序は保証されないgo文自体は「起動元でのgo文の実行」が「起動されるgoroutineの開始」よりhappens-beforeであることを保証する。data = 1はgo func(){}より前に実行されているので、go文のhappens-before規則によりBは1を観測できる。ただし「いつfmt.Printlnが実行されるか」という実行タイミングの保証はない
(B)No(未定義動作)flagPlainはプレーンなbool変数であり、その読み書きにはメモリモデル上のhappens-before規則が存在しない。コンパイラ・CPUはdata = 2とflagPlain = trueの順序を並べ替えてよく、読み込み側のfor !flagPlain {}もコンパイラが最適化により無限ループ化・レジスタキャッシュ化する可能性がある。-raceはこれを実際にデータ競合として報告する
(C)成立するatomic.BoolのStore/LoadはGo 1.19で明文化されたatomicのhappens-before規則に従う。「あるatomic Load操作が、あるatomic Store操作が書き込んだ値を観測した場合、そのStoreはそのLoadよりhappens-before」。flagAtomic.Load()がtrueを観測した時点で、それより前に実行されたdata = 3もhappens-before関係により可視になる
(D)成立するchannel通信ルール:「送信操作は、対応する受信操作の完了よりhappens-before」。unbuffered channelでは送受信が同期的にランデブーするため、ch <- struct{}{}の前に書かれたdata = 4は、<-chが完了した後のコードから確実に観測できる
(E)成立するMutexルール:「n回目のUnlock()は、n<mであるm回目のLock()の完了よりhappens-before」。AのUnlock()が先に完了すれば、Bの(後続の)Lock()完了よりhappens-beforeとなる。ただしAのLock()が先に成功するとは限らない点に注意(実行順序の非決定性であり競合ではない)
(F)成立するsync.WaitGroupはメモリモデル本文の短いルール一覧には明示されていないが、syncパッケージのドキュメントで「Waitは内部カウンタが0になるまでブロックする」ことが規定されており、実装上はruntimeの低レベルなsemaphoreを使って可視性を保証している。実務上「Done()の呼び出しは、対応するWait()の返却よりhappens-before」と扱ってよい正式な同期プリミティブ
(G)No(未定義動作)time.Sleepはスケジューリング上の時間待ちを行うだけで、メモリモデル上のhappens-before関係を一切作らない。「十分待てばgoroutineは終わっているだろう」という時間的な確率の話と、「メモリモデルが可視性を保証するか」という仕様の話は完全に別次元

2. (B)と(C)の違い

見た目はどちらも「フラグ変数をスピンで待つ」という同じパターンですが、変数の型がメモリモデル上の同期プリミティブとして規定されているかどうかが決定的に異なります。

  • bool型のプレーンな変数への読み書きは、コンパイラにとって「ただのメモリアクセス」でしかない。最適化(命令の並べ替え、レジスタへのキャッシュ、ループの除去)を妨げる理由が言語仕様上どこにもない
  • atomic.Bool(やatomic.Int32等)のStore/Loadメソッドは、Go 1.19のメモリモデル改訂で明示的に「Release-Acquireセマンティクス」を持つと規定された。コンパイラはStoreの前にある書き込みをStoreより後に並べ替えることを禁止され、Loadが値を観測した側では、それ以前の書き込みがすべて可視になることが保証される

つまり(B)は「実装上たまたま動くことがある未定義動作」、(C)は「言語仕様が可視性を保証する正しいコード」という、見た目の類似性とは裏腹に本質的に別物です。

3. (G)がtime.Sleepで解決しない理由

根本的な理由は2つあります。

  1. 理論的な理由: happens-beforeは「時間が経過したかどうか」ではなく「同期プリミティブによる明示的なやり取りがあったかどうか」で定義される。CPUのキャッシュコヒーレンシプロトコルが「たまたま」正しい値を見せてくれることはあっても、それは仕様が保証する動作ではない
  2. 実務的な理由: 「1秒待てば終わっているはず」という前提はGCの一時停止、OSのスケジューリング遅延、CPU負荷の変動によって簡単に破られる。本番環境で低頻度にしか発生しない「たまに古い値が見える」バグの温床になる

正しい修正は、sync.WaitGroupやchannelのclose、contextのキャンセルなど、メモリモデルが定義する同期プリミティブでgoroutineの完了を待つことです。

4. (B)を検出するテストコード

ファイル構成: day035/go.mod + day035/main_test.go

// go.mod
module example.com/day035

go 1.22
package main

import (
	"sync/atomic"
	"testing"
)

// TestSpinWaitPlainBool_Race は、プレーンなbool変数によるスピン待ちが
// happens-before関係を持たないことを go test -race で検出するテスト。
// 100回繰り返し実行することで、コンパイラ・CPUの最適化・スケジューリング
// の揺らぎによってたまたま検出漏れになる確率を下げる。
func TestSpinWaitPlainBool_Race(t *testing.T) {
	for i := 0; i < 100; i++ {
		var data int
		var flagPlain bool
		done := make(chan struct{})

		go func() {
			data = 2
			flagPlain = true // (X) 書き込み側: dataとflagPlainの両方に非アトミックにアクセス
			close(done)
		}()

		for !flagPlain { // (Y) 読み込み側: flagPlainへの非同期な読み込みループ
		}
		_ = data
		<-done // テストのgoroutineリーク防止のためcloseを待つ
	}
}

// TestSpinWaitAtomicBool_NoRace は、対比としてatomic.Boolを使った
// 正しい実装が -race で警告を出さないことを確認するテスト。
func TestSpinWaitAtomicBool_NoRace(t *testing.T) {
	for i := 0; i < 100; i++ {
		var data int
		var flagAtomic atomic.Bool
		done := make(chan struct{})

		go func() {
			data = 3
			flagAtomic.Store(true)
			close(done)
		}()

		for !flagAtomic.Load() {
		}
		_ = data
		<-done
	}
}
▶ 確認手順を見る
mkdir day035 && cd day035
go mod init example.com/day035
# main_test.go を上記の内容で保存

go test -race -run TestSpinWaitPlainBool_Race -count=3 .
# => WARNING: DATA RACE
#    Write at 0x... by goroutine N:
#      example.com/day035.TestSpinWaitPlainBool_Race.func1()
#    Previous read at 0x... by goroutine M:
#      example.com/day035.TestSpinWaitPlainBool_Race()
#    のような報告が (X)/(Y) の間で出る

go test -race -run TestSpinWaitAtomicBool_NoRace -count=3 .
# => ok  example.com/day035  0.XXXs   (DATA RACEの報告なし)

🪜 Step-by-Step 解説

1
happens-beforeは「規則の一覧」として理解する
Go Memory Modelは抽象的な原則ではなく、「どの操作の組み合わせがhappens-beforeを作るか」という具体的で有限な規則のリストとして定義されています。goroutine作成、channel通信、Mutex、Once、atomicの5つが主要な規則であり、これ以外の手段(time.Sleep、プレーンな変数の読み書き、CPUのキャッシュが「たまたま」揃うこと)はどれもリストに含まれません。「規則のリストにあるか」を機械的にチェックする習慣が、並行処理コードの安全性を見抜く最短ルートになります。
2
「動く」と「仕様が保証する」を切り分ける
(B)や(G)のような未定義動作のコードは、多くの環境で「たまたま」正しく動きます。これは、多くの実用的なCPUアーキテクチャ(x86など)が比較的強いメモリ順序保証を持つことに起因する偶然の産物です。ARMのような弱いメモリ順序を持つアーキテクチャへの移植や、コンパイラのバージョンアップによる最適化強化で突然壊れることがあります。「動いた」という経験的事実は、「仕様が保証している」ことの証拠にはなりません。
3
atomicのRelease-Acquireセマンティクスを理解する
atomic.Bool.Storeは「Release」操作、atomic.Bool.Loadは「Acquire」操作として振る舞います。Releaseは「それ以前の書き込みをすべて確定させてから公開する」、Acquireは「値を読んだ時点で、対応するReleaseより前の書き込みがすべて見える」という意味を持ちます。これはCPUのメモリバリア命令とコンパイラの最適化抑制の両方によって実現される、CPUアーキテクチャを問わない移植可能な保証です。
4
goroutineの完了待ちには必ず同期プリミティブを使う
time.Sleepでgoroutineの完了を待つコードを見たら、sync.WaitGroup・channelのclose・contextのいずれかへの置き換えを検討します。これらはいずれもメモリモデルの規則リストに含まれる正式な同期プリミティブであり、可視性が仕様で保証されます。

💡 設計思想・なぜこう書くのか

📌
パフォーマンスと安全性のトレードオフを言語レベルで明示する: Goが「happens-before」という抽象的な順序関係を導入し、プレーンな変数の読み書きにはあえて何の保証も与えないのは、パフォーマンスと安全性のトレードオフを言語レベルで明示するという設計判断です。もしすべてのメモリアクセスに強い順序保証を与えれば、コンパイラやCPUの最適化余地が大きく制限され、シングルスレッド性能が犠牲になります。逆に何の規則もなければ並行処理は書けません。Goは「同期が必要な箇所は明示的なプリミティブ(channel・Mutex・atomic)を使う」という設計で、コストのかかる同期を必要な箇所だけに限定しつつ、規則を守ればプラットフォームを問わず正しく動くことを保証するという均衡点を選んでいます。
📌
言語仕様として明文化することへのこだわり: Go 1.19でメモリモデルがC/C++のメモリモデルに正式に整合する形で明文化されたのは、「実装上たまたまこう動く」という状態から「仕様として保証する」状態への移行であり、コンパイラの最適化がさらに積極化しても壊れないコードを書けるようにするための布石です。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaJava Memory Model(JMM)のhappens-before関係JavaはJDK 5(2004年)からhappens-beforeを明文化しており、Goのメモリモデルの用語・考え方はJMMに強く影響を受けている。volatile変数への読み書きがJavaにおける「Release/Acquire」に相当し、Goのatomicパッケージと同じ役割を持つ
C/C++C11/C++11のstd::atomicとメモリオーダー(memory_order_acquire/release/seq_cst)C/C++はmemory_orderを明示的に選択できる(relaxedからseq_cstまで細かく制御可能)のに対し、Goのsync/atomicはデフォルトでsequentially consistentに近い強い保証を提供し、選択肢を単純化している。GoのMemory ModelはC/C++のモデルを基礎にしているため、根底の理論は共通している
Ruststd::sync::atomicのOrdering列挙型RustもC++のメモリモデルを踏襲しており、Ordering::Acquire/Release/SeqCst等を明示的に指定する。加えてRustは所有権システムによって「共有される可変状態」自体をコンパイル時に制限するため、そもそも同期忘れによるデータ競合がコンパイルエラーになりやすい。Goはランタイムの-race検出に頼る点が対照的

🏆 実務での使いどころ

  • 「動いているから正しい」を疑うコードレビュー: time.Sleepでgoroutineの完了を待つコードや、プレーンなbool/intを「合図」として使うコードを見たら、happens-beforeが成立しているかを機械的にチェックする
  • パフォーマンスクリティカルな箇所でのMutex→atomicへの置き換え判断: 単純なカウンタ・フラグであればatomicはMutexより軽量。ただしatomicで守れるのは単一のスカラー値のみ(Day 034参照)という制約を踏まえた上での判断が必要
  • クロスプラットフォーム移植時のリスク評価: x86中心の開発環境で「たまたま動いていた」並行処理コードをARMベースの環境(Apple Silicon、AWS Gravitonなど)に移植する際、happens-beforeが厳密に成立しているかの再点検が重要になる
  • 障害調査での仮説形成: 「特定の環境・負荷でだけ稀に古い値が見える」という障害報告を受けた際、まず「同期プリミティブを使わず可視性に依存しているコードがないか」を疑う出発点になる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
-raceで警告が出なければhappens-beforeも成立していると思い込むどちらも「並行処理の安全性チェック」という同じカテゴリに見える-raceは動的解析であり、そのテスト実行で実際に踏んだパスの競合しか検出しない。happens-beforeは静的な仕様上の保証であり、「たまたまそのテストでは競合が発火しなかった」ことと「仕様上安全である」ことは別問題
time.Sleepを十分長くすれば同期の代わりになると考える実行環境で「大抵は間に合う」ため経験的に動いてしまうtime.Sleepはスケジューリングの時間待ちであり、メモリモデルの同期プリミティブのリストに含まれない。可視性の保証は時間の長さとは無関係
atomicを使えば常にMutexより速く安全だと考える「軽量な同期プリミティブ」という表面的な理解atomicが守れるのは単一のスカラー値への操作のみ。複数フィールドにまたがる複合的な不変条件を守りたい場合は、複数のatomic操作を組み合わせても中間状態の一貫性は保証されず、Mutexが必要になる
goroutine作成(go文)だけで、起動元と起動先の間で「何でも」happens-beforeが成立すると考える(A)のパターンで「dataが見える」ことを一般化しすぎるgo文が保証するのは「goroutine開始よりhappens-before」という一方向・一時点の関係のみ。goroutine起動後に起動元が行った書き込みや、起動されたgoroutineの終了時点の状態については別途明示的な同期が必要

🚀 次のステップ

  • 発展: Go Memory Modelの原文(go.dev/ref/mem)を読み、sync.Onceが内部でatomicとMutexをどう組み合わせて実装しているか(標準ライブラリのソースコードsync/once.go)を確認してみましょう
  • 次回予告: GC・エスケープ解析の理解 — Day 036では、happens-beforeとは異なる軸として、Goのランタイムが「どの変数をヒープに置き、どの変数をスタックに置くか」をどう判断しているかを理解します

🎯 自己評価

自分の回答

気づき・メモ