Go: Goチャネル通信:バッファなし/バッファあり、方向制限、close/range、デッドロック解析
チャネルは、Goの並行処理モデルの核となる基本要素です。「メモリを共有して通信するのではなく、通信を通じてメモリを共有する」という考え方です。チャネルにより、ゴルーチン間のデータ転送は、パイプ処理と同様に安全かつ洗練されたものになります。
Goの並行処理において、ゴルーチンが「人々」だとすれば、チャネルはそれらをつなぐ「電話回線」のようなものです。このレッスンでは、チャネルの基本的な使い方をすべて習得し、よくある落とし穴についても学びます。
1. 学習内容
- チャンネルの作成 (
make(chan T)バッファなし /make(chan T, n)バッファあり) ch <- vを送信し、v := <-chを受信- バッファなしチャネルの同期動作
- バッファ付きチャネルの非同期動作
closeがチャネルを閉じ、rangeが反復処理を停止する- アクセス権限の制限:
chan<-は書き込み専用、<-chanは読み取り専用です - 一般的なデッドロックシナリオの分析
- セレクト・マルチプレクシングの概要
- 生産者から消費者への流通経路に関する包括的な事例研究
2. バックエンドエンジニアの実話
(1) 課題:共有メモリとミューテックスのコードが冗長で、デッドロックが発生しやすい
アリスは決済チームのバックエンドエンジニアで、複数のゴルーチン間でトランザクションデータをやり取りする必要があります:
「私は
sync.Mutexを使って、共有されている[]Transactionスライスを保護しました。そこへ5つのgoroutineが書き込みを行い、3つのgoroutineが読み取りを行っていました。100行のコードのうち、30行がLock/Unlock文でした。レビュー担当者がこれを見たとき、すぐに『このコードはレビューできません。1行たりとも手をつける勇気はありません』と言いました。」
彼女のコードは次のようになっています:
// Shared memory approach: lock on every read/write, hard to maintain
type TransactionPool struct {
mu sync.Mutex
items []Transaction
}
func (p *TransactionPool) Add(t Transaction) {
p.mu.Lock()
defer p.mu.Unlock()
p.items = append(p.items, t)
}
func (p *TransactionPool) Get() Transaction {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.items) == 0 {
return Transaction{}
}
item := p.items[0]
p.items = p.items[1:]
return item
}
(2) Goでの解決策:チャネルを介したデータの受け渡し
// channel_approach.go
package main
import "fmt"
type Transaction struct {
ID string
Amount float64
}
func main() {
// Create an unbuffered channel
ch := make(chan Transaction)
// Producer goroutine
go func() {
tx := Transaction{ID: "TXN-001", Amount: 99.99}
ch <- tx // Send (blocks until receiver is ready)
fmt.Println("Producer: send complete")
}()
// Consumer
tx := <-ch // Receive (blocks until sender is ready)
fmt.Printf("Consumer: received %s ($%.2f)\n", tx.ID, tx.Amount)
}
出力:
Consumer: received TXN-001 ($99.99)
Producer: send complete
(3) パフォーマンス:チャネル対共有メモリ
| 次元 | 共有メモリ + ミューテックス | チャネル |
|---|---|---|
| コードサイズ | 30行のテンプレートコード | 1行 ch <- v |
| スレッドセーフ | 手動によるロック/ロック解除 | 言語固有の保証 |
| 結合度 | 高 (共有変数を介して結合) | 低 (chan インターフェースのみに依存) |
| テスト容易性 | モックミュテックスが必要 | チャネルを使用して直接テスト |
| デッドロックのリスク | 高 (ロックの順序に関する問題) | 一部のデッドロックはコンパイル時に検出可能 |
3. チャンネルの作成と基本的な操作
(1) チャンネルを作成する
package main
import "fmt"
func main() {
// Unbuffered channel (synchronous)
ch1 := make(chan int)
// Buffered channel (asynchronous, capacity 3)
ch2 := make(chan string, 3)
// nil channel (cannot be used directly)
var ch3 chan float64
fmt.Printf("ch1: %T, unbuffered\n", ch1)
fmt.Printf("ch2: %T, buffer=%d\n", ch2, cap(ch2))
fmt.Printf("ch3: %T, nil=%v\n", ch3, ch3 == nil)
}
(2) 送信と受信
ch := make(chan int)
// Send: ch <- value
ch <- 42 // Unbuffered: blocks until someone receives
// Receive: value := <-ch
value := <-ch // Blocks until someone sends
// Discard received value
<-ch // Receive only, ignore value (used for synchronization signals)
▶ サンプル:バッファなしチャネルの同期挙動
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("goroutine: ready to send...")
ch <- "hello" // Blocks until main receives
fmt.Println("goroutine: send complete")
}()
time.Sleep(1 * time.Second) // Simulate delay
fmt.Println("main: ready to receive...")
msg := <-ch
fmt.Printf("main: received %q\n", msg)
}
出力:
goroutine: ready to send...
main: ready to receive...
goroutine: send complete
main: received "hello"
4. バッファなしチャネルとバッファ付きチャネル
(1) バッファ付きチャネルの非同期動作
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3) // Capacity 3
go func() {
for i := 1; i <= 5; i++ {
ch <- i
fmt.Printf("Sent %d (len=%d)\n", i, len(ch))
}
close(ch)
}()
time.Sleep(500 * time.Millisecond)
for v := range ch {
fmt.Printf("Received %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
出力:
Sent 1 (len=0)
Sent 2 (len=1)
Sent 3 (len=2)
Sent 4 (len=3)
Sent 5 (len=4)
Received 1
Received 2
Received 3
Received 4
Received 5
▶ サンプル:バッファがいっぱいになったときにブロックする
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 2) // Capacity 2
ch <- 1 // ✅ Does not block (has space)
ch <- 2 // ✅ Does not block (has space)
// ch <- 3 // ❌ Blocks! Buffer is full, waiting for receiver
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutine: starting to receive")
<-ch // Frees up one slot
}()
fmt.Println("main: sending 3")
ch <- 3 // Now does not block (goroutine has received one)
fmt.Println("main: send complete")
close(ch)
for v := range ch {
fmt.Printf("v=%d\n", v)
}
}
(3) バッファなしとバッファありの比較
| 特集 | バッファなし make(chan T) |
バッファあり make(chan T, n) |
|---|---|---|
| 送信操作 | 受信側が準備完了するまでブロックする | バッファに空きがある場合はブロックしない |
| 受信動作 | 送信側が準備完了するまでブロックする | バッファにデータがある場合はブロックしない |
| 同期/非同期 | 同期(ハンドシェイク) | 非同期(キュー) |
| 容量 | 0 | n |
| 代表的なシナリオ | 同期シグナル、ゴルーチン間の連携 | タスクキュー、パイプライン |
5. クローズとレンジの反復処理
(1) close: チャンネルを閉じる
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // Cannot send after closing
// Can still receive remaining data after closing
v1 := <-ch // 1
v2 := <-ch // 2
v3 := <-ch // 0 (zero value, ok=false)
fmt.Println(v1, v2, v3)
}
▶ サンプル:comma-ok は、チャネルが閉じられているかどうかを確認します
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
// comma-ok: ok=false means channel is closed and has no data
v, ok := <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=10, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=20, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=0, ok=false (closed and empty)
}
(3) for-rangeループを使用してチャンネルを反復処理する
package main
import "fmt"
func main() {
ch := make(chan string, 3)
ch <- "Alice"
ch <- "Bob"
ch <- "Charlie"
close(ch)
// for range automatically exits when the channel is closed
for name := range ch {
fmt.Printf("Hello, %s!\n", name)
}
// Equivalent to:
// for {
// name, ok := <-ch
// if !ok { break }
// fmt.Println(name)
// }
}
for range が自動的に処理します。送信側は常に close を呼び出す責任があります。
6. 進行方向の制限
(1) 関数のパラメータはチャネルの方向を指定します
package main
import "fmt"
// Write-only channel (can only send)
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
out <- i // ✅ Can only send
}
close(out)
}
// Read-only channel (can only receive)
func consumer(in <-chan int) {
for v := range in {
fmt.Printf("Received: %d\n", v) // ✅ Can only receive
}
}
func main() {
ch := make(chan int, 3)
go producer(ch) // Automatically converts to chan<- int
consumer(ch) // Automatically converts to <-chan int
}
▶ サンプル:実践における方向性制約(パイプラインパターン)
package main
import (
"fmt"
"strings"
)
// Stage 1: Write-only
func stage1(names []string, out chan<- string) {
for _, name := range names {
out <- strings.ToUpper(name)
}
close(out)
}
// Stage 2: Read-write (read from in, process, write to out)
func stage2(in <-chan string, out chan<- string) {
for name := range in {
out <- "Hello, " + name + "!"
}
close(out)
}
// Stage 3: Read-only
func stage3(in <-chan string) {
for msg := range in {
fmt.Println(msg)
}
}
func main() {
ch1 := make(chan string, 3)
ch2 := make(chan string, 3)
names := []string{"Alice", "Bob", "Charlie"}
go stage1(names, ch1)
go stage2(ch1, ch2)
stage3(ch2)
}
出力:
Hello, ALICE!
Hello, BOB!
Hello, CHARLIE!
(3) 方向制限のある使用法
| 宣言 | 権限 | 目的 |
|---|---|---|
ch chan T |
読み取り/書き込み | 変数宣言 |
ch chan<- T |
書き込み専用 | プロデューサー関数の引数 |
ch <-chan T |
読み取り専用 | コンシューマー関数の引数 |
7. セレクト・マルチプレクシングの概要
(1) SELECT の基礎
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "from ch2"
}()
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
case <-time.After(300 * time.Millisecond):
fmt.Println("Timeout")
}
}
select は switch と似ていますが、チャンネルに対して使用されます。case 内のチャンネルのうち、最初に準備が整ったものが実行されます。複数のチャンネルが同時に準備が整った場合は、その中からランダムに1つが選ばれます。selectは、Goにおける並行プログラミングのための究極のツールです。これについては、第15課で詳しく解説します。
▶ サンプル:SELECT文におけるタイムアウト制御の実装
package main
import (
"fmt"
"time"
)
func longOperation(result chan<- string) {
time.Sleep(3 * time.Second)
result <- "Done"
}
func main() {
result := make(chan string)
go longOperation(result)
select {
case res := <-result:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("Operation timed out!")
}
}
出力:
Operation timed out!
8. よくあるデッドロックのシナリオ
// Deadlock 1: Unbuffered channel with send but no receive
func deadlock1() {
ch := make(chan int)
ch <- 42 // fatal error: all goroutines are asleep - deadlock!
}
// Deadlock 2: main goroutine waiting on itself
func deadlock2() {
ch := make(chan int)
<-ch // fatal error: deadlock
}
// Deadlock 3: Multiple goroutines waiting on each other
func deadlock3() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1 // Wait for ch1
ch2 <- 1
}()
<-ch2 // Wait for ch2
ch1 <- 1 // Never reaches this line
}
(1) クイックリファレンス:4種類のデッドロックシナリオ
| シナリオ | 原因 | 解決策 |
|---|---|---|
| 送信専用、受信なし | バッファなしのチャネルブロッキング | コンシューマーが存在することを確認 |
| 受信のみ、送信なし | チャンネルが空で、送信者がいない | プロデューサーが存在することを確認してください |
| 互いに待機し合うゴルーチン | AはBのチャネルを待機し、BはAのチャネルを待機する | 依存関係の順序の再設計 |
| nilチャネルに対する操作 | nilチャネルへの書き込み/読み取りで永続的にブロックする | チャネルの初期化 |
9. 完全な例:プロデューサー・コンシューマー・パイプライン
// pipeline.go
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Work unit
type Job struct {
ID int
Payload string
}
type Result struct {
Job Job
Output string
Err error
Duration time.Duration
}
// Producer: generates jobs (write-only channel)
func producer(jobs chan<- Job, count int) {
for i := 1; i <= count; i++ {
jobs <- Job{
ID: i,
Payload: fmt.Sprintf("task-%d", i),
}
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
}
close(jobs)
}
// Consumer: processes jobs (read jobs, write results)
func consumer(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
start := time.Now()
// Simulate processing
time.Sleep(time.Duration(50+rand.Intn(100)) * time.Millisecond)
output := fmt.Sprintf("consumer-%d processed %s", id, job.Payload)
results <- Result{
Job: job,
Output: output,
Duration: time.Since(start),
}
}
}
// Collector: collects and prints results (read-only channel)
func collector(results <-chan Result, done chan<- struct{}) {
var total time.Duration
count := 0
for r := range results {
count++
total += r.Duration
status := "OK"
if r.Err != nil {
status = "ERR"
}
fmt.Printf("[%s] Job#%d: %s (%v)\n",
status, r.Job.ID, r.Output, r.Duration)
}
if count > 0 {
fmt.Printf("\nTotal %d tasks, avg %.2f ms\n",
count, float64(total.Milliseconds())/float64(count))
}
close(done)
}
func main() {
const (
jobCount = 20
workerCount = 3
)
jobs := make(chan Job, 10)
results := make(chan Result, 10)
done := make(chan struct{})
// 1 producer
go producer(jobs, jobCount)
// N consumers
var wg sync.WaitGroup
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go consumer(w, jobs, results, &wg)
}
// Wait for all consumers to finish, then close results
go func() {
wg.Wait()
close(results)
}()
// Collector (main goroutine)
collector(results, done)
<-done
}
期待される出力:
[OK] Job#1: consumer-2 processed task-1 (85ms)
[OK] Job#2: consumer-1 processed task-2 (92ms)
[OK] Job#3: consumer-3 processed task-3 (78ms)
...
[OK] Job#20: consumer-1 processed task-20 (112ms)
Total 20 tasks, avg 87.45 ms
sequenceDiagram
participant P as Producer
participant C as Channel
participant W as Consumer
Note over P: Sender is responsible for close
P->>C: ch <- job (send)
activate C
Note over C: Unbuffered: sync wait<br/>Buffered: enqueue
C->>W: job := <-ch (receive)
deactivate C
Note over W: Process task
W-->>P: Continue loop
Note over P: close(ch)
Note over C: Remaining data readable
Note over W: for range auto-exit
❓ よくある質問
v, ok := <-chにおいて、okの値がfalseである場合は、チャネルが閉じられており、データが残っていないことを示します。chan<- および <-chan の目的は何ですか?runtime/chan.go にある hchan 構造体を参照してください。select はチャンネルをどのように使用しますか?select は複数のチャンネルを同時に監視し、最初に準備が整ったチャンネルの case を実行します。複数のチャンネルが同時に準備完了状態になった場合は、ランダムに1つを選択します。どのチャンネルも準備完了状態でなく、かつdefaultのケースが指定されている場合は、defaultのケースが実行されます。それ以外の場合は、プロセスはブロックして待機します。selectは、タイムアウト制御とノンブロッキング操作の鍵となります。selectをdefault節と組み合わせて使用してください。チャネルが閉じられたことを受信側に通知するには、closeを使用してください。sem := make(chan struct{}, 10) を送信し、操作の前に sem <- struct{}{} を送信し、完了時に <-sem を受信します。バッファがいっぱいになると処理がブロックされるため、自然なレート制限が実現されます。📖 まとめ
- チャンネルの作成:
make(chan T)(バッファなし)、make(chan T, n)(バッファあり) ch <- vの送信によりブロックされる可能性があります。また、v := <-chの受信によりブロックされる可能性があります。- バッファなし = 同期ハンドシェイク、バッファあり = 非同期キュー
close(ch)は送信者によって呼び出され、受信者はfor rangeを使用してそれを反復処理するv, ok := <-chチャンネルが閉じられているかどうかを確認する- アクセス方向の制限:
chan<-は書き込み専用、<-chanは読み取り専用です(コンパイル時にチェックされます)。 selectマルチプレクシングを使用して、複数のチャンネルを聞く- よくあるデッドロック:対応するプロセスが存在しない、循環依存、nilチャネル
📝 練習問題
-
基本問題(難易度 ⭐):0 から 9 までの 10 桁の数字をバッファなしのチャネルに送信するゴルーチンを開始し、メインのゴルーチンがそれらを受信して出力するプログラムを作成してください。送信と受信が交互に行われる順序に注目してください。
-
上級問題(難易度 ⭐⭐):ファンアウトパターンを実装してください。プロデューサーが100個のタスクをチャネルに送信します。同じチャネルからタスクを読み込んで処理する5つのコンシューマーgoroutineを起動し、各コンシューマーが自身のIDとタスク番号を出力するようにしてください。バッファ付きチャネル、
close、およびfor rangeを使用する必要があります。 -
課題(難易度 ⭐⭐⭐):3段階のパイプラインを実装してください。第1段階ではランダムな整数を生成し(
[]int)、第2段階では偶数を除外し、第3段階では二乗和を計算します。各ステージは、チャネルを介して接続された独立したgoroutineです。要件:(1) ステージ間で方向性制約を使用すること;(2) ステージ数の動的な調整に対応すること;(3)selectを使用して洗練された終了処理を実装すること。