クイズで学ぶGoの並行処理:channelとgoroutineの陥りやすいミスとその対策
はじめに
Go言語を使う際に避けて通れない並行処理について、特に初学者がつまずきやすいchannel
とgoroutine
、および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をクローズ
}
この例では、channel
とgoroutine
を使って文字列「hoge」を送受信しています。channel
はmake
関数で初期化し、<-
を使って値の送受信を行います。また、使用後にはclose
関数を使ってchannel
を閉じることで、もうデータの送受信が行われないことを明示します。
第1問
次に、少し変わったコードを見てみましょう。
func main() {
c := make(chan string)
c <- "hoge"
fmt.Println(<-c)
close(c)
}
このコードの出力はどうなるでしょうか?
hoge
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 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)
}
このコードの出力はどうなるでしょうか?
hoge
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 4
解説: このコードはクローズされたchannel
に値を送信しようとしたため、実行時エラーが発生します。channel
を閉じた後に値を送信することは許されておらず、このようなコードはpanic
を引き起こします。
第3問
次は以下のコードを考えてみてください。
func main() {
c := make(chan string)
go func() {
c <- "hoge"
}()
close(c) // 取り出し前にクローズ
fmt.Println(<-c)
}
このコードの出力はどうなるでしょうか?
hoge
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 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)
}
}
このコードは、順次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)
}
1 DONE: 1s
3 DONE: 1s
2 DONE: 2s
4 DONE: 2s
0 DONE: 3s
このコードでは、channel
とgoroutine
を使って並行処理が実現されています。それにより、各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)
}
- 処理時間順に出力(並列処理)
- ループ順に出力(並列処理されない)
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 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)
}
- 処理時間順に出力(並列処理)
- ループ順に出力(並列処理されない)
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 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")
}
このコードでは、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")
}
第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")
}
このコードの出力はどうなるでしょうか?
-
DONE
が出力 - 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 1 or 4
解説: この場合、wg.Done()
が6回呼び出されるため、5回目の呼び出しでwg.Wait()
が解除され、「DONE」が出力される可能性があります。しかし、各goroutine
の終了タイミングによっては、WaitGroup
のカウンターがマイナスになり、panic: sync: negative WaitGroup counter
という実行時エラーが発生する可能性もあります。特に並行処理のタイミング次第では、想定通りの結果が得られないことがあるので注意が必要です。
sync.WaitGroupとchannelを組み合わせたパターン
sync.WaitGroup
とchannel
を組み合わせることで、より柔軟な並行処理が可能です。次のコードを見てみましょう。
第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)
}
このコードの出力はどうなるでしょうか?
- 処理時間順に出力(並列処理)
- ループ順に出力(並列処理されない)
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 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)
}
このコードの出力はどうなるでしょうか?
- 処理時間順に出力(並列処理)
- ループ順に出力(並列処理されない)
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 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)
}
このコードの出力はどうなるでしょうか?
- 処理時間順に出力(並列処理)
- ループ順に出力(並列処理されない)
- 出力なし
- コンパイルエラー
- 実行時エラー
解答
解答: 5
解説: このコードでは、channel
がunbuffered
であるため、値が送信された後、受信されるまで次のgoroutine
がブロックされます。しかし、ここではwg.Wait()
によって全てのgoroutine
が終了するまで待たれてしまうため、channel
が詰まってデッドロックが発生します。このようなケースでは、buffered channel
を使用することで、送信された値を一時的にバッファリングし、goroutine
が詰まるのを防ぐことができます。
修正例として、c := make(chan string, 5)
とすることで、この問題を回避できます。これにより、5つの値がバッファに収まるようになり、goroutine
が正常に終了します。
まとめ
この記事では、Go言語におけるchannel
とgoroutine
、およびsync.WaitGroup
の基本的な使い方をクイズ形式で学んできました。
Goの並行処理は非常に強力で効率的ですが、間違った使い方をするとデッドロックや実行時エラーが発生しやすくなります。特に、channel
の送受信のタイミングや、WaitGroup
のカウンターの操作には注意が必要です。
参考
- Goでの並行処理を徹底解剖! さき(H.Saki)
「物流の次を発明する」をミッションに物流のシェアリングプラットフォームを運営する、ハコベル株式会社 開発チームのテックブログです! 【エンジニア積極採用中】t.hacobell.com/blog/career
Discussion