🔩 ねじき教室 Go と Web の教室

goroutine と並行性

読了目安 約5分

Web サーバーの土台である goroutine を動かし、共有変数が壊れる競合状態を実際に起こして sync.Mutex で直します。

30秒でつかむ 並行に動かし、共有する場所だけを守る
並行に動かし、共有する場所だけを守るgoroutineが同時に仕事を進め、共有変数への書き込みだけをMutexで一つずつ通します。1goroutine A同時に動く2Mutex共有部分を守る3goroutine B同時に動く
  1. goroutine A 同時に動く
  2. Mutex 共有部分を守る
  3. goroutine B 同時に動く
この章の目次

goroutine は、Go が言語として備える軽量な実行の単位です。 OS のスレッド(わからない場合、気にしなくてよいです)より軽く、1 台で数万を同時に動かせます。

1 リクエスト = 1 goroutine

ブラウザからの 1 回のアクセスをリクエストと呼びます(Part 2 で詳しく扱います)。 Go の標準の Web サーバーは、リクエストごとに新しい goroutine で処理を実行します。 だから Part 3 で書くハンドラは、最初から並行に実行されます。

go キーワードと sync.WaitGroup

Go
package main

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

func handleRequest(id int) {
	time.Sleep(100 * time.Millisecond) // データベースへの問い合わせのつもり
	fmt.Printf("リクエスト %d を処理しました\n", id)
}

func main() {
	start := time.Now()
	var wg sync.WaitGroup
	for i := 1; i <= 5; i++ {
		wg.Go(func() {
			handleRequest(i)
		})
	}
	wg.Wait()
	fmt.Println("所要時間:", time.Since(start).Round(time.Millisecond))
}

1 件 100 ミリ秒の処理が 5 件でも、5 つが同時に待つのでおよそ 100 ミリ秒です。 逐次なら 500 ミリ秒だった待ちが、重なって 100 ミリ秒に縮みます。

sync.WaitGroup は、wg.Go で起動した goroutine が全部終わるのを wg.Wait() で待つ道具です。 誰も待たないと、main が終わった時点でプログラム全体が終了します。

wg.Go(func() { ... })func() { ... } は、名前のない関数(無名関数)です。 goroutine にしたい処理を、名前を付けずにその場で作って渡しています。

実は wg.Go は Go 1.25 で入った書き方で、ISUCON の参考実装を含む既存のコードは、同じことを 3 つの呼び出しで書いています。

Go
for i := 1; i <= 5; i++ {
	wg.Add(1) // 待つ数を 1 増やす
	go func() {
		defer wg.Done() // 抜けるときに 1 減らす
		handleRequest(i)
	}()
}
wg.Wait() // 0 になるまで待つ

WaitGroup の実体はただのカウンターで、wg.Go はこの 3 つ(増やす・起動・減らす)をまとめた書き方です。 go func() { ... }() は、先頭の go が goroutine を始め、末尾の () が無名関数を呼び出します。

読むときはこの対応で読み、書くときは wg.Go を使ってください。 wg.Add のドキュメントにも、wg.Go を優先するよう明記されています。

共有変数への同時書き込みは壊れる

Go
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			v := counter      // 読む
			runtime.Gosched() // ここで他の goroutine に切り替わる
			v = v + 1         // 足す
			counter = v       // 書く
		}()
	}
	wg.Wait()
	fmt.Println("期待値: 1000 実際:", counter)
}

1000 回足したのに、はるかに小さい値が出ます。 加算は「読む、足す、書く」の 3 段階に分かれ、読んでから書くまでに他の goroutine が割り込むからです。 両方が 0 を読んで両方が 1 を書けば、加算は 1 回分消えます。

実際に書く counter++ も CPU 上では同じ 3 段階なので、runtime.Gosched() がなくても本番のサーバーでは同じように壊れます。

壊れるかどうかはタイミング次第で、手元のテストは通り、本番でだけ稀に壊れます。 この状態を競合状態(race condition)と呼びます。

sync.Mutex で守る

sync.Mutex は、「Lock から Unlock までの区間を同時に実行できる goroutine は 1 つだけ」という制約を作る道具(排他ロック)で、他の goroutine は Lock の位置で順番を待ちます。

Go
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	counter := 0
	var mu sync.Mutex
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			v := counter
			runtime.Gosched() // 切り替わっても、他の goroutine は Lock で待つ
			counter = v + 1
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("期待値: 1000 実際:", counter)
}

3 段階がまるごとロックで守られ、結果は必ず 1000 になります。 この区間は直列実行に戻るので、囲む範囲は共有変数に触る部分だけにします。

手元で動かすときは go run -race を覚えておいてください。競合状態の多くを実行時に検出してくれます。

ISUCON では Mutex が必須になる

ISUCON では、DB の問い合わせ結果を map に覚えて使い回す高速化(キャッシュ)を自作します(キャッシュ)。 この map は全リクエストの goroutine が触る共有変数で、同時書き込みは panic で落ちるので Mutex が前提です。

試してみよう

  1. WaitGroup の例から wg.Wait() を消すと出力がどうなるか、予想してから実行してください。
  2. 同じ例でリクエスト数を 5 から 20 に増やしても、所要時間がほぼ変わらないことを確かめてください。
  3. Mutex の例で mu.Unlock()runtime.Gosched() の直前(読んだ直後)に移すと、結果はどうなるでしょうか。守っているつもりでロックの範囲が足りない、という典型的な失敗を安全に再現できます。
  4. 共有変数の例は wg.Addwg.Done で書かれています。wg.Go で書き直しても結果が変わらないことを確かめてください。
答えを見る

1 は、「リクエスト N を処理しました」が 1 行も出ず、所要時間だけが表示されます。

テキスト
所要時間: 0s

goroutine が 100 ミリ秒待っている間に main が先に終わり、プログラム全体が終了するからです。

2 は、20 件に増やしても所要時間はほぼ 100 ミリ秒のままです。 20 個の goroutine が同時に待つので、待ちは重なって 1 件分で済みます。

テキスト
リクエスト 4 を処理しました
リクエスト 2 を処理しました
(中略)
リクエスト 8 を処理しました
所要時間: 102ms

どの goroutine が先に終わるかは決まっていないので、出力の順序は実行のたびに変わります。

3 は、また 1000 より小さい値になります。

Go
mu.Lock()
v := counter
mu.Unlock() // 読んだ直後に手放してしまう
runtime.Gosched()
counter = v + 1
テキスト
期待値: 1000 実際: 82

実際の値は実行のたびに変わります。 読む操作をロックで守っても、読んでから書くまでの区間を 1 つのロックで囲まない限り、割り込みは防げません。

4 は、wg.Go に書き直しても壊れたままです(この値も実行のたびに変わります)。

Go
for i := 0; i < 1000; i++ {
	wg.Go(func() {
		v := counter      // 読む
		runtime.Gosched() // ここで他の goroutine に切り替わる
		counter = v + 1   // 書く
	})
}
テキスト
期待値: 1000 実際: 115

競合状態の原因は共有変数への同時アクセスであって、WaitGroup の書き方ではないとわかります。