Goにおける並列処理の常用パターン

3 min read読了の目安(約2800字

WEBのバックエンド開発でGo言語を採用する理由の一つに、並列処理が簡単に実装できる goroutine を使いたいというのがあるかと思います。
しかしひとえに goroutine といっても色んな書き方があり、どれを使うのが良い感じなの?落とし穴は?等は気になる所だと思います。
なので私見ではありますが、自分がよく使うパターンを残したいと思います。

パターン

errgroup

Goで何かしらの処理を書くときは適切なエラーハンドリングが切っても切り離せないと思いますので、それに特化した errgroup を使っています。

// サンプル準備
arr := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}
newArr := []int{}

// 並列処理を開始
eg := errgroup.Group{}
mutex := sync.Mutex{}
for _, v := range arr {
    // ループする時はちゃんと値をコピーしないと1つが複数回実行されてしまう
    v := v

    eg.Go(func() error {
        // 何かしらの処理(もしエラーが出たらerrを返す)
        fmt.Println(v)

        // 値をsliceやmapに格納する時は排他制御する
        mutex.Lock()
        newArr = append(newArr, v)
        mutex.Unlock()

        time.Sleep(time.Second * 1)
        return nil
    })
}
if err := eg.Wait(); err != nil { // 実行が終わるまで待つ
    // エラーハンドリング
    fmt.Println(err)
    return
}

fmt.Println(newArr)

errgroup(最大実行数制御ver)

最大同時実行件数が予測できない時は実行数に制限掛けたくなるよね。

// サンプル準備
ctx := context.Background()
arr := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}
newArr := []int{}

// 並列処理を開始
eg := errgroup.Group{}
mutex := sync.Mutex{}
sem := semaphore.NewWeighted(3) // 最大数を3に設定
for _, v := range arr {
    // ループする時はちゃんと値をコピーしないと1つが複数回実行されてしまう
    v := v

    sem.Acquire(ctx, 1)
    eg.Go(func() error {
        // 何かしらの処理(もしエラーが出たらerrを返す)
        fmt.Println(v)
        time.Sleep(time.Second * 1)

        // 値をsliceやmapに格納する時は排他制御する
        mutex.Lock()
        newArr = append(newArr, v)
        mutex.Unlock()

        sem.Release(1)
        return nil
    })
}
if err := eg.Wait(); err != nil { // 実行が終わるまで待つ
    // エラーハンドリング
    fmt.Println(err)
    return
}

fmt.Println(newArr)

以上です(^q^)

え、少なっ!
と思われたかもしれませんが、自分はこの2つくらいしか使わなかったです。
他にも色々ありますがほぼ使わなかったです。

  • channelを使った並列処理
    • slice格納時にちゃんと排他制御してれば問題ない
    • コードの見た目が直感的でわかりやすい
    • sliceを使えばサイズも柔軟で慣れていて使いやすい
  • errgroupのcontextを使った処理のキャンセル
    • 途中で失敗したとして、既に平行に実行されて終わっている処理が複数あるので、頑張ってキャンセルするメリットが薄い
    • そもそもそこまでセンシティブな処理は並列処理にするべきではない
    • どうしてもやりたい場合はワーカー構築してちゃんと制御したほうが良い

気をつける事

本当に並列処理が必要なのか立ち止まって考える

大前提として、並列処理を使わないで済むなら使わないに越したことはないです。
再現性が低く致命的なバグが埋め込まれやすくなったり、DB等に想定外の負荷が掛かったり、コードをメンテするエンジニアのハードルが上がったり等。
なので5分〜10分で良いので一度立ち止まって設計を見直し、I/Fやロジックやクエリの工夫でどうにかならないかは考えるべきです。
その上で有用であると判断できたら使うようにするのが良いかと思います。

最大負荷をちゃんとイメージする

Goは並列処理を気軽に実装できますが、軽い気持ちで多用すると本番環境で想定外の負荷がかかって大障害発生とかよくある話です。
自分は主に以下の点を考えるようにしています。

  • 最大同時実行件数はどのくらいかざっくり計算
  • 他の高負荷処理とバッティングしないか?
  • 最大同時実行時にマシンリソース(CPU、メモリ等)は大丈夫か?
  • 外部リソース(外部APIのRateLimit、DB)への負荷や料金は大丈夫か?

結果の格納には細心の注意を払う

忘れがちですが、sliceやmapは排他制御しないと不整合が起きます。
コンパイルエラーでは気づけないので、自分や他人の目を使って確認しましょう。(lintとか静的解析でわかる?)

mutex := sync.Mutex{}

...

mutex.Lock()
newArr = append(newArr, v)
mutex.Unlock()

まとめ

バックエンドを開発していると、連携先のAPIが遅かったり、DB接続が多く必要になってAPIのパフォーマンスが下がる等はよくあります。
そういう時に並列処理は解決のためのひとつのカードになりますので、適切に使えると良いエンジニアライフを送れるようになるかと思います。