💯

クイズで学ぶGoの並行処理:channelとgoroutineの陥りやすいミスとその対策

2024/09/04に公開

はじめに

Go言語を使う際に避けて通れない並行処理について、特に初学者がつまずきやすいchannelgoroutine、およびsync.WaitGroupの基本的な使い方をクイズ形式で解説します。
Goの並行処理は非常に強力ですが、正しい理解と使い方をマスターするためには、いくつかの落とし穴に注意する必要があります。
この記事では、それらのポイントを具体的なコード例を通じて学んでいきます。

基本的なchannelの使い方

Goでプロダクトを作っていると、異なるgoroutine間でデータをやり取りするために channel を用いることがよくあります。以下はその基本的な使い方を示すコードです。

package main

import "fmt"

func main() {
	c := make(chan string) // channelを初期化
	go func() {            // goroutineを生成
		c <- "hoge"    // channelに送信
	}()
	fmt.Println(<-c)       // channelから受信
	close(c)               // channelをクローズ
}

Go Playground

この例では、channelgoroutineを使って文字列「hoge」を送受信しています。channelmake関数で初期化し、<-を使って値の送受信を行います。また、使用後にはclose関数を使ってchannelを閉じることで、もうデータの送受信が行われないことを明示します。

第1問

次に、少し変わったコードを見てみましょう。

func main() {
	c := make(chan string)
	c <- "hoge"
	fmt.Println(<-c)
	close(c)
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. hoge
  2. 出力なし
  3. コンパイルエラー
  4. 実行時エラー
解答

解答: 4
解説: このコードは実行時エラーになります。バッファなしのchannelに値を送信する際、送信は別のgoroutine内で行う必要があります。上記のコードではメインのgoroutineで直接送信を試みているため、受信側が存在せずデッドロックが発生し、結果として実行時エラーが発生します。
c := make(chan string, 1)とバッファありのchannelを作成することで、エラーなくプログラムが終了します。Go Playground

第2問

続いて、別の例を見てみましょう。

func main() {
	c := make(chan string)
	close(c) // 送信前にクローズ
	go func() {
		c <- "hoge"
	}()
	fmt.Println(<-c)
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. hoge
  2. 出力なし
  3. コンパイルエラー
  4. 実行時エラー
解答

解答: 4
解説: このコードはクローズされたchannelに値を送信しようとしたため、実行時エラーが発生します。channelを閉じた後に値を送信することは許されておらず、このようなコードはpanicを引き起こします。

第3問

次は以下のコードを考えてみてください。

func main() {
	c := make(chan string)
	go func() {
		c <- "hoge"
	}()
	close(c) // 取り出し前にクローズ
	fmt.Println(<-c)
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. hoge
  2. 出力なし
  3. コンパイルエラー
  4. 実行時エラー
解答

解答: 2 or 4
解説: クローズされたchannelから値を取るとゼロ値(ここでは空文字列)が返ります。そのため、このコードは通常「出力なし」となりますが、稀にgoroutineの実行順序によって、channelクローズされた後に値を送信しようとした場合に実行時エラーが発生する可能性があります。
このようなケースを防ぐためには、 defer close(c) とすることで、channelのクローズし忘れを防ぎ、実行順序を気にせず安全にchannelを使用することができます。

複数goroutineを使ったchannel

次に、channelを使った並行処理の基本的なパターンを見てみましょう。

ベースコード:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

// 時間のかかるメソッド
// 終了までに1~3秒ランダムに時間がかかる
func slowMethod(v int) string {
	randSec := time.Duration(rand.Intn(3)+1) * time.Second
	time.Sleep(randSec)
	return fmt.Sprintf("%v DONE: %v", v, randSec)
}

func main() {
	for i := range 5 {
		ret := slowMethod(i)
		fmt.Println(ret)
	}
}

Go Playground

このコードは、順次slowMethodを実行してその結果を出力します。並行処理を行わないため、各メソッドが終了するまで次の処理が待たれます。

出力
0 DONE: 2s
1 DONE: 3s
2 DONE: 3s
3 DONE: 1s
4 DONE: 1s

channelを使用した並行処理に書き換えると以下のようになります。

func main() {
	c := make(chan string) // 1. channelを初期化
	for i := range 5 {
		go func(i int) {     // goroutineで並行処理
			c <- slowMethod(i) // 2. channelへ詰め込む
		}(i)
	}
	for _ = range 5 {
		fmt.Println(<-c)     // 3. channelから取り出す
	}
	close(c)
}

Go Playground

出力
1 DONE: 1s
3 DONE: 1s
2 DONE: 2s
4 DONE: 2s
0 DONE: 3s

このコードでは、channelgoroutineを使って並行処理が実現されています。それにより、各slowMethodが終了するたびに結果が出力され、処理全体の時間が短縮されます。

第4問

次のコードではどうなるでしょうか?

func main() {
	c := make(chan string)
	for i := range 5 { // ループは5回
		go func(i int) {
			c <- slowMethod(i)
		}(i)
	}
	for _ = range 10 { // ループを10回
		fmt.Println(<-c)
	}
	close(c)
}

Go Playground

  1. 処理時間順に出力(並列処理)
  2. ループ順に出力(並列処理されない)
  3. 出力なし
  4. コンパイルエラー
  5. 実行時エラー
解答

解答: 5
解説: channelに送信されるのは5回分だけなので、10回の受信を試みると5回目以降は待ち状態となり、最終的にデッドロックエラーが発生します。

第5問

次に、このコードを考えてみましょう。

func main() {
	c := make(chan string)
	for i := range 10 { // ループは10回
		go func(i int) {
			c <- slowMethod(i)
		}(i)
	}
	for _ = range 5 { // ループを5回
		fmt.Println(<-c)
	}
	close(c)
}

Go Playground

  1. 処理時間順に出力(並列処理)
  2. ループ順に出力(並列処理されない)
  3. 出力なし
  4. コンパイルエラー
  5. 実行時エラー
解答

解答: 1 ※ただし、先頭5つのみ出力
解説: この場合、5つの値を受け取った時点でmain関数が終了します。そのため、残りの5つのgoroutineは実行中のまま中断されます。このようなケースでは、意図的に待機を設けない限り、全てのgoroutineが実行される前にプログラムが終了することがあります。

sync.WaitGroupを使って全ての処理を待つパターン

全てのgoroutineが終了するまで待機する場合は、sync.WaitGroupを使うのが一般的です。

以下が成功するベースコードです:

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := range 5 {
		go func(i int) {
			_ = slowMethod(i)
			wg.Done()
		}(i)
	}
	wg.Wait() // wg.Add()で指定した数だけwg.Done()が呼び出されるまでブロック
	fmt.Println("DONE")
}

Go Playground

このコードでは、WaitGroupを使用して5つのgoroutineが全て終了するまで待機しています。wg.Add(5)で5回の待機を指定し、各goroutine内で処理が終わった後にwg.Done()を呼び出すことで待機カウンターをデクリメントします。全てのgoroutineが終了すると、wg.Wait()が解除され、「DONE」が出力されます。

待機とデクリメントの数が揃わない場合、fatal error: all goroutines are asleep - deadlock!が発生します。

func main() {
	var wg sync.WaitGroup
	wg.Add(6)          // 6回待つ
	for i := range 5 { // 5回Done()を呼び出す
		go func(i int) {
			_ = slowMethod(i)
			wg.Done()
		}(i)
	}
	wg.Wait()
	fmt.Println("DONE")
}

Go Playground

第6問

次に、こちらのコードを見てみましょう。

func main() {
	var wg sync.WaitGroup
	wg.Add(5)          // 5回待つ
	for i := range 6 { // 6回Done()を呼び出す
		go func(i int) {
			_ = slowMethod(i)
			wg.Done()
		}(i)
	}
	wg.Wait()
	fmt.Println("DONE")
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. DONEが出力
  2. 出力なし
  3. コンパイルエラー
  4. 実行時エラー
解答

解答: 1 or 4
解説: この場合、wg.Done()が6回呼び出されるため、5回目の呼び出しでwg.Wait()が解除され、「DONE」が出力される可能性があります。しかし、各goroutineの終了タイミングによっては、WaitGroupのカウンターがマイナスになり、panic: sync: negative WaitGroup counterという実行時エラーが発生する可能性もあります。特に並行処理のタイミング次第では、想定通りの結果が得られないことがあるので注意が必要です。

sync.WaitGroupとchannelを組み合わせたパターン

sync.WaitGroupchannelを組み合わせることで、より柔軟な並行処理が可能です。次のコードを見てみましょう。

第7問

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	c := make(chan string, 5)  // バッファありのchannelを初期化
	for i := range 5 {
		go func(i int) {
			c <- slowMethod(i)
			wg.Done()
		}(i)
	}
	wg.Wait()

	// channelから取り出す
	for _ = range 5 {
		fmt.Println(<-c)
	}
	close(c)
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. 処理時間順に出力(並列処理)
  2. ループ順に出力(並列処理されない)
  3. 出力なし
  4. コンパイルエラー
  5. 実行時エラー
解答

解答: 1
解説: このコードでは、正常に全ての処理が並行して実行され、処理時間順に出力されます。

第8問

次に、wg.Wait()のタイミングとchannelからデータを取り出す順番を入れ替えたコードを見てみましょう。

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	c := make(chan string, 5)
	for i := range 5 {
		go func(i int) {
			c <- slowMethod(i)
			wg.Done()
		}(i)
	}

	for _ = range 5 {
		fmt.Println(<-c)
	}

	// ここでwg.Wait()
	wg.Wait()
	close(c)
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. 処理時間順に出力(並列処理)
  2. ループ順に出力(並列処理されない)
  3. 出力なし
  4. コンパイルエラー
  5. 実行時エラー
解答

解答: 1
解説: このコードでも、正常に全ての処理が並行して実行され、処理時間順に出力されます。ただし、全てのgoroutineが完了する前にchannelからの受信が始まるため、全ての処理が完了していない可能性があります。すべての処理が完了したことを確認したい場合は、第7問のようにWaitGroupの待機を受信前に行う必要があります。

第9問

次に、こちらのコードを見てみましょう。

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	c := make(chan string) // バッファなしchannel初期化
	for i := range 5 {
		go func(i int) {
			c <- slowMethod(i)
			wg.Done()
		}(i)
	}
	wg.Wait()
	for _ = range 5 {
		fmt.Println(<-c)
	}
	close(c)
}

Go Playground

このコードの出力はどうなるでしょうか?

  1. 処理時間順に出力(並列処理)
  2. ループ順に出力(並列処理されない)
  3. 出力なし
  4. コンパイルエラー
  5. 実行時エラー
解答

解答: 5
解説: このコードでは、channelunbufferedであるため、値が送信された後、受信されるまで次のgoroutineがブロックされます。しかし、ここではwg.Wait()によって全てのgoroutineが終了するまで待たれてしまうため、channelが詰まってデッドロックが発生します。このようなケースでは、buffered channelを使用することで、送信された値を一時的にバッファリングし、goroutineが詰まるのを防ぐことができます。
修正例として、c := make(chan string, 5)とすることで、この問題を回避できます。これにより、5つの値がバッファに収まるようになり、goroutineが正常に終了します。

まとめ

この記事では、Go言語におけるchannelgoroutine、およびsync.WaitGroupの基本的な使い方をクイズ形式で学んできました。
Goの並行処理は非常に強力で効率的ですが、間違った使い方をするとデッドロックや実行時エラーが発生しやすくなります。特に、channelの送受信のタイミングや、WaitGroupのカウンターの操作には注意が必要です。

参考

  • Goでの並行処理を徹底解剖! さき(H.Saki)

https://zenn.dev/hsaki/books/golang-concurrency

Hacobell Developers Blog

Discussion