跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

Goのgoroutineとchannelをブログ風に理解する

goroutineとchannelを、ブログ生成や画像処理の例でゆっくり理解するメモ。

BY 稻, 草人

Goを勉強すると、比較的早い段階でgoroutineとchannelが出てきます。説明を読むと「軽量スレッド」「channelで通信する」と書かれていますが、最初は何が便利なのか分かりにくいです。

自分は、複数の作業者へ仕事を渡し、終わるまで待ち、結果を一か所へ集める流れとして考えると理解しやすくなりました。

目次

goroutineは仕事を並行して進める

通常の関数呼び出しは、終了してから次へ進みます。

download("a.csv")
download("b.csv")

goを付けると、その関数をgoroutineとして開始し、呼び出し側は待たずに次へ進みます。

go download("a.csv")
go download("b.csv")

ただし、mainが終了すると実行中のgoroutineも終わります。待つ仕組みが必要です。

WaitGroupで終了を待つ

var wg sync.WaitGroup

files := []string{"a.csv", "b.csv", "c.csv"}

for _, file := range files {
    wg.Add(1)

    go func(name string) {
        defer wg.Done()
        download(name)
    }(file)
}

wg.Wait()
fmt.Println("all done")

Add(1)で待つ仕事を追加し、終了時にDone()、最後にWait()します。

Done()を忘れると永遠に待つため、goroutineの先頭でdefer wg.Done()を書くと漏れにくくなります。

channelは値を渡す通路

results := make(chan string)

go func() {
    results <- "completed"
}()

message := <-results
fmt.Println(message)

results <- valueで送り、value := <-resultsで受け取ります。channelは共有変数へ直接書き込む代わりに、処理間で値を受け渡す方法です。

バッファなしとバッファあり

unbuffered := make(chan int)
buffered := make(chan int, 3)

バッファなしchannelの送信は、受信側が受け取れるまで待ちます。バッファありchannelは空きがある間、送信側が値を置いて先へ進めます。

バッファを大きくすれば速くなるわけではありません。送信と受信の速度差を一時的に吸収するものとして考えます。

結果とエラーをまとめて送る

type Result struct {
    File string
    Size int64
    Err  error
}

func inspect(path string, ch chan<- Result) {
    info, err := os.Stat(path)
    if err != nil {
        ch <- Result{File: path, Err: err}
        return
    }

    ch <- Result{
        File: path,
        Size: info.Size(),
    }
}

channelの方向をchan<- Resultと書くと、この関数は送信だけを行う意図が分かります。

channelを閉じるのは送信側

複数の結果をrangeで受け取る場合、channelを閉じないと受信側は次の値を待ち続けます。

results := make(chan Result)
var wg sync.WaitGroup

for _, file := range files {
    wg.Add(1)
    go func(name string) {
        defer wg.Done()
        inspect(name, results)
    }(file)
}

go func() {
    wg.Wait()
    close(results)
}()

for result := range results {
    fmt.Println(result)
}

原則として、もう送らないことを判断できる送信側が閉じます。受信側が勝手に閉じると、その後の送信でpanicになる可能性があります。

worker poolで同時実行数を制限する

ファイルが1万件ある場合、1万goroutineを無条件に作るより、3人のworkerへ仕事を配るほうが安全な場合があります。

jobs := make(chan string)
results := make(chan Result)

for i := 0; i < 3; i++ {
    go func(workerID int) {
        for path := range jobs {
            inspect(path, results)
        }
    }(i)
}

実際には終了管理も必要ですが、考え方は「goroutineの数を仕事件数ではなく、許容する並行数に合わせる」です。外部APIやDBへアクセスする処理では特に重要です。

contextでキャンセルする

HTTPリクエストがキャンセルされたのに裏側の処理だけ続くと、無駄な負荷になります。長い処理にはcontext.Contextを渡し、キャンセルやタイムアウトを伝えます。

select {
case <-ctx.Done():
    return ctx.Err()
case result := <-results:
    return handle(result)
}

よくある失敗

共有変数を同時に変更する

複数goroutineが同じmapや変数へ書くとdata raceになる可能性があります。channelで一か所に集めるか、mutexで保護します。

go test -race ./...

goroutineの中のエラーを捨てる

goroutineは普通の戻り値を呼出元へ直接返せません。結果用channel、errgroupなど、エラーを回収する設計が必要です。

channelを閉じれば停止すると思う

closeは「もう値を送らない」という通知であり、実行中goroutineを強制停止する機能ではありません。停止にはcontextなどを使います。

まとめ

  • goroutine:仕事を並行して進める
  • WaitGroup:複数の仕事の終了を待つ
  • channel:値や結果を受け渡す
  • worker pool:同時実行数を制限する
  • context:キャンセルと期限を伝える

goroutineを増やせば必ず速くなるわけではありません。CPU、ネットワーク、DB、外部APIなど、どこが制約なのかを見て使う必要があります。

まずは3 worker程度の小さな処理を作り、ログへworker番号を出すと、仕事が分担される様子を理解しやすいです。

参考資料