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が終わるとすべてのゴルーチンが終了 #
よく出会う罠。
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
#
送信側がもう送るものがないとき。
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, 4for range chパターンが最も一般的です。チャネルがcloseされると自動で終了します。
closeのルール #
- 受信側はcloseしない — 送る側だけ
- すでに閉じたチャネルへsend → panic
- すでに閉じたチャネルからreceive → 即座にzero valueを返す(ブロックなし)
closeされたか確認 — comma-ok #
v, ok := <-ch
if !ok {
fmt.Println("チャネル閉じた")
}mapのcomma-okと同じパターン。okがfalseなら閉じていてもう受け取るものがないという意味。
WaitGroup — 複数のゴルーチンを待つ
#
複数のゴルーチンが終わるまで待ちたいとき — sync.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個のゴルーチンが作業キューから仕事を取って処理。
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します。部分デッドロック(一部だけ停止)は捕まえませんが、全体のデッドロックは自動検知されます。
最もよくある原因:
- 送る人と受け取る人のペアが合いません
- closeしないまま
for range chが永遠に待つ - 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文、そしてタイムアウトとキャンセルパターンを整理します。