はじめに (対象読者・この記事でわかること)

この記事は、Goに興味があるが並列処理に手を出していなかった方、非同期処理をシンプルに書きたい方を対象としています。
記事を読むと、goroutineの起動方法、channelによるデータのやり取り、並列処理で陥りがちな競合を避けるための基本的なパターンを実装レベルで理解できます。サンプルコードをコピペ&実行するだけで手元で動作を確かめながら学習できます。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - Goの基本文法(変数宣言、関数定義、for文) - 並列(parallel)と並行(concurrent)の違いをぼんやりと知っていること

Go並列処理の魅力:なぜgoroutineなのか

Goが誇る軽量スレッド「goroutine」は、数万単位で起動してもメモリ1〜2 KB程度と圧倒的に軽いため、I/O待機が多いWebサービスやマイクロサービスで高い生産性を発揮します。
OSスレッドを使わないため、スケジューラの切り替えコストが小さく、CPUコア数を超える並行処理を高速に実行できます。channelによるデータ受け渡しは、ミューテックスを使わずに「通信によって共有メモリを避ける」設計思想を体現し、デッドロックのリスクを軽減します。

goroutineとchannelで実装する並列パターン

goroutineの起動と待ち合わせ(sync.WaitGroup)

まずは最速で並列処理を体験してみましょう。複数のURLにHTTP GETしてレスポンスボディの長さを集計するサンプルを考えます。

Go
package main import ( "fmt" "net/http" "sync" ) func fetch(url string, wg *sync.WaitGroup, result chan<- int) { defer wg.Done() resp, err := http.Get(url) if err != nil { result <- 0 return } defer resp.Body.Close() buf := make([]byte, 4096) n, _ := resp.Body.Read(buf) result <- n } func main() { urls := []string{ "https://example.com", "https://golang.org", "https://github.com", } var wg sync.WaitGroup sizeCh := make(chan int, len(urls)) for _, u := range urls { wg.Add(1) go fetch(u, &wg, sizeCh) } wg.Wait() close(sizeCh) total := 0 for s := range sizeCh { total += s } fmt.Println("total bytes:", total) }

go fetch(...) 1行で並列実行。sync.WaitGroupで全goroutineの終了を待ち、チャネルで結果を回収します。

channelだけで完結するパイプライン

channelを使うと、並列パイプラインが直感的に書けます。文字列を受け取り、大文字に変換して、フィルタをかける処理を考えます。

Go
package main import ( "fmt" "strings" "time" ) func producer(words []string) <-chan string { out := make(chan string) go func() { defer close(out) for _, w := range words { out <- w time.Sleep(100 * time.Millisecond) } }() return out } func toUpper(in <-chan string) <-chan string { out := make(chan string) go func() { defer close(out) for w := range in { out <- strings.ToUpper(w) } }() return out } func filter(in <-chan string, prefix string) <-chan string { out := make(chan string) go func() { defer close(out) for w := range in { if strings.HasPrefix(w, prefix) { out <- w } } }() return out } func main() { words := []string{"apple", "Application", "banana", "ApplePie"} pipe := producer(words) pipe = toUpper(pipe) pipe = filter(pipe, "APP") for w := range pipe { fmt.Println(w) } }

channelを返す関数を連結するだけで、並列パイプラインが構築できます。

for-selectでタイムアウト・キャンセルに対応

本番運用ではタイムアウトやキャンセルが必須です。contextselectを使って安全に終了させましょう。

Go
package main import ( "context" "fmt" "time" ) func heavy(ctx context.Context) <-chan string { out := make(chan string) go func() { defer close(out) for i := 0; i < 5; i++ { select { case <-ctx.Done(): fmt.Println("canceled:", ctx.Err()) return case out <- fmt.Sprintf("step %d", i): time.Sleep(1 * time.Second) } } }() return out } func main() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() for msg := range heavy(ctx) { fmt.Println(msg) } }

context.WithTimeoutで3秒後にキャンセルシグナルが送られ、goroutine内のselectで検知して早期リターンします。

[ハマった点やエラー解決]

  1. goroutineが暴走して終了しない
    原因:チャネルをcloseしていない、またはrangeループが終了条件を見ていない。
  2. データ競合(data race)で値がおかしい
    原因:複数のgoroutineが同じ変数を同時に読み書き。
  3. 遅延メモリリーク
    原因:チャネルにバッファを設定しすぎ、またはgoroutine内で無限ループでreturnしていない。

解決策

  1. sync.WaitGroupで必ず終了を待つ、またはcontextでキャンセル。
  2. 共有変数へのアクセスはsync.Mutexで保護、またはchannel経由で排他。
  3. チャネルはバッファサイズを最小に、defer closeを忘れずに。静的解析にはgo run -raceを常用してdata raceを早期発見。

まとめ

本記事では、Goの並列処理の基礎であるgoroutineの起動、channelによるデータ受け渡し、並列パイプラインの組み方、タイムアウト・キャンセルの実装を解説しました。

  • goroutineは1行で起動でき、数万単位でも軽量
  • channelで「通信により共有メモリを避ける」設計が可能
  • for-selectcontextで安全にキャンセル制御

この記事を通して、非同期処理をシンプルに記述するGoの強みを実感していただけたでしょうか。
次回は、worker poolパターンやfan-in/fan-out、エラーグルーピングを活用した本格的な並列プログラミングについて深掘りします。

参考資料