Ritolabo
  1. Home
  2. Go
  3. Go の同期処理に使う sync パッケージの基本

Go の同期処理に使う sync パッケージの基本

  • 公開日
  • カテゴリGo
  • タグgolang
Go の同期処理に使う sync パッケージの基本

Go では goroutine を使って並行処理を実装できますが、並行で走る複数の処理が同じデータを同時に扱い、予期しない動作を招く場合があります。

そこで活躍するのが標準ライブラリの sync パッケージです。

実践でよく使う同期機能が揃っており、シンプルな書き味で安全な並行処理を実現できます。

contents

  1. sync パッケージとは
  2. 1. Mutex:基本的な排他ロック
    1. なぜ Mutex が必要なのか
    2. Mutex を使った実装例
    3. データレースチェッカー
  3. 2. RWMutex:読み取りが多いデータ向けの排他ロック
    1. なぜ RWMutex が必要なのか
    2. RWMutex を使った実装例
  4. 3. WaitGroup:複数 goroutine の完了を待つための同期
    1. なぜ WaitGroup が必要なのか
    2. WaitGroup を使った実装例
  5. 4. Once:初期化処理を一度だけ実行するための同期
    1. なぜ Once が必要なのか
    2. Once を使った実装例
  6. 5. Cond:条件が整うまで待つための同期
    1. なぜ Cond が必要なのか
    2. Cond を使った実装例
  7. 6. Pool:オブジェクトを再利用してメモリ負荷を抑える仕組み
    1. なぜ Pool が必要なのか
    2. Pool を使った実装例
  8. 7. sync.Map:並行アクセスに安全なマップ
    1. なぜ sync.Map が必要なのか
    2. sync.Map を使った実装例

sync パッケージとは

sync パッケージは、goroutine 同士の競合を防ぎながら処理を調整するための仕組みを提供するパッケージです。

ロック、待ち合わせ、一度きりの初期化、条件待機、オブジェクト再利用など、幅広い用途をカバーしています。

1. Mutex:基本的な排他ロック

Mutex は、複数の処理が同じデータへアクセスするときに「同時に触らないように順番を制御する」、つまり排他ロックを取得するための仕組みです。

共有データを扱う際に、処理を安全に実行するための基本的なロックとして使用します。

なぜ Mutex が必要なのか

goroutine を複数起動し、同じ変数 count を更新すると、更新処理が衝突して値が正しく増えないことがあります。

次のように Mutex を使わずに count++ を実行すると、レースコンディションが発生します。

// goroutine が同時に count++ を行うため値が不安定になる例
package main

import (
  "fmt"
  "sync"
)

var count int

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 1000; i++ {
    wg.Go(func() {
      // ここで同時に count++ が実行されるため競合が発生する
      count++
    })
  }

  wg.Wait()
  fmt.Println("count:", count)
}

実行するたびに違う数値が表示され、安定しません。

# 1回目
count: 742
# 2回目
count: 891
# 3回目
count: 964

これは、複数の goroutine が同じ変数を書き換え、どの更新が残るかが毎回異なるためです。

Mutex を使った実装例

Mutex を使うと、Lock() によって排他ロックがかかり、共有データの更新を順番に行うため、競合を防ぐことができます。

package main

import (
  "fmt"
  "sync"
)

var (
  mu    sync.Mutex
  count int
)

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 1000; i++ {
    wg.Go(func() {
      mu.Lock()
      count++
      mu.Unlock()
    })
  }

  wg.Wait()
  fmt.Println("count:", count)
}

この実装では、毎回正しく count: 1000 と表示されます。

データレースチェッカー

Go にはデータ競合を検出できる race detector(データレースチェッカー) が用意されています。

Mutex を外した例を使い、次のように実行すると競合を検出できます。

go run -race mutex.go

以下のような警告が表示されます。

WARNING: DATA RACE
Write at 0x000100b970d0 by goroutine 10:
.
.
.
==================
count: 856
Found 2 data race(s)

どこで競合が起きているかを教えてくれるため、並行処理のデバッグに非常に役立ちます。

2. RWMutex:読み取りが多いデータ向けの排他ロック

RWMutex は、排他ロックと共に、共有ロックを提供している機能です。読み取り専用の処理を複数同時に実行しつつ、書き込みが行われるときだけ排他制御をかけたいといった場合に役立ちます。

なぜ RWMutex が必要なのか

Mutex を使うと、読み取りでも書き込みでもすべて排他ロックを取得するため、読み取りが多いケースでは読み取りがブロック(Unlock()されるまで待ちが発生)されパフォーマンスが低下します。

次の例では、読み取りが何度も発生しているにもかかわらず排他ロックを使うため無駄な待ち時間が発生します。

// Mutex だけですべての読み書きを直列化してしまう例
package main

import (
  "fmt"
  "sync"
)

type Data struct {
  mu sync.Mutex
  v  int
}

func (d *Data) Read() int {
  d.mu.Lock()
  defer d.mu.Unlock()
  return d.v
}

func (d *Data) Write(val int) {
  d.mu.Lock()
  defer d.mu.Unlock()
  d.v = val
}

func main() {
  d := &Data{}
  var wg sync.WaitGroup

  for i := 0; i < 1000; i++ {
    wg.Go(func() {
      _ = d.Read() // 読み取りでもロックを占有
    })
  }

  wg.Go(func() {
    d.Write(10) // 書き込みも同じロック
  })

  wg.Wait()
  fmt.Println("done")
}

このコードは動作としては安全ですが、読み取りが直列化されるため並行性が大幅に低下します。

読み取りが多いケースでは RWMutex を使うことで処理が効率化されます。

RWMutex を使った実装例

RWMutex が提供する 2 種類のロックは、次の意味を持ちます。

メソッドロック種別性質同時に実行できる?用途
Lock()排他ロック(exclusive lock)完全に単独で占有読み取りも書き込みも 全てブロック書き込み処理
RLock()共有ロック(shared lock)複数で共有可能読み取り同士は 同時実行OK読み取り処理

RWMutex の RLock / RUnlock を使うことで、複数の読み取りが同時に実行できます。

書き込み処理では Lock / Unlock を使用し、書き込み中はすべての読み取りが止まります。

package main

import (
  "fmt"
  "sync"
)

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

func (c *Cache) Get(key string) (string, bool) {
  c.mu.RLock()
  defer c.mu.RUnlock()
  v, ok := c.m[key]
  return v, ok
}

func (c *Cache) Set(key, value string) {
  c.mu.Lock()
  defer c.mu.Unlock()
  c.m[key] = value
}

func main() {
  c := &Cache{m: make(map[string]string)}
  var wg sync.WaitGroup

  c.Set("lang", "Go")

  for i := 0; i < 1000; i++ {
    wg.Go(func() {
      _, _ = c.Get("lang") // 読み取りは並列でOK
    })
  }

  wg.Go(func() {
    c.Set("version", "1.25") // 書き込みは単独で実行
  })

  wg.Wait()
  fmt.Println("done")
}

読み取りが多いケースでは、この方法が Mutex よりも高い並行性を実現できます。

読み取りと書き込みが混在するデータ構造では、RWMutex を正しく使うことで安全性とパフォーマンスを両立できます。

3. WaitGroup:複数 goroutine の完了を待つための同期

WaitGroup は、複数の goroutine がすべて処理を終えるまで「次の処理へ進まないようにする」ための仕組みです。

goroutine は起動するとすぐに呼び出し元へ制御を返すため、そのままでは「並行処理が終わる前に次のステップへ進んでしまう」ことがあります。

なぜ WaitGroup が必要なのか

goroutine をループ内で大量に起動した場合でも、呼び出し元の処理は完了を待たずに進行します。

並行処理の結果を使って後続の処理を行う場合、待たずに進んでしまうと 「まだ終わっていない状態なのに次の処理をしてしまう」 という問題が発生します。

次の例では、worker のログ出力が終わっていなくても main done が先に実行される可能性があります。

// WaitGroup を使わない例(全てのログが出るとは限らない)
package main

import (
  "fmt"
)

func main() {
  for i := 0; i < 5; i++ {
    i := i
    go func() {
      fmt.Println("worker", i, "done")
    }()
  }

  // goroutine を待たずに main が終了してしまう可能性がある
  fmt.Println("main done")
}

実際の出力例:

worker 0 done
worker 4 done
main done
worker 1 done
worker 3 done

worker の完了前に main done が先に実行されているのが分かります。

実行環境によっては worker のログがすべて表示されることもありますが、仕様上は 並行処理が完了する前に次の処理へ進む ため、安定しません。

WaitGroup を使った実装例

WaitGroup を使うと、すべての goroutine が完了するまで処理が停止し、「並行処理が終わる前に次へ進んでしまう」問題を防げます。

ここでは wg.Go() を利用して、goroutine の起動とカウント管理をまとめて書いています。

// WaitGroup を使って、全 worker の終了を待つ例
package main

import (
  "fmt"
  "sync"
)

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 5; i++ {
    i := i
    wg.Go(func() {
      fmt.Println("worker", i, "done")
    })
  }

  // すべての goroutine が終わるまで待つ
  wg.Wait()

  fmt.Println("main done")
}

この例では、worker のログ出力がすべて終わったあとに main done が必ず実行されます。

実際の出力例:

worker 4 done
worker 0 done
worker 3 done
worker 1 done
worker 2 done
main done

WaitGroup は「共有データを守る」ためのものではなく、並行処理と次の処理のタイミングを揃えるためのツール として使います。

なお、syncパッケージとは別で、errgroup という、WaitGroup にエラー処理やコンテキストキャンセルの仕組みを加えた便利な並行処理パッケージがあります。これについては、こちらの記事で詳しく紹介しています。

WaitGroup と errgroup の違いを理解する

4. Once:初期化処理を一度だけ実行するための同期

Once は、複数の goroutine から呼ばれても「一度だけ」処理を実行したい場面で使う同期ツールです。

キャッシュの初期化、接続のセットアップ、設定ファイルの読み込みなど、複数の goroutine が並行に処理を進めている中で一度きり実行したい初期化処理に向いています。

なぜ Once が必要なのか

複数の goroutine が同時に同じ初期化関数を呼ぶと、初期化が重複して行われたり、想定外の値が上書きされる問題が発生します。

例えば、設定ロードや接続初期化などは一回だけ行われるべきですが、goroutine が並列に動くと次のように「初期化が何度も実行されてしまう」状況が起きます。

// Once を使わない例(初期化が複数回実行されてしまう)
package main

import (
  "fmt"
  "sync"
)

var initialized bool

func initConfig() {
  fmt.Println("initialize config")
  initialized = true
}

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 5; i++ {
    wg.Go(func() {
      initConfig() // 本来は一度だけにしたい処理
    })
  }

  wg.Wait()
}

実行例:

initialize config
initialize config
initialize config
initialize config
initialize config

goroutine が並行に進むため、初期化が 5 回呼ばれてしまっています。

Once を使った実装例

Once を使うと、複数の goroutine が同時に呼んでも 指定した処理が一度だけ実行される ように制御できます。

一度実行されたあとは、他の goroutine が呼んでもスキップされます。

// Once を使った実装例(初期化は一度だけ)
package main

import (
  "fmt"
  "sync"
)

var (
  once        sync.Once
  initialized bool
)

func initConfig() {
  fmt.Println("initialize config")
  initialized = true
}

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 5; i++ {
    wg.Go(func() {
      once.Do(initConfig) // ここは一度だけ実行される
    })
  }

  wg.Wait()
}

実行例:

initialize config

初期化処理が大量の goroutine から呼ばれても、実行されるのは一度だけです。

これにより、初期化の重複実行やそれによる値の破壊、タイミング競合によるバグなどを防ぐことができます。

5. Cond:条件が整うまで待つための同期

Cond は、ある条件が満たされるまで goroutine を待機させ、条件が満たされたタイミングで他の goroutine に通知を送るための仕組みです。

処理の進行が「特定の状態に依存する」場面で便利で、キューの待ちや条件待ちなどで使われます。

なぜ Cond が必要なのか

goroutine どうしが状態を共有していると、「ある条件が満たされるまで進めない」という状況がよく発生します。

例えば、共有キューにデータが入るまで待ち続けたい場合、素直に書くと次のようなポーリング(無駄なループ)が発生してしまいます。

// Cond を使わない例(ポーリングが発生する)
package main

import (
  "fmt"
  "sync"
  "time"
)

var queue []int
var mu sync.Mutex

func main() {
  go func() {
    for {
      mu.Lock()
      if len(queue) > 0 {
        fmt.Println("got:", queue[0])
        mu.Unlock()
        return
      } else {
        fmt.Println("no queue")
      }
      mu.Unlock()

      // 条件が満たされていない間ずっとループし続ける(CPUを無駄に消費)
      time.Sleep(10 * time.Millisecond)
    }
  }()

  time.Sleep(50 * time.Millisecond) // 遅れてデータを追加
  mu.Lock()
  queue = append(queue, 42)
  mu.Unlock()

  time.Sleep(100 * time.Millisecond)
}

この例では、条件(キューにデータあり)を満たすまで 10ms ごとにループし続けてしまう ため、CPU が無駄に使われます。

実行例:

no queue
no queue
no queue
no queue
no queue
got: 42

Cond を使った実装例

Cond を使うと、条件が満たされるまで goroutine を効率的に待機させ、条件が整った瞬間に通知を受けて処理を再開できます。

Cond は RWMutex・Mutex と一緒に使い、

  1. Wait() で待機
  2. Signal() または Broadcast() で通知

という流れで使います。

// Cond を使った実装例(条件が満たされるまで効率よく待機)
package main

import (
  "fmt"
  "sync"
  "time"
)

var (
  queue []int
  mu    sync.Mutex
  cond  = sync.NewCond(&mu)
)

func main() {
  var wg sync.WaitGroup

  // 消費側(データが追加されるまで待つ)
  wg.Go(func() {
    cond.L.Lock()
    for len(queue) == 0 {
      fmt.Println("waiting")
      cond.Wait() // データが入るまで効率的に待機
    }
    fmt.Println("got:", queue[0])
    cond.L.Unlock()
  })

  // 生産側(少し遅れてデータを追加)
  wg.Go(func() {
    time.Sleep(50 * time.Millisecond)
    cond.L.Lock()
    queue = append(queue, 42)
    cond.L.Unlock()
    cond.Signal() // 1つの待機中 goroutine に通知する
  })

  wg.Wait()
}

実行例:

waiting
got: 42

この実装では、消費側は条件が満たされるまで無駄にループせず、Signal が来た瞬間だけ起きて処理が続行される ため効率が非常に良くなります。

Cond は頻繁に使う場面は少ないものの、

  • キュー処理
  • ワーカープール
  • 条件待ちが必要な状態管理

などではとても役立つ同期手法です。

6. Pool:オブジェクトを再利用してメモリ負荷を抑える仕組み

Pool は、使い捨てになりがちなオブジェクトを再利用することで、メモリ確保や GC の負荷を減らすための仕組みです。

大量の一時オブジェクトを生成する処理では、都度メモリ確保が行われるためコストが高くなります。Pool を使うと、生成済みオブジェクトを再利用でき、処理を効率化できます。

なぜ Pool が必要なのか

例えば、1 回のリクエスト処理のたびに毎回大きめのバッファや構造体を新しく作っていると、次のような問題が起きます。

  • 毎回メモリ確保が発生する
  • 不要になったオブジェクトが増えて GC のコストが上がる
  • 高頻度の処理ではパフォーマンス劣化につながる

次の例では、毎回新しいバイトスライスを作るため、高負荷な環境では無駄なメモリ確保が増えてしまいます。

// Pool を使わない例(毎回新しいオブジェクトを生成してしまう)
package main

import (
  "bytes"
  "fmt"
)

func main() {
  for i := 0; i < 5; i++ {
    buf := bytes.NewBuffer(make([]byte, 0, 1024)) // 毎回新しく確保
    buf.WriteString("hello")
    fmt.Println(buf.String())
  }
}

実行結果は一見問題なく見えますが、裏側では毎回バッファが新規確保され、GC の対象がどんどん増えていきます。

Pool を使った実装例

Pool を使うと、使い終わったオブジェクトを再利用できるため、メモリ確保のコストを削減できます。

Pool には New 関数を定義しておき、必要になったときに新しい値を作る仕組みを登録します。

// Pool を使った例(バッファを再利用して負荷を抑える)
package main

import (
  "bytes"
  "fmt"
  "sync"
)

var bufPool = sync.Pool{
  New: func() any {
    return bytes.NewBuffer(make([]byte, 0, 1024))
  },
}

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 5; i++ {
    wg.Go(func() {
      buf := bufPool.Get().(*bytes.Buffer)
      buf.Reset()              // 再利用する前に初期化
      buf.WriteString("hello") // 適当な処理

      fmt.Println(buf.String())

      bufPool.Put(buf) // 使い終わったら戻す
    })
  }

  wg.Wait()
}

実行例:

hello
hello
hello
hello
hello

毎回同じように見える動作ですが、内部では バッファが再利用され、メモリ確保と GC が減る という効果があります。

Pool は以下のような状況で特に有効です。

  • 大量アクセスの Web サーバ
  • ログ処理
  • 一時的なバッファを繰り返し使う処理

Pool は、大きなバッファや初期化コストの高い構造体など、「生成や破棄のコストが重い=作るときに、メモリ確保・解析・状態構築などの負荷が大きい」オブジェクトを再利用したい場面で特に効果を発揮します。

一方で、int や小さな構造体など軽量なオブジェクトに対しては、Pool の利用によるメリットはあまりありません。

7. sync.Map:並行アクセスに安全なマップ

sync.Map は、複数の goroutine から同時に読み書きされることを前提に設計された、スレッドセーフなマップです。

通常の map は並行アクセスに対応していないため、複数の goroutine が書き込むとランタイムエラーが発生します。

sync.Map を使うことで、ロック制御を意識せず安全に共有マップを扱えるようになります。

なぜ sync.Map が必要なのか

通常の map を複数の goroutine で同時に書き込むと、Go ランタイムが競合を検知して fatal error: concurrent map writes を出すことがあります。

次のように 100 個の goroutine が同時に map に書き込みを行うと、かなりの確率でエラーが発生します。

// 通常の map を複数 goroutine で同時に書き込む例(データ競合が発生)
package main

import (
  "fmt"
  "sync"
)

func main() {
  m := make(map[int]int)
  var wg sync.WaitGroup

  for i := 0; i < 100; i++ {
    i := i

    wg.Go(func() {
      for j := 0; j < 1000; j++ {
        m[i] = j // 同じ map に大量の並行書き込み
      }
    })
  }

  wg.Wait()
  fmt.Println("done")
}

実行すると、次のようなエラーが表示されます。

fatal error: concurrent map writes

goroutine 18 [running]:
runtime.throw({0x10b4f8a?, 0xc000024f20?})
runtime/panic.go:1067 +0x48
runtime.mapassign_fast64(0x10a2f80?, 0xc000014180?, 0xc000024f50?)
runtime/map_fast64.go:92 +0x3f4
...

このように、通常の map は複数 goroutine からの同時書き込みに対して安全ではありません。

map を共有して使う場合は、自分で sync.RWMutex を使ってロックを管理するか、sync.Map を利用する必要があります。

sync.Map を使った実装例

sync.Map は、複数の goroutine からの読み書きを安全に扱えるよう内部でロックやコピーオンライトなどを行ってくれます。

主なメソッドは次のとおりです。

  • Store(key, value):値を書き込む
  • Load(key):値を読み取る
  • LoadOrStore(key, value):存在チェックと追加を一度に行う
  • Range(func(key, value any) bool):全要素を列挙する

以下は、sync.Map を使って並行に書き込みと読み取りを行う例です。

// sync.Map を使った並行読み書きの例
package main

import (
"fmt"
"sync"
)

func main() {
var sm sync.Map
var wg sync.WaitGroup

  // 書き込み
  for i := 0; i < 100; i++ {
    i := i

    wg.Go(func() {
      for j := 0; j < 10; j++ {
        sm.Store(i, j)
      }
    })
  }

  // 読み取り
  for i := 0; i < 100; i++ {
    i := i

    wg.Go(func() {
      if v, ok := sm.Load(i); ok {
        fmt.Println("key:", i, "value:", v)
      }
    })
  }

  wg.Wait()

  // 全要素の列挙
  sm.Range(func(key, value any) bool {
    fmt.Println("range:", key, value)
    return true // false を返すとループ終了
  })
}

実行しても、通常の map のように concurrent map writes で落ちることはありません。

sync.Map は、複数の goroutine から同時に読み書きされる状況を想定して作られているため、利用者がロックを意識しなくても安全に使えます。

一方で、すべてのケースで万能というわけではなく、書き込みが多い場面では map + RWMutex のほうが速いこともあります。

「並行アクセスが前提のときに、気軽に使える安全な共有マップ」

それが sync.Map の役割です。

まとめ

Go で並行処理を書くとき、複数の goroutine が同じデータを扱うことは珍しくありません。

sync パッケージの各機能は、そうした場面での「データの守り方」や「処理の進め方」を整えるために用意されています。

用途に合わせて適切な機能を選び、並行処理を安全に実装していきましょう。

機能役割のイメージ
Mutexひとつずつ順番に処理したいときの基本ロック
RWMutex読み取りは並行で進めつつ、書き込みは順番に行いたいとき
WaitGroup並行処理がすべて終わるのを待ってから次へ進むとき
Once初期化処理などを一度だけ実行したいとき
Cond条件が整うのを待ち、整った瞬間に進めたいとき
Pool生成コストの高いオブジェクトを使い回して効率化したいとき
sync.Map複数の goroutine から同時に触れても安全に使える共有マップ

Author

rito

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級