Goroutine と Channel: Go における concurrency の基礎
1. 概要
Go は callback hell や複雑なスレッドプールなしに、簡単に concurrent プログラムを書けるように設計されています。二つの核となる構成要素は goroutine(軽量な実行単位)と channel(goroutine 間の通信チャネル)です。この記事を読み終わる頃には、goroutine の起動方法、sync.WaitGroup による同期、channel を通じた安全な通信、複数の channel を同時に処理する select の使い方、そして goroutine leak や deadlock といった一般的な落とし穴を避ける方法がわかるでしょう。
すべての例は Go 1.22 以上を使用しており、外部ライブラリは不要です — go run main.go で動かせます。
2. Goroutine とは
Goroutine は Go ランタイムに管理される独立した関数実行です。OS のスレッドではなく、はるかに軽量です。goroutine の作成コストは非常に低く(スタック数 KB、自動スケーリング)、数万個の goroutine でもシステムリソースを枯渇させる心配がありません。
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Println("こんにちは,", name)
}
func main() {
go sayHello("Order Service")
go sayHello("Payment Service")
time.Sleep(100 * time.Millisecond) // goroutine の実行を待つ
fmt.Println("Main 終了")
}
go キーワードを関数呼び出しの前に付けると、その関数が新しい goroutine で実行され、main はブロックされません。main が 2 つの goroutine より先に終了する可能性があるため、time.Sleep で待つ必要があります — これは 信頼できない方法であり、単なる説明用です。出力は以下のようになる可能性があります:
こんにちは, Payment Service
こんにちは, Order Service
Main 終了
出力順序が確定していないのは、スケジューラーが実行する goroutine の順序を決めるためです。これが concurrency の本質です:複数の作業が「同時に」進行しますが、順序は保証されません。
注記: 実際のコードで goroutine を同期するために
time.Sleepを使うのは反パターンです。セクション 4 でsync.WaitGroupの使用方法を紹介します。
3. Channel: goroutine 間の通信チャネル
Channel は goroutine がデータを送受信するための型安全なパイプです。Go の哲学は「メモリを共有することで通信するのではなく、通信することでメモリを共有する」というものです。
package main
import "fmt"
func calculateTotal(amount float64, result chan<- float64) {
tax := amount * 0.1
result <- amount + tax
}
func main() {
result := make(chan float64)
go calculateTotal(1_000_000, result)
total := <-result
fmt.Printf("注文の合計金額: %.0f\n", total)
}
make(chan float64)は unbuffered channel を作成します。- 関数パラメータの
chan<- float64は channel が 送信専用(send-only)であることを示し、コンパイラが誤りを防ぎます。 result <- amount + taxは channel に値を送信します。mainの<-resultは ブロックし、データが到着するまで待ちます — 自然な同期メカニズムであり、Sleepは不要です。
注文の合計金額: 1100000
3.1. Unbuffered vs Buffered Channel
Unbuffered channel(make(chan T))は送信者と受信者が同時に「出会う」必要があります — 受信者が準備できるまで送信はブロックされます。Buffered channel(make(chan T, n))は n 個までの値を受信者を待たずに送信できます。
package main
import "fmt"
func main() {
orders := make(chan string, 3)
orders <- "Order-1"
orders <- "Order-2"
orders <- "Order-3"
close(orders)
for order := range orders {
fmt.Println("処理:", order)
}
}
バッファのサイズが 3 なので、3 つの送信はブロックされません。close(orders) は送信終了を示し、range ループが次の値を無限に待つのではなく、自動的に終了します。
処理: Order-1
処理: Order-2
処理: Order-3
注記: channel の 送信側だけが
closeを呼べます。閉じた channel への送信は panicsend on closed channelを引き起こします。閉じた channel からの読み取りは有効で、ゼロ値とok == falseを返します。
4. WaitGroup で複数の goroutine を同期する
複数の goroutine の完了を待つ必要があるが、戻り値は気にしない場合、sync.WaitGroup は channel より簡潔な選択肢です。
package main
import (
"fmt"
"sync"
)
func processOrder(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Order-%d を処理しました\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go processOrder(i, &wg)
}
wg.Wait()
fmt.Println("すべての注文の処理が完了しました")
}
wg.Add(1)は goroutine 実行前にカウンタを増やします — race condition を避けるため、goroutine 内ではなく、必ず 前に 呼びます。defer wg.Done()は goroutine 終了時にカウンタを減らします。wg.Wait()はカウンタが 0 になるまで main をブロックします。
Order-2 を処理しました
Order-1 を処理しました
Order-3 を処理しました
すべての注文の処理が完了しました
最初の 3 行の順序は不確定ですが、最後の行は常に最後に出力されます — これが WaitGroup が保証することです。
4.1. Concurrent 動作をテストする
すべての goroutine が完了することを確認するテストを書くことができます。出力順序に頼らず、結果カウント channel を使用してください:
package orderpkg
import (
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessOrders_AllCompleted(t *testing.T) {
var wg sync.WaitGroup
var processed int32
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
atomic.AddInt32(&processed, 1)
}(i)
}
wg.Wait()
assert.Equal(t, int32(5), processed)
}
atomic.AddInt32 は複数の goroutine が同時にカウント変数を増やすときの race condition を防ぎます — 通常の processed++ を使うと、go test -race が data race を報告します。
テスト用の依存関係(go.mod に追加):
go get github.com/stretchr/testify
5. Select: 複数の channel を同時に処理する
select は channel 用の switch のようなもので — どの case が最初に準備できるかを選択して実行します。複数のデータソースを待つ場合やタイムアウトを設定するときに非常に便利です。
package main
import (
"fmt"
"time"
)
func fetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "キャッシュからのデータ"
}
func fetchFromDB(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "データベースからのデータ"
}
func main() {
cacheCh := make(chan string)
dbCh := make(chan string)
go fetchFromCache(cacheCh)
go fetchFromDB(dbCh)
select {
case res := <-cacheCh:
fmt.Println("受け取ったデータ:", res)
case res := <-dbCh:
fmt.Println("受け取ったデータ:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("タイムアウト、デフォルトデータを使用")
}
}
select は何らかの case がデータ準備完了するまでブロックされます。キャッシュは 50ms 後に、DB は 200ms 後に返すため、cacheCh case が勝ちます:
受け取ったデータ: キャッシュからのデータ
time.After は指定時間後に channel に値を送信し、select 全体のタイムアウトを実装するのに使用されます。
6. Context で goroutine を正しくキャンセルする
一般的なバグは goroutine を起動するが、それを停止する方法がない場合です — goroutine leak となります。context パッケージは call chain 全体にキャンセルシグナルを渡すことでこの問題を解決します。
package main
import (
"context"
"fmt"
"time"
)
func monitorPayment(ctx context.Context, orderID string) {
for {
select {
case <-ctx.Done():
fmt.Println("注文の監視を停止:", orderID, "-", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Println("注文の状態を確認:", orderID)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
go monitorPayment(ctx, "Order-99")
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // goroutine が最後の行を出力するのを待つ
}
context.WithTimeoutは 1200ms 後に自動的にDone()する context を作成します。monitorPaymentでは、selectはctx.Done()と定期的なチェックの両方をリッスンします。context がタイムアウトすると、goroutine はクリーンに終了し、永遠に実行されません。defer cancel()は関数が早期に終了した場合でも context リソースを確実に解放します。
注文の状態を確認: Order-99
注文の状態を確認: Order-99
注文の監視を停止: Order-99 - context deadline exceeded
注記:
WithTimeoutまたはWithCancelを使用するかどうかにかかわらず、必ずcancel()を呼び出します(defer経由)。この手順を忘れても即座にエラーにはなりませんが、長期間実行されるループでは context リークが発生します。
7. 一般的なエラー
Channel に受信者がないため deadlock。 Unbuffered channel に送信しても誰も読まないと、プログラムは動かず、Go ランタイムが自動検出します:
func main() {
ch := make(chan int)
ch <- 1 // 誰も読まない → deadlock
}
fatal error: all goroutines are asleep - deadlock!
Goroutine leak。 Goroutine が永遠に channel 読み取りを待ち続ける(誰も送らず、キャンセル機構がない) — メモリはプログラム終了まで保持されます。長期実行 goroutine を起動するとき、常に context または “done” channel を含めます。
ループ変数キャプチャバグ(Go 1.22 前)。 Go < 1.22 では for ループの変数 i はすべての反復間で共有されるため、すべての goroutine が同じ最終値を見る可能性があります。Go 1.22 以降、各ループが新しい i を作成するため、この問題は解決しました — ただしプロジェクトで古いバージョンを使う場合は、セクション 4.1 の例のようにパラメータで変数を渡します。
変数共有時の race condition。 2 つの goroutine が channel または sync/atomic を通さずに同じ変数を読み書きする場合、これは古典的な race condition です。開発フェーズで go test -race または go run -race を常に実行して、早期に検出してください。
Goroutine を使わない時: タスクが短い場合、goroutine 作成と同期のコストが利益を上回ることがあります。マイクロ秒単位の作業の場合、順序実行の方が通常は高速で読みやすいです。
8. まとめ
Goroutine は非常に低いコストで実行スレッドを作成する方法を提供し、channel はメモリの直接共有ではなく安全な通信メカニズムを提供します。sync.WaitGroup で同期し、select で複数ソースを処理し、context で適切なタイミングでキャンセルすることで、Go の一般的な concurrency パターン — worker pool、pipeline、fan-in/fan-out などを構築するための十分なツールセットが揃います。次のステップは go test -race を使って実践し、早期に race condition を検出する習慣を身につけることです。