Goを学んでいて、楽しいと感じやすいポイントのひとつが並行処理です。
- goroutineが軽い
- channelで通信できる
- 標準ライブラリだけでもかなり書ける
- 非同期処理を自然に扱える
このあたりは、Goを好きになる理由としてかなり強いです。
ただ、その一方で
動くコード と 安全なコード は全然別です。
最初はうまく動いて見えても、実務で件数やアクセスが増えるとこうなりがちです。
- goroutineが増え続ける
- channelの詰まりで止まる
- race conditionでたまに壊れる
- エラーが拾えない
- いつ終わるのか分からない
この記事では、Goで並行処理を書き始めた人がやりがちなミスを5つに絞って紹介します。
どれもよくある落とし穴なので、先に知っておくだけでかなり事故を減らせます。
1. goroutineを増やせば速くなると思ってしまう
最初にやりがちなのがこれです。
for _, job := range jobs {
go process(job)
}
コードはすごくシンプルですし、Goっぽく見えます。
でも、件数が増えた瞬間に危なくなります。
- goroutine数が制御できない
- CPUやメモリを食い潰す
- DB接続や外部APIの同時実行数を超える
- 全体が逆に遅くなる
並行処理は たくさん動かすこと ではなく、適切な数で動かすこと が大事です。
たとえばワーカープールにするとかなり安全になります。
func worker(jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
process(job)
}
}
func main() {
jobs := make(chan int)
var wg sync.WaitGroup
workerNum := 3
for i := 0; i < workerNum; i++ {
wg.Add(1)
go worker(jobs, &wg)
}
for _, job := range []int{1, 2, 3, 4, 5, 6} {
jobs <- job
}
close(jobs)
wg.Wait()
}
goroutineは軽いですが、無限に雑に増やしていいわけではありません。
同時実行数を制御するだけで、かなり実務的なコードになります。
2. channelを閉じる責任者が曖昧
Goの並行処理で混乱しやすいのが、channelを誰が閉じるのか問題です。
たとえば、こういうコードは事故の元です。
func producer(ch chan int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
func anotherProducer(ch chan int) {
for i := 10; i < 15; i++ {
ch <- i
}
close(ch)
}
複数の送信側が同じchannelを閉じようとすると、panicの原因になります。
channelの原則はかなりシンプルです。
- closeするのは送信側
- しかも、最後の送信側だけ
- 受信側は基本的にcloseしない
送信元が複数あるなら、雑にcloseせず、WaitGroup などで終端を管理してから最後に閉じるほうが安全です。
func main() {
ch := make(chan int)
var wg sync.WaitGroup
producer := func(start int) {
defer wg.Done()
for i := start; i < start+5; i++ {
ch <- i
}
}
wg.Add(2)
go producer(0)
go producer(10)
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
誰がcloseするのかが曖昧なコードは、だいたいそのうち壊れます。
channelは通信の仕組みであると同時に、終了の契約でもあると考えると整理しやすいです。
3. 終了条件がなく、goroutineリークする
Goの並行処理で一番怖いのが、気づきにくいリークです。
たとえばこういうコードです。
func startWorker(ch <-chan int) {
go func() {
for v := range ch {
fmt.Println(v)
}
}()
}
一見問題なさそうですが、ch が閉じられなかったり、呼び出し側の事情で処理が不要になったりすると、goroutineが残り続けることがあります。
Web APIやバッチでは、途中キャンセルできることがかなり重要です。
そのために context.Context を使います。
func worker(ctx context.Context, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
fmt.Println(v)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go worker(ctx, ch, &wg)
ch <- 1
ch <- 2
cancel()
close(ch)
wg.Wait()
}
goroutineを書くときは、どう始めるかより、どう終わるかを先に考えたほうがうまくいきます。
4. エラーを捨てたまま並行処理してしまう
goroutineの中でエラーが出ても、雑に書くと回収できません。
for _, job := range jobs {
go func(job int) {
if err := process(job); err != nil {
fmt.Println(err)
}
}(job)
}
これだと、その場でログを出すくらいしかできず、
- どれか1つでも失敗したら止めたい
- 最初のエラーを呼び出し元に返したい
- 全体失敗として扱いたい
みたいな要件に対応しにくいです。
そういうときは、エラーをchannelで集めるか、errgroup のような考え方でまとめて扱うのが定番です。
まずは素朴なchannel版です。
func main() {
jobs := []int{1, 2, 3, 4}
errCh := make(chan error, len(jobs))
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(job int) {
defer wg.Done()
if err := process(job); err != nil {
errCh <- fmt.Errorf("job %d failed: %w", job, err)
}
}(job)
}
wg.Wait()
close(errCh)
for err := range errCh {
fmt.Println("error:", err)
}
}
並行処理は成功パターンだけ見ていると危険です。
失敗したときにどう集約するかまで決めておくと、かなり実務向けになります。
5. 共有変数を雑に触ってrace conditionを起こす
これもかなり多いです。
count := 0
for i := 0; i < 1000; i++ {
go func() {
count++
}()
}
見た目は単純ですが、これは安全ではありません。
複数goroutineが同じ変数を同時に更新すると、値が壊れることがあります。
こういうときは、少なくとも次のどちらかを使います。
sync.Mutexsync/atomic
まずはMutex版です。
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(count)
単純なカウンタならatomicも使えます。
var count int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&count, 1)
}()
}
wg.Wait()
fmt.Println(count)
Goは並行処理が書きやすいぶん、共有メモリを雑に触っても書けてしまいます。
だからこそ、共有するなら守る、守れないなら共有しない、を意識したほうが安全です。
まとめ
Goの並行処理でやりがちなミスを5つ紹介しました。
もう一度まとめると、こんな感じです。
- goroutineを無制限に増やさない
- channelを閉じる責任者を明確にする
- 終了条件を作ってgoroutineリークを防ぐ
- エラーを回収する設計にする
- 共有変数はMutexやatomicで守る
Goの並行処理は強力ですが、なんとなく書くと簡単に危ないコードになります。
逆にいうと、この5つを意識するだけでかなり安定したコードになります。
これからGoでWeb APIやバッチを書く人は、
速く動かすこと よりも、壊れずに止まれること を先に意識したほうが、結果的にいい設計になりやすいです。
もし反応が良ければ次は、
- Goのテストでやりがちなミス5選
- Goのinterface設計でやりがちなミス
- Goのディレクトリ構成で迷ったときの考え方
あたりも書けます。
以下はGoのUdemyの講座の半額クーポン自分の講座
https://www.udemy.com/course/go-31100/?couponCode=818EB20AF096FC6CC4FD