Go上級 #2 メモリモデルと sync パッケージ
#1 並行性パターン でチャネルをどう組み立てるか見たなら — 今回は 反対側の道具。チャネルではなく共有変数に直接アクセスするときに必要な道具。
“Don’t communicate by sharing memory; share memory by communicating.”
格言は正しいですが — 実践では 共有変数のほうが合う場面 もあります。カウンタ、キャッシュ、lazy 初期化のようなもの。
メモリモデル一行まとめ #
Go のメモリモデルが保証するのは — 同じ変数に 二つのゴルーチンが同時にアクセス(読み + 書き、または書き + 書き) するのはデータレース。一緒に使うには 同期が必要 です。
同期ツールはチャネル以外に — 今回の記事で見る sync パッケージと sync/atomic。
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 で読みの並行性を高められます。
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 で見ましたね。要点だけもう一度。
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 初期化に合うツール。
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 のプレッシャーを減らしたいとき。
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 より軽いですが、保護対象が一つの変数のときだけ合います。
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 #
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 は標準ツールでデータレースを捕まえてくれます。
go run -race main.go
go test -race ./...
go build -raceランタイムで同時アクセスを監視し — 発見すれば即座に報告。オーバーヘッドがあるのでプロダクションでは有効にしませんが、テストと CI では常に 有効にしておくのが良いです。
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。
// ゴルーチン 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 がどう動作するかを整理します。