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

goroutine と並行性

読了目安 約4分

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

この章の目次

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 を使ってください。 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 に切り替わる
			counter = v + 1   // 書く
		}()
	}
	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. 共有変数の例は Add と Done で書かれています。wg.Go で書き直しても結果が変わらないことを確かめてください。