Go: Goチャネル通信:バッファなし/バッファあり、方向制限、close/range、デッドロック解析

チャネルは、Goの並行処理モデルの核となる基本要素です。「メモリを共有して通信するのではなく、通信を通じてメモリを共有する」という考え方です。チャネルにより、ゴルーチン間のデータ転送は、パイプ処理と同様に安全かつ洗練されたものになります。

Goの並行処理において、ゴルーチンが「人々」だとすれば、チャネルはそれらをつなぐ「電話回線」のようなものです。このレッスンでは、チャネルの基本的な使い方をすべて習得し、よくある落とし穴についても学びます。

1. 学習内容



2. バックエンドエンジニアの実話

(1) 課題:共有メモリとミューテックスのコードが冗長で、デッドロックが発生しやすい

アリスは決済チームのバックエンドエンジニアで、複数のゴルーチン間でトランザクションデータをやり取りする必要があります:

「私はsync.Mutexを使って、共有されている[]Transactionスライスを保護しました。そこへ5つのgoroutineが書き込みを行い、3つのgoroutineが読み取りを行っていました。100行のコードのうち、30行がLock/Unlock文でした。レビュー担当者がこれを見たとき、すぐに『このコードはレビューできません。1行たりとも手をつける勇気はありません』と言いました。」

彼女のコードは次のようになっています:

GO
// 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での解決策:チャネルを介したデータの受け渡し

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)
}

出力:

TEXT 📖 参照専用
Consumer: received TXN-001 ($99.99)
Producer: send complete

(3) パフォーマンス:チャネル対共有メモリ

次元 共有メモリ + ミューテックス チャネル
コードサイズ 30行のテンプレートコード 1行 ch <- v
スレッドセーフ 手動によるロック/ロック解除 言語固有の保証
結合度 高 (共有変数を介して結合) 低 (chan インターフェースのみに依存)
テスト容易性 モックミュテックスが必要 チャネルを使用して直接テスト
デッドロックのリスク 高 (ロックの順序に関する問題) 一部のデッドロックはコンパイル時に検出可能
💡 ヒント: Goの格言:「記憶を共有してコミュニケーションをとるのではなく、コミュニケーションをとって記憶を共有せよ。」 — データを共有するのではなく、チャネルを介してあるゴルーチンから別のゴルーチンへデータを渡すようにし、各データには所有者が1つだけであることを確実にしましょう。



3. チャンネルの作成と基本的な操作

(1) チャンネルを作成する

GO
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) 送信と受信

GO
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)

▶ サンプル:バッファなしチャネルの同期挙動

GO
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)
}
▶ 試してみよう

出力:

TEXT 📖 参照専用
goroutine: ready to send...
main: ready to receive...
goroutine: send complete
main: received "hello"
🔥 よくある間違い(同期型バッファレスチャネル): 送信側と受信側は同時に準備が整っていなければなりません。そうでないと、どちらかがブロックされてしまいます。バッファレスチャネル=同期型—送信側は受信側を待ち、受信側は送信側を待ちます。双方が「ハンドシェイク」を完了して初めて、同時に処理が継続されます。



4. バッファなしチャネルとバッファ付きチャネル

(1) バッファ付きチャネルの非同期動作

GO
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)
    }
}

出力:

TEXT 📖 参照専用
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

▶ サンプル:バッファがいっぱいになったときにブロックする

GO
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: チャンネルを閉じる

GO
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 は、チャネルが閉じられているかどうかを確認します

GO
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ループを使用してチャンネルを反復処理する

GO
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) 関数のパラメータはチャネルの方向を指定します

GO
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
}

▶ サンプル:実践における方向性制約(パイプラインパターン)

GO
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)
}
▶ 試してみよう

出力:

TEXT 📖 参照専用
Hello, ALICE!
Hello, BOB!
Hello, CHARLIE!

(3) 方向制限のある使用法

宣言 権限 目的
ch chan T 読み取り/書き込み 変数宣言
ch chan<- T 書き込み専用 プロデューサー関数の引数
ch <-chan T 読み取り専用 コンシューマー関数の引数
💡 ヒント: 方向性は コンパイル時 にのみチェックされます。書き込み専用パラメータに双方向チャネルを渡しても(暗黙の自動変換が行われるため)エラーにはなりませんが、書き込み専用チャネルでデータを受信しようとすると、コンパイル時エラーが発生します。



7. セレクト・マルチプレクシングの概要

(1) SELECT の基礎

GO
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")
    }
}
💡 ヒント: selectswitch と似ていますが、チャンネルに対して使用されます。case 内のチャンネルのうち、最初に準備が整ったものが実行されます。複数のチャンネルが同時に準備が整った場合は、その中からランダムに1つが選ばれます。selectは、Goにおける並行プログラミングのための究極のツールです。これについては、第15課で詳しく解説します。

▶ サンプル:SELECT文におけるタイムアウト制御の実装

GO
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!")
    }
}
▶ 試してみよう

出力:

TEXT 📖 参照専用
Operation timed out!


8. よくあるデッドロックのシナリオ

GO
// 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. 完全な例:プロデューサー・コンシューマー・パイプライン

GO
// 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
}

期待される出力:

TEXT 📖 参照専用
[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
100%
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
🔥 よくある間違い: この例には 3 つのチャネル(jobs/results/done)と、3 種類の方向性制約(書き込み専用、読み取り専用、読み書き可能)があります。方向性制約は、コンパイル時にプロデューサー側でデータを受信できないことを保証します。これは、並行処理の安全性を確保するための Go の型システムの設計上の特徴です。


❓ よくある質問

Q バッファ付きチャネルとバッファなしチャネルの違いは何ですか?
A バッファなしチャネルでは、送信と受信が同時に準備完了している必要があります(同期ハンドシェイク)。そうでない場合、チャネルはブロックされます。バッファ付きチャネルでは、バッファが満杯でなければ送信時にブロックされず、バッファが空でなければ受信時にブロックされません。バッファなしチャネルは同期的な連携に、バッファ付きチャネルは非同期キューに使用されます。
Q チャンネルが閉じられた後もメッセージを送信できますか?
A いいえ。閉じられたチャンネルにメッセージを送信すると、パニックが発生します。閉じた後も、残っているデータは引き続き受信できますが、すべてのデータが読み取られると、ゼロ値が返されます。v, ok := <-chにおいて、okの値がfalseである場合は、チャネルが閉じられており、データが残っていないことを示します。
Q 方向制約 chan<- および <-chan の目的は何ですか?
A これらは関数シグネチャにおけるチャネルの方向を制限するもので、コンパイル時に、プロデューサーが誤って受信したり、コンシューマーが誤って送信したりしないように保証します。Go コンパイラは、実行時にパニックを引き起こすのではなく、コンパイル時にエラーを報告します。
Q チャネルの内部実装はどのようなものですか?
A 内部的には、チャネルはリングバッファと2つの待機キュー(送信キューと受信キュー)で構成されています。データが送信された際、受信キューに待機中の受信者がいれば、そのデータは直接渡されます。そうでない場合は、バッファまたは送信キューに格納されます。Goのソースコード runtime/chan.go にある hchan 構造体を参照してください。
Q select はチャンネルをどのように使用しますか?
A select は複数のチャンネルを同時に監視し、最初に準備が整ったチャンネルの case を実行します。複数のチャンネルが同時に準備完了状態になった場合は、ランダムに1つを選択します。どのチャンネルも準備完了状態でなく、かつdefaultのケースが指定されている場合は、defaultのケースが実行されます。それ以外の場合は、プロセスはブロックして待機します。selectは、タイムアウト制御とノンブロッキング操作の鍵となります。
Q チャネルのデッドロックを回避するにはどうすればよいですか?
A 4つのルールがあります。バッファなしのチャネルには対応する受信側が必要です。ゴルーチン間の依存関係の順序に注意してください。ノンブロッキング操作を実装するには、selectdefault節と組み合わせて使用してください。チャネルが閉じられたことを受信側に通知するには、closeを使用してください。
Q チャネルをセマフォとして使用できますか?
A はい。バッファ付きチャネルはセマフォとして機能します。初期化時に sem := make(chan struct{}, 10) を送信し、操作の前に sem <- struct{}{} を送信し、完了時に <-sem を受信します。バッファがいっぱいになると処理がブロックされるため、自然なレート制限が実現されます。
Q チャネルとミューテックスは、それぞれどのような場合に使用すべきですか?
A データ転送 → チャネル、データの保護 → ミューテックス。具体的には、所有権の譲渡(あるゴルーチンから別のゴルーチンへのデータ転送) → チャネル、共有状態の保護(複数のゴルーチンが同じ変数にアクセスする場合) → ミューテックス。この2つは互いに代替し合うものではなく、互いに補完し合うものです。

📖 まとめ


📝 練習問題

  1. 基本問題(難易度 ⭐):0 から 9 までの 10 桁の数字をバッファなしのチャネルに送信するゴルーチンを開始し、メインのゴルーチンがそれらを受信して出力するプログラムを作成してください。送信と受信が交互に行われる順序に注目してください。

  2. 上級問題(難易度 ⭐⭐)ファンアウトパターンを実装してください。プロデューサーが100個のタスクをチャネルに送信します。同じチャネルからタスクを読み込んで処理する5つのコンシューマーgoroutineを起動し、各コンシューマーが自身のIDとタスク番号を出力するようにしてください。バッファ付きチャネル、close、および for range を使用する必要があります。

  3. 課題(難易度 ⭐⭐⭐)3段階のパイプラインを実装してください。第1段階ではランダムな整数を生成し([]int)、第2段階では偶数を除外し、第3段階では二乗和を計算します。各ステージは、チャネルを介して接続された独立したgoroutineです。要件:(1) ステージ間で方向性制約を使用すること;(2) ステージ数の動的な調整に対応すること;(3) select を使用して洗練された終了処理を実装すること。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%