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 への送信は panic send 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 を検出する習慣を身につけることです。

golanggoroutinechannelconcurrency