Go上級 #2 メモリモデルと sync パッケージ

読了 6分

#1 並行性パターン でチャネルをどう組み立てるか見たなら — 今回は 反対側の道具。チャネルではなく共有変数に直接アクセスするときに必要な道具。

“Don’t communicate by sharing memory; share memory by communicating.”

格言は正しいですが — 実践では 共有変数のほうが合う場面 もあります。カウンタ、キャッシュ、lazy 初期化のようなもの。

メモリモデル一行まとめ #

Go のメモリモデルが保証するのは — 同じ変数に 二つのゴルーチンが同時にアクセス(読み + 書き、または書き + 書き) するのはデータレース。一緒に使うには 同期が必要 です。

同期ツールはチャネル以外に — 今回の記事で見る sync パッケージと sync/atomic

Mutex — 最も単純なロック #

Mutex カウンタ
import "sync"

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

Lock() / Unlock() の間のコードが — クリティカルセクション。一度に一つのゴルーチンだけが入れます。

パターン — defer Unlock が標準 #

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()    // 関数の最後に自動
	c.n++
}

panic や early return でも安全。ほぼ常にこのパターンに従います。

Mutex は zero value で使用可能 #

var mu sync.Mutex    // すぐ使える
mu.Lock()

sync.Mutex{} のような初期化は不要。構造体の中に入れれば — 自動的に動作可能な状態。

落とし穴 — Mutex をコピーするな #

よくある落とし穴
type Counter struct {
	mu sync.Mutex
	n  int
}

func use(c Counter) {    // ✗ 値コピー — Mutex もコピーされる
	c.mu.Lock()
	// ...
}

値で受け取ると — Mutex がまるごとコピーされて 別のロック になります。go vet が捕まえてくれます。常に ポインタレシーバ

RWMutex — 読みは同時に #

読みが多くて書きが少ないとき — sync.RWMutex で読みの並行性を高められます。

RWMutex
type Cache struct {
	mu sync.RWMutex
	m  map[string]string
}

func (c *Cache) Get(k string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.m[k]
}

func (c *Cache) Set(k, v string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[k] = v
}
  • RLock / RUnlock — 複数の reader が同時に入れる
  • Lock / Unlock — writer は一人で

ベンチマークしてみると — 読みの比率が 90% を超えて圧倒的なとき だけ意味があります。それ以下だと普通の Mutex と大差なかったりかえって遅かったりします(RWMutex 自体のオーバーヘッド)。

WaitGroup — 複数のゴルーチンの終了を待つ #

中級 #3 で見ましたね。要点だけもう一度。

WaitGroup
var wg sync.WaitGroup

for _, x := range items {
	wg.Add(1)
	go func(x Item) {
		defer wg.Done()
		process(x)
	}(x)
}

wg.Wait()

三つのメソッド: Add(n) で登録、Done() で終了、Wait() で全員終わるのを待つ。

落とし穴 — Add の位置 #

Add(1)ゴルーチン開始前、メインゴルーチンで。ゴルーチンの中で Add すると — Wait が先に到着して 0 の状態で抜けることがあります。

Once — 一度だけ実行 #

lazy 初期化に合うツール。

Once
var (
	once     sync.Once
	instance *DB
)

func GetDB() *DB {
	once.Do(func() {
		instance = connect()
	})
	return instance
}

once.Do(f) が — 複数のゴルーチンが同時に呼んでも f を正確に一度だけ 実行。二回目以降の呼び出しは即座に return。

singleton、lazy init、1 回限りの cleanup のような場面に合います。

Pool — 一時オブジェクトの再利用 #

GC のプレッシャーを減らしたいとき。

sync.Pool
var bufPool = sync.Pool{
	New: func() any {
		return new(bytes.Buffer)
	},
}

func handler() {
	buf := bufPool.Get().(*bytes.Buffer)
	defer func() {
		buf.Reset()
		bufPool.Put(buf)
	}()

	// buf を使用
}

特徴:

  • Get がプールから取り出すか、空なら New を呼ぶ
  • Put で返却
  • プールのオブジェクトは GC が任意に回収 し得る — 必ず再び受け取る保証はない

ホットパスの bytes.Buffer、byte slice のような場面で効果が大きい。fmt パッケージ内部でも活用しています。

atomic — ロックなしで単一変数を同期 #

sync/atomic パッケージは — 単一変数の読み/書きを アトミックに 処理します。Mutex より軽いですが、保護対象が一つの変数のときだけ合います。

atomic カウンタ
import "sync/atomic"

type Counter struct {
	n atomic.Int64
}

func (c *Counter) Inc() {
	c.n.Add(1)
}

func (c *Counter) Value() int64 {
	return c.n.Load()
}

Go 1.19+ で追加された atomic.Int64 のような型は — アトミック性を型で強制して安全。以前のバージョンの atomic.AddInt64(&n, 1) よりすっきりしています。

compare-and-swap #

CAS
var flag atomic.Bool
if flag.CompareAndSwap(false, true) {
	// 最初に入ったゴルーチンだけがここへ
}

CompareAndSwap(old, new) — 現在の値が old なら new に変えて true、違えばそのままにして false。ロックフリーアルゴリズムの基本ツール。

どの道具をいつ使うか? #

状況道具
単一カウンタ/フラグatomic
構造体や複数の変数を同時保護Mutex
読み圧倒的、書きはまれRWMutex
複数のゴルーチンの終了待ちWaitGroup
1 回初期化Once
一時オブジェクトの再利用Pool
段階的データの流れ、信号チャネル

基本は チャネル優先。チャネルが不自然になる場面(共有カウンタ、キャッシュ、1 回初期化)で sync ツールに乗り換えます。

データレース — race detector #

Go は標準ツールでデータレースを捕まえてくれます。

race 検出
go run -race main.go
go test -race ./...
go build -race

ランタイムで同時アクセスを監視し — 発見すれば即座に報告。オーバーヘッドがあるのでプロダクションでは有効にしませんが、テストと CI では常に 有効にしておくのが良いです。

race 報告例
WARNING: DATA RACE
Read at 0x00c000118018 by goroutine 7:
  main.read()
      /tmp/race.go:14 +0x3a

Previous write at 0x00c000118018 by goroutine 6:
  main.write()
      /tmp/race.go:9 +0x47

読み/書きの位置を両方教えてくれます。重要なデバッグツール。

チャネル vs Mutex — どっち? #

状況どっち?
データの所有権を移す(producer → consumer)チャネル
段階的処理の流れ(pipeline)チャネル
共有状態に複数のゴルーチンが読み書きMutex
単純なカウンタ/フラグatomic
1 回初期化Once

Go コミュニティの合意は — 両方とも道具。格言は「チャネル優先」ですが、データ構造の内側のようにチャネルが不自然な場面は Mutex が自然です。標準ライブラリ自体が両方を自由に使っています。

落とし穴 — ロックの範囲 #

よくある落とし穴
func (c *Cache) Get(k string) string {
	c.mu.Lock()
	v := c.m[k]
	c.mu.Unlock()
	return v
}

func main() {
	v := c.Get("k")
	process(v)    // ロックの外なので OK
}

ロックは — 必要な分だけ 取る必要があります。ロックの中で重い仕事をすると(例: I/O、外部呼び出し)、他のゴルーチンが全員待ちます。

よくないパターン
c.mu.Lock()
defer c.mu.Unlock()
v := c.m[k]
log.Print(v)              // ✗ ロック内で I/O
http.Get(someURL)          // ✗ ロック内でネットワーク

I/O は常にロックの外で。

落とし穴 — 二つのロックの順序 #

複数のロックを取ることがあれば — 常に同じ順序で。さもなければ deadlock。

deadlock シナリオ
// ゴルーチン A
a.Lock(); b.Lock()    // a → b の順

// ゴルーチン B (逆)
b.Lock(); a.Lock()    // b → a の順 — A と出会うと deadlock

可能なら — ロックが複数必要な設計自体を避けるのが良いです。

まとめ #

今回の記事で整理した内容:

  • Mutex — 最も単純なクリティカルセクション保護、defer Unlock が標準
  • RWMutex — 読みが圧倒的なときだけ意味あり
  • WaitGroup — 複数のゴルーチン終了待ち、Add はゴルーチンの外で
  • Once — 1 回実行保証、lazy init
  • Pool — 一時オブジェクト再利用、GC プレッシャー緩和
  • atomic — 単一変数のロックフリー同期
  • race detector — テスト/CI に常に有効化
  • ロックの中に I/O 禁止、ロック順序を統一

次の記事(#3 ジェネリクス)では — Go 1.18 で導入された type parameter。いつ使っていつ使わないか、constraint がどう動作するかを整理します。

X