goroutine と並行性
読了目安 約5分
Web サーバーの土台である goroutine を動かし、共有変数が壊れる競合状態を実際に起こして sync.Mutex で直します。
- goroutine A 同時に動く
- Mutex 共有部分を守る
- goroutine B 同時に動く
この章の目次
goroutine は、Go が言語として備える軽量な実行の単位です。 OS のスレッド(わからない場合、気にしなくてよいです)より軽く、1 台で数万を同時に動かせます。
1 リクエスト = 1 goroutine
ブラウザからの 1 回のアクセスをリクエストと呼びます(Part 2 で詳しく扱います)。 Go の標準の Web サーバーは、リクエストごとに新しい goroutine で処理を実行します。 だから Part 3 で書くハンドラは、最初から並行に実行されます。
go キーワードと sync.WaitGroup
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 つの呼び出しで書いています。
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 を優先するよう明記されています。
共有変数への同時書き込みは壊れる
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 の位置で順番を待ちます。
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 が前提です。
試してみよう
- WaitGroup の例から
wg.Wait()を消すと出力がどうなるか、予想してから実行してください。 - 同じ例でリクエスト数を 5 から 20 に増やしても、所要時間がほぼ変わらないことを確かめてください。
- Mutex の例で
mu.Unlock()をruntime.Gosched()の直前(読んだ直後)に移すと、結果はどうなるでしょうか。守っているつもりでロックの範囲が足りない、という典型的な失敗を安全に再現できます。 - 共有変数の例は
wg.Addとwg.Doneで書かれています。wg.Goで書き直しても結果が変わらないことを確かめてください。
答えを見る
1 は、「リクエスト N を処理しました」が 1 行も出ず、所要時間だけが表示されます。
所要時間: 0sgoroutine が 100 ミリ秒待っている間に main が先に終わり、プログラム全体が終了するからです。
2 は、20 件に増やしても所要時間はほぼ 100 ミリ秒のままです。 20 個の goroutine が同時に待つので、待ちは重なって 1 件分で済みます。
リクエスト 4 を処理しました
リクエスト 2 を処理しました
(中略)
リクエスト 8 を処理しました
所要時間: 102msどの goroutine が先に終わるかは決まっていないので、出力の順序は実行のたびに変わります。
3 は、また 1000 より小さい値になります。
mu.Lock()
v := counter
mu.Unlock() // 読んだ直後に手放してしまう
runtime.Gosched()
counter = v + 1期待値: 1000 実際: 82実際の値は実行のたびに変わります。 読む操作をロックで守っても、読んでから書くまでの区間を 1 つのロックで囲まない限り、割り込みは防げません。
4 は、wg.Go に書き直しても壊れたままです(この値も実行のたびに変わります)。
for i := 0; i < 1000; i++ {
wg.Go(func() {
v := counter // 読む
runtime.Gosched() // ここで他の goroutine に切り替わる
counter = v + 1 // 書く
})
}期待値: 1000 実際: 115競合状態の原因は共有変数への同時アクセスであって、WaitGroup の書き方ではないとわかります。