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

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

Go
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 が終わった時点でプログラム全体が終了します。

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

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 では、データベースの問い合わせ結果を map に覚えて使い回す高速化(キャッシュ)を自作します(キャッシュ)。 この map は全リクエストの goroutine が触る共有変数で、同時書き込みは panic でプロセスごと落ちるので、Mutex で守るのが前提です。

試してみよう

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