🫠

【Go】商品消失事件から学ぶ並行処理における排他制御

2025/02/09に公開

Goの並行処理のコードを実務で初めて書いていたのですが、競合状態(Race Condition)が発生していることになかなか気づくことができず、販売するはずの商品がエラーもログも吐くことなく消失するという事件(名付けて「商品消失事件」)を経験しました。

並行処理における排他制御について、抽象化されたサンプルコードの例はよくあると思いますが、実際の業務のビジネスロジックに沿っており、かつシンプルな例はあまりないなと思ったので今回ご紹介してみます。

ビジネスロジックの整理

今回ご紹介する例のビジネスロジックは以下の3ステップです。

  1. 複数の商品をDBから取得
  2. 商品のバリデーション(並行処理
  3. バリデーションを通過した商品のみをレスポンスする

当初はステップ2は同期的な処理だったのですが、バリデーションの中で別のマイクロサービスのAPIを呼び出す必要があり、商品の数が増えるに連れて処理速度が遅くなるということがボトルネックになっていたため、並行処理で実装することとしました。

競合状態が発生してしまう実装例

早速、NGな実装例を見ていきたいと思います。当初実装していたコードは以下になります。

type Item struct {
	Name string
}

func main() {
	const itemNum = 100
	fmt.Printf("%d items\n\n", itemNum)

	// 1. 商品をDBから取得
	items := getItems(itemNum)

	validItems := make([]Item, 0, itemNum)
	var wg sync.WaitGroup
	for _, item := range items {
		wg.Add(1)
		go func(item Item) {
			defer wg.Done()

			// 2. 商品のバリデーション(並行処理)
			if isValid := validate(item); isValid {
				// ここで競合状態が発生する可能性がある
				validItems = append(validItems, item)
			}

		}(item)
	}
	wg.Wait()

	log.Printf("\nvalid items: %d", len(validItems))
}

func getItems(itemNum int) []Item {
	items := make([]Item, itemNum)
	for i := 0; i < itemNum; i++ {
		items[i] = Item{Name: fmt.Sprintf("item%d", i+1)}
	}
	return items
}

func validate(item Item) bool {
	// something validation
	time.Sleep(time.Second * 1)

	log.Printf("%s: validation done.", item.Name)
	return true
}

上記のコードのvalidItems = append(validItems, item)の処理に注目してください。各ゴルーチンのスコープ内でvalidItemsスライスにアクセスしていますが、このスライスはゴルーチンを起動する前に変数宣言してあるため、複数ゴルーチンが同時にアクセスする可能性のある共有メモリなのです。したがって、validItems = append(validItems, item)では競合状態が発生する可能性があるのです。

このコードを実行すると、バリデーションを通過している商品のはずなのにvalidItemsに追加されないことがあり、あたかも商品が消えたかのような事象が発生する可能性があります。

競合状態は気づきにくい

競合状態の厄介な点として、起動するゴルーチンの数が少ないと数回実行するだけでは競合状態が発生しないことが多く、バグに気づきにくいということが挙げられると思います。

実際に、上記のコードをお手元で何度か実行していただくと分かると思いますが、ゴルーチンの数が100個程度であれば、数回実行するだけでは出力を見る限り何の問題もないように錯覚してしまいます。(一体いつから───競合状態が発生していないと錯覚していたのでしょうか...)

ちなみにですが、上記のコードだとitemNum(商品数)を1000個に設定(つまり1秒間でゴルーチンが1000個起動するように設定)すると、高確率で競合状態が発生して、いくつかの商品が消失することを体感いただけると思います。

Goでは競合状態を検出するために、-raceオプションが用意されていますので、横着せずにしっかりと確認することが大事だと再認識させられました。

go run -race main.go

競合状態が発生しない実装例

以下のようにsync.Mutexを使用して排他制御の仕組みを実装してあげることで、競合状態が発生しないようにできます。

func main() {
	<略>
	validItems := make([]Item, 0, itemNum)
	var wg sync.WaitGroup
+	var mu sync.Mutex
	for _, item := range items {
		wg.Add(1)
		go func(item Item) {
			defer wg.Done()

			// 2. 商品のバリデーション(並行処理)
			if isValid := validate(item); isValid {
+				mu.Lock() // ロック
-				// ここで競合状態が発生する可能性がある
				validItems = append(validItems, item)
+				mu.Unlock() // ロック解除
			}

		}(item)
	}
	wg.Wait()
	<略>
}

今回の記事が、並行処理の経験が浅い人がコードを書いたときに、意図しない挙動に遭遇した場合のデバッグの一助になれば幸いです。

さいごに

Goにおける並行処理において、複数ゴルーチン間でのメモリ共有では 「共有メモリにアクセスするのではなく、(チャネルを使って)データを通信することでメモリを共有せよ」 というスローガンがあります:

Do not communicate by sharing memory; instead, share memory by communicating.[1]

今回の例だとそこまで厳密に守る必要性がないかもしれませんが、これを好機としてチャネルを使った通信方法も深掘りしてみたいなと思いました。

ではでは〜👋

脚注
  1. https://go.dev/doc/effective_go#sharing ↩︎

Discussion