Go のスライスを並行処理で安全に扱う:競合の回避パターン

Go のスライスはスレッドセーフではありません。複数のゴルーチンから同じスライスに同時にアクセスすると、データの破損やパニックが発生します。並行処理でスライスを安全に扱うためのパターンを整理します。

競合が起きる例

まず、なぜスライスの並行アクセスが危険なのかを確認しましょう。

func main() {
	s := []int{}

	for i := 0; i < 1000; i++ {
		go func(n int) {
			s = append(s, n)
		}(i)
	}

	time.Sleep(time.Second)
	fmt.Println(len(s)) // 1000 にならない、またはパニック
}

append はスライスの長さを読み取り、要素を書き込み、長さを更新するという複数のステップで構成されています。複数のゴルーチンが同時にこれを実行すると、書き込みが競合してデータが失われたり、index out of range でパニックしたりします。

-race フラグを付けて実行すると、Go のレースデテクタが競合を検出してくれます。

go run -race main.go
# WARNING: DATA RACE

sync.Mutex で保護する

もっとも基本的な対策は、sync.Mutex でスライスへのアクセスを排他制御することです。

var (
	mu sync.Mutex
	s  []int
)

func appendSafe(n int) {
	mu.Lock()
	s = append(s, n)
	mu.Unlock()
}

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			appendSafe(n)
		}(i)
	}

	wg.Wait()
	fmt.Println(len(s)) // 確実に 1000
}

LockUnlock の間は一つのゴルーチンしか実行できないため、append の競合を防げます。defer wg.Done() で確実にカウントを減らし、wg.Wait() で全ゴルーチンの完了を待ちます。

ゴルーチンが Lock を取得

スライスに append

Unlock で解放

次のゴルーチンが Lock を取得

sync.RWMutex で読み取りを並行化する

読み取りが多く書き込みが少ない場合、sync.RWMutex を使うと読み取り同士は並行実行でき、パフォーマンスが向上します。

type SafeSlice struct {
	mu    sync.RWMutex
	items []int
}

func (s *SafeSlice) Append(n int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.items = append(s.items, n)
}

func (s *SafeSlice) Get(index int) (int, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if index < 0 || index >= len(s.items) {
		return 0, false
	}
	return s.items[index], true
}

func (s *SafeSlice) Len() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.items)
}
sync.Mutex

読み取り・書き込みどちらも排他。シンプルだが、読み取りが多い場面ではボトルネックになりやすい。

sync.RWMutex

読み取り同士は並行実行できる。書き込みは排他。読み取りが多い場面でスループットが向上する。

チャネルで集約する

Go らしいアプローチは、チャネルを使って「スライスを操作するゴルーチンを一つに絞る」方法です。

func collector(ch <-chan int, done chan<- []int) {
	var result []int
	for n := range ch {
		result = append(result, n)
	}
	done <- result
}

func main() {
	ch := make(chan int, 100)
	done := make(chan []int)

	go collector(ch, done)

	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			ch <- n
		}(i)
	}

	wg.Wait()
	close(ch)

	result := <-done
	fmt.Println(len(result)) // 1000
}

collector ゴルーチンだけがスライスに書き込むため、ロックは不要です。他のゴルーチンはチャネルにデータを送るだけで、スライスに直接触りません。

各ゴルーチンがチャネルに値を送信

collector が受信して append

チャネルが閉じたら結果を返す

Go の格言「共有メモリで通信するな、通信でメモリを共有しろ(Don’t communicate by sharing memory; share memory by communicating)」を体現するパターンです。

各ゴルーチンで独立したスライスを使う

並行処理の結果を最後にまとめればよい場合は、ゴルーチンごとに独立したスライスを作って最後に結合する方法が最もシンプルです。

func processParallel(data []int, workers int) []int {
	chunkSize := (len(data) + workers - 1) / workers
	results := make([][]int, workers)
	var wg sync.WaitGroup

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			start := id * chunkSize
			end := start + chunkSize
			if end > len(data) {
				end = len(data)
			}

			var local []int
			for _, v := range data[start:end] {
				if v%2 == 0 {
					local = append(local, v)
				}
			}
			results[id] = local
		}(i)
	}

	wg.Wait()

	// 結果を結合
	var merged []int
	for _, r := range results {
		merged = append(merged, r...)
	}
	return merged
}

各ゴルーチンが自分専用の local スライスに書き込むため、ロックもチャネルも不要です。results[id] への代入も、各ゴルーチンが異なるインデックスに書くので競合しません。

Mutexシンプルだが競合が多いと遅い
RWMutex読み取り多めの場面に有効
チャネルGo らしいパターン。書き込み役を一つに絞る
独立スライス + 結合ロック不要で最も高速。分割可能なタスク向き

どれを選ぶか

状況に応じた使い分けが重要です。書き込み頻度が低いなら RWMutex、データの流れが一方向ならチャネル、タスクが分割可能なら独立スライスが適しています。

迷ったときはまず「スライスを共有しない設計にできないか」を考えてみてください。共有しなければ競合は起きません。それが難しい場合に初めてロックやチャネルの出番です。どのパターンを選んでも、go run -race で競合がないことを必ず確認しましょう。