0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goの並行処理でやりがちなミス5選

0
Posted at

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.Mutex
  • sync/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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?