Go中級 #3 ゴルーチンとチャネル入門

#2 エラー処理パターンの次、ここからはGoの最大の強み — 並行性。軽量なゴルーチンとチャネルがどう動くかから整理します。

ゴルーチン — 軽量な並行実行単位 #

ゴルーチン起動
package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 3; i++ {
		fmt.Println(s)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go say("hello")    // ゴルーチンとして実行
	say("world")        // main ゴルーチンで実行

	time.Sleep(time.Second)
}

goキーワード1つで関数が新しいゴルーチンで並行実行されます。main関数の流れから分離されて動作します。

ゴルーチンはどう軽量なのか? #

OSスレッド1つは通常メモリ1MB〜2MB。ゴルーチンは起動時に約2KBです。数十万個のゴルーチンを作っても問題ありません。

Goランタイムが — 少数のOSスレッドの上に多くのゴルーチンを自動でスケジューリングします。ユーザーは並行性だけを表現し、実際にどう実行されるかはランタイムが処理します。

mainが終わるとすべてのゴルーチンが終了 #

よく出会う罠。

main 終わり — ゴルーチン終わり
func main() {
	go say("hello")
	// main がすぐ終わる — say が一行も出力できないかも
}

main関数が終わると — 進行中だったすべてのゴルーチンも即座に死にます。上のtime.Sleepはmainを十分待たせるためのものでした。実戦ではチャネルやWaitGroupのような同期ツールで待ちます。

チャネル — ゴルーチン間の通信 #

チャネルはゴルーチン間で値をやり取りするツールです。同期も一緒にしてくれます。

チャネル基本
func main() {
	ch := make(chan int)

	go func() {
		ch <- 42       // チャネルへ送る
	}()

	v := <-ch          // チャネルから受け取る
	fmt.Println(v)     // 42
}

<-は送るときは右へ、受け取るときは左へ向きます。直感的な矢印。

チャネルは同期ツール #

基本のチャネルはバッファなし(unbuffered)です。送信と受信が出会わないと進みません

  • 送る側: 受け取る側が準備できるまでブロック
  • 受け取る側: 送る側が準備できるまでブロック
同期の意味
ch := make(chan struct{})

go func() {
	doWork()
	ch <- struct{}{}    // 「終わった」シグナル
}()

<-ch                    // シグナルを受け取らないと main が進めない
fmt.Println("作業終了")

struct{}(空struct)がよく使われます — 値自体には意味がなく、シグナルだけが必要なとき。

バッファ付きチャネル — キューのように #

バッファ付きチャネル
ch := make(chan int, 3)    // 容量 3

ch <- 1     // ブロックなし
ch <- 2     // ブロックなし
ch <- 3     // ブロックなし
// ch <- 4  ← ここでブロック (バッファ満杯)

<-ch        // 1
<-ch        // 2
<-ch        // 3
// <-ch    ← ここでブロック (空)

容量が満杯なら送信がブロック、空なら受信がブロック。キューのように動作します。

バッファなしチャネル vs バッファ付きチャネル — デフォルトはバッファなしが安全です。バッファは明確な理由があるときだけ(throughput、一定数の作業のまとめなど)。

チャネルを閉じる — close #

送信側がもう送るものがないとき。

close
ch := make(chan int)

go func() {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch)    // もう送るものなし
}()

for v := range ch {    // close されると自動終了
	fmt.Println(v)
}
// 0, 1, 2, 3, 4

for range chパターンが最も一般的です。チャネルがcloseされると自動で終了します。

closeのルール #

  • 受信側はcloseしない — 送る側だけ
  • すでに閉じたチャネルへsend → panic
  • すでに閉じたチャネルからreceive → 即座にzero valueを返す(ブロックなし)

closeされたか確認 — comma-ok #

close 検査
v, ok := <-ch
if !ok {
	fmt.Println("チャネル閉じた")
}

mapのcomma-okと同じパターン。okがfalseなら閉じていてもう受け取るものがないという意味。

WaitGroup — 複数のゴルーチンを待つ #

複数のゴルーチンが終わるまで待ちたいとき — sync.WaitGroupが標準。

WaitGroup
import (
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Printf("ゴルーチン %d\n", id)
		}(i)
	}

	wg.Wait()    // すべて終わるまで待つ
	fmt.Println("終了")
}

3つのメソッド:

  • Add(n) — 待つゴルーチン数を登録
  • Done() — ひとつのゴルーチン終了(通常defer)
  • Wait() — すべてDoneになるまで待つ

罠 — クロージャキャプチャ #

よく出会う罠
for i := 0; i < 3; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(i)    // ✗ すべて同じ i をキャプチャしうる (3, 3, 3)
	}()
}

古いGoの罠 — すべてのゴルーチンが同じi変数をキャプチャして結果が予想と違いました。Go 1.22+では繰り返しごとに新しいiになり解決されました(JSのletに似た動作)。

古いコードの互換性のために明示的にローカル変数にコピーするパターンもよく見ます。

ローカル変数でキャプチャ — 古いコード互換
for i := 0; i < 3; i++ {
	i := i    // 繰り返しごとに新しい変数
	go func() {
		fmt.Println(i)
	}()
}

パターン — Worker Pool #

よく登場するパターン — N個のゴルーチンが作業キューから仕事を取って処理。

worker pool
func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("worker %d: job %d 処理\n", id, j)
		time.Sleep(time.Second)
		results <- j * 2
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	// worker 3 個起動
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// job 送信
	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs)    // もうない

	// 結果受信
	for a := 1; a <= 9; a++ {
		fmt.Println("結果:", <-results)
	}
}

<-chan int(受信専用) / chan<- int(送信専用)の表記 — 関数シグネチャで意図を明示。この場面ではworkerがjobsを受け取り、resultsに送るだけということが明確です。

パターン — Fan-out / Fan-in #

作業を複数のゴルーチンへ分配し(fan-out)、結果を1つのチャネルに集める(fan-in)。#1 並行性パターンで詳しく扱います。

チャネル == メールボックスのような比喩 #

OOPのメッセージパッシングに似たモデルです。2つのゴルーチンがメモリを共有せず、チャネルでデータをやり取り

“Don’t communicate by sharing memory; share memory by communicating.”
— Go の格言

共有変数にロックをかけるモデルの代わりに、チャネルでデータを流すモデルが推奨されます。並行コードがより読みやすく、バグが少ないです。

罠 — デッドロック #

チャネルは強力ですが、誤用するとデッドロック(互いに待ち合って止まる)が起きます。

デッドロック
func main() {
	ch := make(chan int)
	ch <- 1     // ✗ 受け取る人なし — 永遠にブロック
	fmt.Println("never")
}
// fatal error: all goroutines are asleep - deadlock!

Goランタイムがすべてのゴルーチンが止まっているデッドロックを検知するとpanicします。部分デッドロック(一部だけ停止)は捕まえませんが、全体のデッドロックは自動検知されます。

最もよくある原因:

  1. 送る人と受け取る人のペアが合いません
  2. closeしないままfor range chが永遠に待つ
  3. 2つのゴルーチンが互いのチャネルを待つ(循環)

罠 — ゴルーチンリーク #

ゴルーチンが終わらず永遠に生きていると — メモリリークに近い状況が生まれます。

ゴルーチンリーク
func leak() {
	ch := make(chan int)

	go func() {
		v := <-ch    // 永遠に待つ
		fmt.Println(v)
	}()

	// ch に誰も送らずに関数終了
	// ゴルーチンは生き残ってメモリを占有
}

解決 — ゴルーチンが必ず終了できる経路を用意してください。contextまたはdoneチャネルが標準ツールです(次の#5 context)。

チャネルを関数引数に — 方向を明示 #

方向付きチャネル型
func produce(out chan<- int) {       // 送信専用
	for i := 0; i < 5; i++ {
		out <- i
	}
	close(out)
}

func consume(in <-chan int) {        // 受信専用
	for v := range in {
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}

関数がチャネルをどう使うかを — シグネチャで強制。produceの中で受信を試みればコンパイルエラー。意図がコードで表現されます。

まとめ #

今回の記事で整理した内容:

  • go 関数()でゴルーチン起動 — 軽量な並行実行(〜2KB)
  • mainが終わるとすべてのゴルーチンが終了 — 同期が必要
  • チャネル — ゴルーチン間の値の受け渡し + 同期
  • バッファなしチャネル — 送信/受信が出会わないと進まない
  • バッファ付きチャネル — キューのように、満杯ならブロック
  • close(ch) + for rangeが標準終了パターン
  • sync.WaitGroupで複数のゴルーチンを待つ
  • worker poolパターン(<-chan / chan<-の方向)
  • “Share memory by communicating” — 格言
  • デッドロックとゴルーチンリークの罠

次の記事(#4 selectとタイムアウト)では複数のチャネルを同時に扱うselect文、そしてタイムアウトとキャンセルパターンを整理します。

X