goroutine と並行性
読了目安 約3分
Web サーバーの土台である goroutine を動かし、共有変数が壊れる競合状態を実際に起こして sync.Mutex で直します。
この章の目次
goroutine は、Go が言語として備える軽量な実行の単位です。
関数呼び出しの前に go と書くだけで並行に動き始め、OS のスレッドより軽いので 1 台で数万を同時に動かせます。
1 リクエスト = 1 goroutine
ブラウザからの 1 回のアクセスをリクエストと呼びます(Part 2 で詳しく扱います)。 Go の標準の Web サーバーは、リクエストごとに新しい goroutine で処理を実行します(1 リクエスト = 1 goroutine)。 だから Part 3 で書くハンドラは、最初から並行に実行されます。
go キーワードと sync.WaitGroup
package main
import (
"fmt"
"sync"
"time"
)
func handleRequest(id int, wg *sync.WaitGroup) {
defer wg.Done()
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.Add(1)
go handleRequest(i, &wg)
}
wg.Wait()
fmt.Println("所要時間:", time.Since(start).Round(time.Millisecond))
}1 件 100 ミリ秒の処理が 5 件でも、5 つが同時に待つので所要時間はおよそ 100 ミリ秒です。
sync.WaitGroup は、起動した goroutine が全部終わるまで待つ道具です。
誰も待たないと、main が終わった時点でプログラム全体が終了します。
共有変数への同時書き込みは壊れる
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 の位置で順番を待ちます。
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 では、データベースの問い合わせ結果を map に覚えて使い回す高速化(キャッシュ)を自作します(キャッシュ)。 この map は全リクエストの goroutine が触る共有変数で、同時書き込みは panic でプロセスごと落ちるので、Mutex で守るのが前提です。
試してみよう
- WaitGroup の例から
wg.Wait()を消すと出力がどうなるか、予想してから実行してください。 - 同じ例でリクエスト数を 5 から 20 に増やしても、所要時間がほぼ変わらないことを確かめてください。
- Mutex の例で
mu.Unlock()をruntime.Gosched()の直前(読んだ直後)に移すと、結果はどうなるでしょうか。守っているつもりでロックの範囲が足りない、という典型的な失敗を安全に再現できます。