Go: GoのゴルーチンとWaitGroup:軽量な並行処理、GMPスケジューリング、およびメモリリークの防止
ゴルーチン(Goroutines)は、Go言語における並行プログラミングの基盤です。これらはスレッドでもコルーチンでもなく、Goランタイムによって管理される軽量な並行処理単位です。2 KBのスタック領域を持つため、数百万単位の並行処理を可能にします。
Goの並行処理モデルは他に類を見ないほど優れています。ゴルーチンを開始するには、goというキーワードを指定するだけで済み、Goのランタイムは裏側で何千ものゴルーチンをごく少数のOSスレッドに効率的にマッピングしています。
1. 学習内容
- ゴルーチンという概念と
goキーワード sync.WaitGroupは、すべてのゴルーチンが終了するのを待ちます- ゴルーチンとOSスレッドの比較(スタックサイズ/生成オーバーヘッド)
GOMAXPROCSと GMP スケジューリングモデル- ゴルーチンリークの原因とトラブルシューティング
runtime.NumGoroutine監視- ゴルーチンを使って、10万件のログエントリを並行して処理する
2. データエンジニアの実体験
(1) 課題:10万件のログエントリを単一スレッドで処理するのに5時間かかる
チャーリーは、毎日10万件のサーバーログを処理するデータエンジニアです:
「毎日、夜明けとともにログのクリーンアップ処理が実行され、10万行のデータを次々と処理していく――これには5時間以上かかる。朝のミーティングでは、PMが必ず『昨日のデータはいつ準備できるのか?』と聞いてくる。私が『午後3時です』と答えると、彼らは『なぜ午前9時までに準備できないのか?』と返すのだ。」
彼は現在のコードを開いた:
// Serial processing: 100k entries × 200ms/entry = 5.5 hours
func processLogs(logs []LogEntry) []Result {
results := make([]Result, 0, len(logs))
for _, log := range logs {
result := processSingleLog(log) // 200ms each, including network IO
results = append(results, result)
}
return results
}
各ログエントリの処理には、1回の外部API呼び出し(待ち時間は約200ミリ秒)が必要ですが、実際のCPU処理にかかる時間は1ミリ秒未満であり、処理時間の99.5%はネットワークの待ち時間に費やされています。
(2) Goの解決策:Goroutineによる並行処理
// log_processor.go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
type LogEntry struct {
ID int
Message string
Level string
}
type Result struct {
ID int
Status string
}
func processSingleLog(log LogEntry) Result {
time.Sleep(200 * time.Millisecond)
return Result{ID: log.ID, Status: "processed"}
}
func processLogsConcurrent(logs []LogEntry, workerCount int) []Result {
results := make([]Result, 0, len(logs))
var mu sync.Mutex
var wg sync.WaitGroup
// Use channels for task distribution
jobs := make(chan LogEntry, len(logs))
for _, log := range logs {
jobs <- log
}
close(jobs)
// Start workerCount workers
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for log := range jobs {
result := processSingleLog(log)
mu.Lock()
results = append(results, result)
mu.Unlock()
}
}(w)
}
wg.Wait()
return results
}
func main() {
// Simulate 100 log entries (demo; can scale to 100k)
logs := make([]LogEntry, 100)
for i := 0; i < 100; i++ {
logs[i] = LogEntry{ID: i, Message: fmt.Sprintf("log-%d", i), Level: "INFO"}
}
start := time.Now()
results := processLogsConcurrent(logs, 10)
elapsed := time.Since(start)
fmt.Printf("Processed %d logs in %v\n", len(results), elapsed)
fmt.Printf("Current goroutine count: %d\n", runtime.NumGoroutine())
}
出力:
Processed 100 logs in 2.05s
Current goroutine count: 1
(3) パフォーマンス:順次処理と並行処理の比較
| 処理方法 | 100 ログ | 100,000 ログ | ゴルーチン数 |
|---|---|---|---|
| 逐次処理 | 20年代 | 5.5時間 | 1 |
| 10人の同時実行ワーカー | 2秒 | 33分 | 約12 |
| 100 個の同時実行ワーカー | 0.2 秒 | 3.3 分 | 約 102 |
worker count = GOMAXPROCS * 2–10 を、CPU負荷の高いタスクには worker count = GOMAXPROCS を推奨します。
3. ゴルーチン基礎
(1) go キーワード
package main
import (
"fmt"
"time"
)
func printNumbers() {
for i := 1; i <= 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Printf("%d ", i)
}
}
func printLetters() {
for _, c := range "ABCDE" {
time.Sleep(150 * time.Millisecond)
fmt.Printf("%c ", c)
}
}
func main() {
go printNumbers() // Execute concurrently in a goroutine
go printLetters()
time.Sleep(1 * time.Second)
fmt.Println()
}
出力(毎回異なる場合があります):
1 A 2 B 3 C 4 D 5 E
(2) 匿名ゴルーチン
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("Anonymous goroutine running")
}()
go func(msg string) {
fmt.Println("Anonymous goroutine with parameter:", msg)
}("hello")
time.Sleep(100 * time.Millisecond)
fmt.Println("main done")
}
time.Sleep は、ゴルーチンが終了するのを待つためのものです。本番環境のコードでは、sync.WaitGroup を使用する必要があります。
4. sync.WaitGroup: 同期を待つ
(1) WaitGroup の 3 つのメソッド
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 3. Tell WaitGroup this worker is done
fmt.Printf("Worker %d started\n", id)
// Simulate work...
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 1. Increment counter
go worker(i, &wg)
}
wg.Wait() // 2. Block until all workers finish
fmt.Println("All workers done!")
}
| メソッド | 関数 |
|---|---|
wg.Add(delta int) |
カウンタをインクリメントする(通常、ゴルーチン開始前に呼び出される) |
wg.Done() |
カウンタを1減らす(通常、ゴルーチン内の defer 呼び出し) |
wg.Wait() |
カウンタがゼロになるまでブロックする |
▶ サンプル:WaitGroup とクロージャの落とし穴
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// ❌ Wrong: Closure captures loop variable
for i := 1; i <= 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Printf("Wrong i=%d\n", i) // All print 3 or 4
}()
}
wg.Wait()
// ✅ Correct: Pass parameter (creates a copy)
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Correct i=%d\n", id)
}(i)
}
wg.Wait()
}
i を直接キャプチャしています。ゴルーチンが開始される時点で、ループはすでに終了しており、i の値が上書きされている可能性があります。パラメータのコピーを渡す必要があります。
5. ゴルーチンとOSスレッドの比較
(1) 主な相違点
package main
import (
"fmt"
"runtime"
)
func main() {
// View current GOMAXPROCS
fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
fmt.Printf("CPU cores: %d\n", runtime.NumCPU())
fmt.Printf("Current goroutine count: %d\n", runtime.NumGoroutine())
}
| ディメンション | OSスレッド | ゴルーチン |
|---|---|---|
| 初期スタックサイズ | 1~8 MB | 2 KB |
| スタックの上限 | 1~8 MBに固定 | 動的に1 GBまで拡張 |
| 生成にかかるオーバーヘッド | ~1 µs | ~0.1 µs |
| コンテキストスイッチ | カーネルモード(約1 µs) | ユーザーモード(約0.1 µs) |
| 最大数 | 約10,000 | 百万 |
| スケジューラ | OSカーネルのスケジューリング | GoランタイムのGMP |
(2) GMPスケジューリングモデル
graph TB
G[G: Goroutine Queue] --> P[P: Logical Processor<br/>GOMAXPROCS count]
P --> M[M: OS Thread<br/>Scheduled by kernel]
M --> CPU[CPU Core]
subgraph Global Queue
GQ[(Global Goroutine Queue)]
end
GQ --> P
style P fill:#e1f5fe
style M fill:#fff3e0
GMP: G(ゴルーチン)— P(プロセッサ、論理プロセッサ;その数は GOMAXPROCS に等しい)— M(マシン、OS スレッド)。Go ランタイムは G を P のローカルキューにスケジューリングし、P はその後に M にバインドして実行を行います。
6. GOMAXPROCS と並行処理制御
(1) GOMAXPROCS の設定
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("Default GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
// CPU-intensive: GOMAXPROCS = NumCPU
// IO-intensive: GOMAXPROCS = NumCPU * 2~10
runtime.GOMAXPROCS(4) // Set to 4
fmt.Printf("After setting GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
}
▶ サンプル:GOMAXPROCSがパフォーマンスに与える影響
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func cpuIntensiveTask() {
sum := 0
for i := 0; i < 10000000; i++ {
sum += i
}
}
func benchmarkGOMAXPROCS(n int) time.Duration {
runtime.GOMAXPROCS(n)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cpuIntensiveTask()
}()
}
wg.Wait()
return time.Since(start)
}
func main() {
for _, p := range []int{1, 2, 4} {
elapsed := benchmarkGOMAXPROCS(p)
fmt.Printf("GOMAXPROCS=%d: %v\n", p, elapsed)
}
}
7. ゴルーチンリークとトラブルシューティング
(1) 漏洩のシナリオ
package main
import (
"fmt"
"runtime"
"time"
)
// Leaky goroutine: reads from a channel that is never closed
func leakyGoroutine() {
ch := make(chan int)
go func() {
<-ch // Blocks forever—no one writes to ch
}()
}
func main() {
for i := 0; i < 10; i++ {
leakyGoroutine()
}
time.Sleep(100 * time.Millisecond)
fmt.Printf("Goroutine count after leak: %d\n", runtime.NumGoroutine())
// Output: Goroutine count after leak: 11 (10 leaked + 1 main)
}
▶ サンプル:ゴルーチンを開始する4つの方法
package main
import (
"fmt"
"sync"
"time"
)
func say(msg string) {
fmt.Println(msg)
}
func main() {
var wg sync.WaitGroup
// Style 1: Named function
wg.Add(1)
go func() {
defer wg.Done()
say("Style 1: Named function")
}()
// Style 2: Anonymous function
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("Style 2: Anonymous function")
}()
// Style 3: Anonymous function with parameter (recommended—avoids closure trap)
wg.Add(1)
go func(msg string) {
defer wg.Done()
fmt.Println(msg)
}("Style 3: With parameter")
// Style 4: Function as value
wg.Add(1)
fn := func() {
defer wg.Done()
fmt.Println("Style 4: Function variable")
}
go fn()
wg.Wait()
}
▶ サンプル:ゴルーチンとOSスレッドのスタックの比較
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var memStats runtime.MemStats
var wg sync.WaitGroup
num := 100000 // 100k goroutines
runtime.GC()
runtime.ReadMemStats(&memStats)
before := memStats.HeapAlloc
for i := 0; i < num; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
_ = n
}(i)
}
wg.Wait()
runtime.ReadMemStats(&memStats)
after := memStats.HeapAlloc
perGoroutine := float64(after-before) / float64(num)
fmt.Printf("Started %d goroutines\n", num)
fmt.Printf("Memory increase: %.2f MB\n", float64(after-before)/1024/1024)
fmt.Printf("Per goroutine: ~%.2f KB\n", perGoroutine/1024)
fmt.Printf("Total goroutine count: %d\n", runtime.NumGoroutine())
}
出力:
Started 100000 goroutines
Memory increase: 21.45 MB
Per goroutine: ~0.22 KB
Total goroutine count: 1
▶ サンプル:ゴルーチンライフサイクルの監視
package main
import (
"fmt"
"runtime"
"time"
)
func safeWorker(done chan struct{}) {
<-done // Wait for exit signal
}
func main() {
done := make(chan struct{})
// Start 5 workers
for i := 0; i < 5; i++ {
go safeWorker(done)
}
fmt.Printf("Goroutine count after start: %d\n", runtime.NumGoroutine())
// Send exit signal
close(done)
time.Sleep(10 * time.Millisecond)
fmt.Printf("Goroutine count after close: %d\n", runtime.NumGoroutine())
}
(5) 漏洩防止チェックリスト
| シナリオ | 予防策 |
|---|---|
| チャネルから読み込んでいるが、書き込みを行っているユーザーはいない | バッファ付きチャネルを使用するか、select に default オプションを指定してください |
| チャンネルに書き込みをしても、誰もそれを読み取っていない | コンシューマーが存在することを確認するか、デフォルト値を指定して select を使用してください |
| ゴルーチンによる無限ループ | done チャネルまたはコンテキストを用いた終了制御 |
time.After はループ内でメモリリークを引き起こす |
time.NewTimer と Stop を使用して、各反復で新しいタイマーを作成する |
select {} 空 |
出口経路があることを確認してください |
8. 完全な例:10万件のログエントリの並行処理
// log_processor.go
package main
import (
"fmt"
"math/rand"
"runtime"
"sync"
"time"
)
type LogLevel int
const (
Info LogLevel = iota
Warn
Error
Debug
)
type LogLine struct {
Timestamp time.Time
Level LogLevel
Message string
Source string
}
type ProcessedLog struct {
Original LogLine
Severity string
Alert bool
ProcessedAt time.Time
}
func (l LogLine) Process() ProcessedLog {
// Simulate processing time (IO wait)
time.Sleep(time.Duration(50+rand.Intn(50)) * time.Millisecond)
severity := "low"
alert := false
switch l.Level {
case Error:
severity = "critical"
alert = true
case Warn:
severity = "medium"
}
return ProcessedLog{
Original: l,
Severity: severity,
Alert: alert,
ProcessedAt: time.Now(),
}
}
// Serial processing
func processSerial(logs []LogLine) []ProcessedLog {
results := make([]ProcessedLog, 0, len(logs))
for _, log := range logs {
results = append(results, log.Process())
}
return results
}
// Concurrent processing: worker pool pattern
func processConcurrent(logs []LogLine, workers int) []ProcessedLog {
jobs := make(chan LogLine, len(logs))
results := make(chan ProcessedLog, len(logs))
// Fill jobs
for _, log := range logs {
jobs <- log
}
close(jobs)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for log := range jobs {
results <- log.Process()
}
}(w)
}
// Wait for all workers to finish, then close results
go func() {
wg.Wait()
close(results)
}()
// Collect results
processed := make([]ProcessedLog, 0, len(logs))
for r := range results {
processed = append(processed, r)
}
return processed
}
func main() {
const logCount = 10000 // 10k for demo; can scale to 100k
levels := []LogLevel{Info, Warn, Error, Debug}
sources := []string{"api-gateway", "user-service", "payment", "database"}
logs := make([]LogLine, logCount)
for i := 0; i < logCount; i++ {
logs[i] = LogLine{
Timestamp: time.Now(),
Level: levels[rand.Intn(len(levels))],
Message: fmt.Sprintf("log-entry-%d", i),
Source: sources[rand.Intn(len(sources))],
}
}
fmt.Printf("Log count: %d\n", logCount)
fmt.Printf("CPU cores: %d, GOMAXPROCS: %d\n", runtime.NumCPU(), runtime.GOMAXPROCS(0))
// Serial processing
start := time.Now()
serialResults := processSerial(logs)
serialTime := time.Since(start)
fmt.Printf("\nSerial: %v (%d entries/sec)\n", serialTime,
int(float64(logCount)/serialTime.Seconds()))
// Concurrent processing (10 workers)
runtime.GC()
start = time.Now()
concurrentResults := processConcurrent(logs, 10)
concurrentTime := time.Since(start)
fmt.Printf("Concurrent(10): %v (%d entries/sec)\n", concurrentTime,
int(float64(logCount)/concurrentTime.Seconds()))
// Concurrent processing (50 workers)
runtime.GC()
start = time.Now()
concurrentResults = processConcurrent(logs, 50)
concurrentTime = time.Since(start)
fmt.Printf("Concurrent(50): %v (%d entries/sec)\n", concurrentTime,
int(float64(logCount)/concurrentTime.Seconds()))
fmt.Printf("\nResult validation: serial=%d, concurrent=%d\n",
len(serialResults), len(concurrentResults))
fmt.Printf("Current goroutine count: %d\n", runtime.NumGoroutine())
// Count alerts
alertCount := 0
for _, r := range concurrentResults {
if r.Alert {
alertCount++
}
}
fmt.Printf("Alert count: %d\n", alertCount)
}
期待される出力:
Log count: 10000
CPU cores: 8, GOMAXPROCS: 8
Serial: 5.2s (1923 entries/sec)
Concurrent(10): 520ms (19230 entries/sec)
Concurrent(50): 110ms (90909 entries/sec)
Result validation: serial=10000, concurrent=10000
Current goroutine count: 1
Alert count: 2453
recover ではこれを捕捉できません。各ゴルーチンエントリポイントは defer recover() で保護する必要があります。チャネルに関する第14課では、このパターンについてさらに詳しく解説します。
❓ よくある質問
sync.WaitGroup を使用します。—wg.Add(n) はカウントをインクリメントし、wg.Done() はデクリメントし、wg.Wait() はカウントがゼロになるまでブロックします。なお、Addは外側のスコープ内、またはゴルーチン起動前に呼び出す必要があり、Doneは確実に実行されるようにdeferで囲む必要があります。runtime.NumGoroutine() を使用してカウントが継続的に増加しているかどうかを確認し、net/http/pprof を使用してゴルーチンスタックトレースを調査してください。リークの一般的な原因としては、チャネルの読み取りや書き込みがブロックされている場合、select句にdefault句が含まれていない場合、およびゴルーチンが終了するのを制御せずにゴルーチン起動を行うforループなどが挙げられます。runtime.Gosched の目的は何ですか?time.Sleep などの状況において、自動的にこれをスケジューリングします。defer func() { if r := recover(); r != nil { ... } }() を記述する必要があります。go キーワードで呼び出される関数は、引数を持たない必要がありますか?go myFunc(arg1, arg2)。クロージャ内で変数をキャプチャする際は、循環変数の罠に注意してください。直接キャプチャではなく、パラメータ渡し(コピーが生成される)を使用してください。📖 まとめ
goキーワードはゴルーチン起動し、軽量なユーザーモードの並行処理を可能にします- ゴルーチンのスタックは2 KBから始まり、動的に拡大します。OSスレッドのスタックは1~8 MBに固定されています。
sync.WaitGroup:Add、Done、Waitの 3 つのメソッドは、ゴルーチンのライフサイクルを管理します- ゴルーチン・クロージャがループ変数をキャプチャする場合、そのパラメータはコピー渡しで渡さなければならない
GOMAXPROCSは並列度を制御します。デフォルト値 = NumCPU- GMPスケジューリングモデル:ゴルーチン → プロセッサ → マシン
- ゴルーチンリーク:ブロックされたチャネル、終了条件のない無限ループ
runtime.NumGoroutine()は、現在のゴルーチン数を監視します
📝 練習問題
-
基本問題(難易度 ⭐):5つのgoroutineを起動するプログラムを作成してください。各goroutineは、自身の番号と「Hello」を出力します。
sync.WaitGroupを使用して、すべてが終了するのを待ち、goroutineの実行順序がランダムであることを確認してください。 -
上級問題(難易度 ⭐⭐):並行処理による素数フィルタを実装してください。整数 n が与えられたとき、4 つの goroutine を使用して 1 から n までの範囲のどの数が素数であるかを並行してチェックし、素数のリストを返してください。同期には WaitGroup を使用し、並行処理と順次処理のアプローチのパフォーマンスの違いを比較してください。
-
課題(難易度 ⭐⭐⭐):並行タスクスケジューラを構築する。タスクのバッチ(
[]func() Result)が与えられた場合、ワーカープールモデルを用いてそれらを並行して実行する。このスケジューラは、以下の機能をサポートする必要があります:(1) ワーカー数の設定が可能であること;(2) フォールトトレランス(単一のgoroutineでパニックが発生しても、他のgoroutineに影響を与えないこと);(3) 進行状況のコールバック(タスクの10%が完了するたびにメッセージを出力すること);(4)runtime.NumGoroutineのリーク検出。