Day 010 — channelの基礎(バッファなし/バッファあり)

2026-08-07 🔵 中級者 / Phase 2 実装 channelの基礎(バッファなし/バッファあり)

📚 背景知識(読んでから問題へ)

前回、goroutineの完了を待ち合わせるためにchan struct{}を使いましたが、channelはGoの並行処理における最も基本的な通信のための型です。makeで生成する際に、バッファの有無で挙動が大きく変わります。

ch1 := make(chan int)     // バッファなしchannel(unbuffered)
ch2 := make(chan int, 3)  // バッファありchannel(buffered, 容量3)

バッファなしchannelは「送信者と受信者が同時に立ち会わないと成立しない」channelです。これはランデブー(rendezvous)と呼ばれる同期の仕組みで、以下の性質を持ちます。

  • 送信 (ch <- v) は、誰かが受信するまでブロックする
  • 受信 (<-ch) は、誰かが送信するまでブロックする

つまりバッファなしchannelへの送信が完了した(ch <- vの行を通過した)ということは、その値を受信側が確かに受け取ったことを意味します。これは値の受け渡しだけでなく、「その時点まで両者の処理が到達した」という同期のイベントとしても機能します。

バッファありchannelは容量分の「待合室」を持ちます。

  • 送信は、バッファに空きがある限りブロックしない(空きがなければブロックする)
  • 受信は、バッファに値がある限りブロックしない(値がなければブロックする)

バッファがあることで送信側と受信側の処理速度の一時的な差を吸収できますが、バッファが満杯になれば送信はやはりブロックします。「バッファがある=ブロックしない」という誤解は非常によくあるミスです。

もう1つ重要なのがclose(ch)です。channelを閉じると、それ以降そのchannelへの送信はpanicになりますが、受信は「バッファに残っている値をすべて受信し終えた後、ゼロ値とok=falseを返し続ける」形で安全に扱えます。この性質を利用したfor v := range chは、channelがcloseされるまで自動的に値を受信し続け、closeされた時点でループを抜けるという、Goらしい簡潔なイディオムです。

v, ok := <-ch // ok が false なら「closeされていて、かつバッファも空」を意味する

channelを閉じる責任は、慣習として送信側が持ちます。受信側がcloseすることは基本的にありません(複数の送信者がいる場合はさらに注意が必要ですが、これはPhase 3のFan-inパターンで扱います)。

📝 問題

以下の要件を満たす、1つの実行可能なGoプログラムを実装してください。

  1. generateNumbers(n int, bufSize int) <-chan int という関数を実装してください。この関数は新しいgoroutineを起動し、そのgoroutineの中で1からnまでの数値を1つずつchannelへ送信します。bufSizeは生成するchannelのバッファサイズです。送信が全て終わったら、送信側が責任を持って必ずchannelをcloseしてください。関数自体は受信専用の<-chan intを返します
  2. 呼び出し側(main)では、for v := range chを使って値を受信し、closeを検知したら自動的にループが終了するようにしてください(ok変数を使った手動チェックは不要です)
  3. 送信の直前・直後にログを出力するようにし(例:"[送信前] %d""[送信後] %d")、バッファなし(bufSize=0バッファあり(bufSize=3の両方で実行して、ログの出力タイミングがどう異なるかを比較できるようにしてください。受信側はtime.Sleepで少し待ってから受信を始めるようにし、「送信がブロックされて待たされている」状態を意図的に作り出してください

go run main.goでそのまま実行できる、完全な1つのプログラムとして提出してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

for v := range chは、chがcloseされて中身が空になるまで自動的に受信を繰り返す構文です。つまり受信側でループの終了条件を手動で書く必要はありません。ループを正しく終了させるためには、送信側のgoroutineが送信を終えたタイミングで確実にclose(ch)を呼ぶことが前提になります。「送信し忘れたgoroutineがある」「closeを忘れた」場合、rangeは永遠に次の値を待ち続けてしまう(goroutineリーク・デッドロック)ことを意識してください。

ヒント2 — アプローチ

goroutine内で複数のreturnパスがある場合や、途中でpanicする可能性がある処理では、close(ch)の呼び忘れを防ぐためにdefer close(ch)をgoroutineの先頭で書くのが定石です。バッファなしとバッファありの違いを観察するには、送信ループの直前でtime.Sleepを使って受信開始を意図的に遅らせ、「送信前ログ」と「送信後ログ」の間にどれだけ間が空くか(=送信がブロックされている時間)を目で確認できるようにしてください。

ヒント3 — コード骨格
package main

import (
	"fmt"
	"time"
)

func generateNumbers(n int, bufSize int) <-chan int {
	ch := make(chan int, bufSize)
	go func() {
		defer close(ch) // 送信側が責任を持ってcloseする
		for i := 1; i <= n; i++ {
			fmt.Printf("  [送信前] %d\n", i)
			ch <- i // バッファが満杯 or バッファなしなら、ここでブロックされる
			fmt.Printf("  [送信後] %d\n", i)
		}
	}()
	return ch
}

func main() {
	ch := generateNumbers(5, 0) // まず bufSize=0 で試す

	time.Sleep(200 * time.Millisecond) // 受信側の開始をわざと遅らせる

	for v := range ch {
		fmt.Printf("  [受信] %d\n", v)
	}
}

これをbufSize=3に変えて再実行し、ログの出方の違いを比較してみてください。

模範解答

package main

import (
	"fmt"
	"time"
)

// generateNumbers は 1〜n の数値を bufSize のバッファを持つ channel に送信するgoroutineを起動し、
// 受信専用の channel を返す。送信側が defer close(ch) によって責任を持ってcloseすることで、
// 呼び出し側は for range で安全にループを終了できる。
func generateNumbers(n int, bufSize int) <-chan int {
	ch := make(chan int, bufSize)
	go func() {
		defer close(ch)
		for i := 1; i <= n; i++ {
			fmt.Printf("  [送信前] %d をchannelへ送信しようとしています\n", i)
			ch <- i
			fmt.Printf("  [送信後] %d の送信が完了しました\n", i)
		}
	}()
	return ch
}

// run は label と bufSize を受け取り、生成→受信→合計までの一連の流れを実行して観察する。
func run(label string, bufSize int) {
	fmt.Printf("=== %s (バッファサイズ: %d) ===\n", label, bufSize)
	ch := generateNumbers(5, bufSize)

	// 受信側の開始を意図的に遅らせ、「送信がブロックされて待たされる」状態を作る
	time.Sleep(200 * time.Millisecond)
	fmt.Println("  --- 受信側がここで受信を開始する ---")

	sum := 0
	for v := range ch {
		fmt.Printf("  [受信] %d を受信しました\n", v)
		sum += v
	}
	fmt.Printf("合計: %d\n\n", sum)
}

func main() {
	run("バッファなしchannel", 0)
	run("バッファありchannel", 3)
}
▶ 実行結果を見る(go run main.go / 実際に検証済み)
=== バッファなしchannel (バッファサイズ: 0) ===
  [送信前] 1 をchannelへ送信しようとしています
  --- 受信側がここで受信を開始する ---
  [受信] 1 を受信しました
  [送信後] 1 の送信が完了しました
  [送信前] 2 をchannelへ送信しようとしています
  [送信後] 2 の送信が完了しました
  [送信前] 3 をchannelへ送信しようとしています
  [受信] 2 を受信しました
  [受信] 3 を受信しました
  [送信後] 3 の送信が完了しました
  [送信前] 4 をchannelへ送信しようとしています
  [送信後] 4 の送信が完了しました
  [送信前] 5 をchannelへ送信しようとしています
  [受信] 4 を受信しました
  [受信] 5 を受信しました
  [送信後] 5 の送信が完了しました
合計: 15

=== バッファありchannel (バッファサイズ: 3) ===
  [送信前] 1 をchannelへ送信しようとしています
  [送信後] 1 の送信が完了しました
  [送信前] 2 をchannelへ送信しようとしています
  [送信後] 2 の送信が完了しました
  [送信前] 3 をchannelへ送信しようとしています
  [送信後] 3 の送信が完了しました
  [送信前] 4 をchannelへ送信しようとしています
  --- 受信側がここで受信を開始する ---
  [受信] 1 を受信しました
  [受信] 2 を受信しました
  [受信] 3 を受信しました
  [受信] 4 を受信しました
  [送信後] 4 の送信が完了しました
  [送信前] 5 をchannelへ送信しようとしています
  [送信後] 5 の送信が完了しました
  [受信] 5 を受信しました
合計: 15

※ goroutineのスケジューリングにより行の細かい順序は実行のたびに変わりますが、傾向は毎回同じです。重要な事実は、バッファなし側では[送信後]のログが受信開始前にはほぼ現れないのに対し、バッファあり側では[送信前] 4までのログが受信開始前に一気に出力されている点です。

🪜 Step-by-Step 解説

1
バッファなしchannelの挙動を読み解く
bufSize=0のとき、ch <- 1は「誰かが<-chで受信するまで」その場でブロックされます。ログを見ると、[送信前] 1が出力された直後、main側がtime.Sleep(200ms)をしている間はほとんど[送信後]が出力されません。受信側がrangeで受信を始めた瞬間に初めて[受信] 1[送信後] 1がほぼ同時に出力されます。これは「送信の完了=相手が受け取った瞬間」という、バッファなしchannelのランデブー的な性質そのものです。以降も1つ送っては相手が受け取るのを待つ、という交互に近いリズムになります。
2
バッファありchannelの挙動を読み解く
bufSize=3のとき、最初の3回の送信(1, 2, 3)は、mainSleepで何もしていない間に次々と完了します。これはバッファに空きがある限り送信がブロックされないためです。しかし4回目の送信(4)は、バッファ(容量3)が1,2,3で満杯になっているため、受信側が値を取り出してバッファに空きができるまでブロックされます。ログ上で[送信前] 4の後にしばらく間が空き、受信側が受信を開始した後にようやく[送信後] 4が出力されるのはこのためです。
3
defer close(ch)とrangeの連携
go func() {
	defer close(ch)
	for i := 1; i <= n; i++ {
		ch <- i
	}
}()
goroutineの先頭でdefer close(ch)を書くことで、ループが正常に終わっても途中でpanicしても(今回はpanicしませんが)、必ずchannelがcloseされることが保証されます。呼び出し側の
for v := range ch {
	// ...
}
は、内部的にはv, ok := <-chを繰り返し呼び出し、okfalseになった時点(=closeされていてバッファも空)で自動的にループを終了します。この仕組みのおかげで、呼び出し側は「あと何個来るか」を数えたりokを手動でチェックしたりする必要がなく、「closeされるまで受け取り続ける」という意図をそのままコードで表現できます。

💡 設計思想・なぜこう書くのか

📌
「値の受け渡し」と「同期」を同じ操作で表現する: Goのchannelは、単なる「スレッドセーフなキュー」ではありません。バッファなしchannelが標準的な選択肢として言語に組み込まれているのは、Goがこの2つを同じ操作で表現することを重視しているためです。他の言語で「キューに入れる」と「相手の完了を待つ」は別々の道具(キュー+join()Future)で実現することが多いですが、Goではch <- vという1つの操作が両方を兼ねます。
📌
バッファは緩衝地帯であり、無制限のバッファではない: バッファありchannelは、この同期の強さを意図的に緩めて「一定量までは相手を気にせず先に進んでよい」という緩衝地帯を作る道具です。重要なのは、バッファは無限に受け止めてくれる魔法の箱ではなく、容量という明確な上限を持つ点です。バッファが尽きれば送信側は必ずブロックされます。これはGoが暗黙のうちに提供するバックプレッシャー(背圧)の仕組みでもあります。生産者が消費者より速すぎる場合、メモリを無制限に消費してシステムを壊すのではなく、生産者自身の速度を自然に落とすことで全体のバランスを取ります。バッファサイズの選択は「どこまで速度差を許容するか」という設計判断そのものであり、安易に大きくすればよいというものではありません。

🌐 他言語との比較

観点GoJavaPythonNode.js
相当する仕組みchannel(言語組み込みの型)BlockingQueue(ライブラリのクラス)queue.Queuethreadingと組み合わせて使用)streamやasync generatorだが、同期プリミティブとしての性質は薄い
バッファなし相当make(chan T) — 送受信が同時に成立するランデブーSynchronousQueue — 容量0の特殊なキュー標準では稀(Queue(maxsize=0)は逆に無制限の意味なので注意)直接の相当物はない(Promiseベースの設計が主流)
バッファあり相当make(chan T, n)ArrayBlockingQueue(n)Queue(maxsize=n)明示的なバッファ制御は自前実装が必要になりやすい
クローズによる終了通知close(ch) + rangeで自動検知明示的な終了マーカー値(poison pill)を自前で送るのが一般的同上(Noneを終了シグナルにするなどの自前実装)stream.end()やasync generatorのreturn

Java/Pythonのブロッキングキューを触ったことがある人にとっては、バッファサイズの意味論はほぼ同じ感覚で理解できます。一方で「closeでループが自動終了する」というrangeの挙動はGo独自の簡潔さで、他言語では終了を知らせるための特別な値(poison pill)を自前で送るパターンがよく使われる点が異なります。

🏆 実務での使いどころ

  • パイプライン処理: 複数の処理ステージをchannelで繋ぎ、各ステージがgoroutineとして動く「ストリーム処理」の基礎になる(Phase 3のPipelineパターンへ直結)
  • 同時実行数の制御: バッファ付きchannelを「トークンの入れ物」として使い、バッファ容量分だけ同時に処理を許可する簡易的なセマフォとして使う(例: 外部APIへの同時リクエスト数をmake(chan struct{}, 5)で5に制限する)
  • プロデューサー・コンシューマー構成: ログ収集やイベント処理で、生成する側と処理する側の速度差をバッファがある程度吸収しつつ、吸収しきれない分は送信側の速度低下として現れる自然なバックプレッシャーを得る
  • 終了シグナルの伝播: close(ch)rangeの組み合わせは、「これ以上データが来ないこと」を安全かつ簡潔に伝える標準パターンとして、Web APIのストリーミングレスポンスやバッチ処理の完了通知など幅広く使われる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
バッファ付きchannelは絶対にブロックしないと思い込む「バッファ」という言葉から「常に非同期でノンブロッキング」という印象を持ってしまうバッファに空きがある間だけブロックしない。満杯になれば通常のchannelと同様に送信はブロックされる
受信側でchannelをcloseしてしまう「使い終わったリソースは使う側が片付ける」という他言語の感覚を持ち込むcloseは送信側の責任。受信側がcloseすると、送信側がその後送信しようとした際にpanicする危険がある
既にcloseされたchannelを再度closeしてpanicになるループや複数箇所からcloseを呼んでしまい、二重closeに気づかないcloseは1つのchannelにつき1回だけ許される。sync.Onceや、closeを呼ぶ主体を1箇所に限定する設計で防ぐ
close(ch)を忘れてrangeが永遠に終わらない正常系の実装ばかり気にして、goroutineの終了処理を後回しにする送信側のgoroutineではdefer close(ch)を先頭で書く習慣をつけると、途中経路が増えても閉じ忘れを防げる

🚀 次のステップ

  • 発展: run関数のbufSizeをもっと大きくして(例:100)、受信側のSleepをさらに長くしてみましょう。送信側が「一切ブロックされずに全ての送信を終えてしまう」状態を作れるか確認してみてください
  • 次回予告: Day 011 — select文(実装問題)。複数のchannelを同時に監視し、タイムアウトや複数ソースからの受信を1つの構文で扱うselectを学びます

🎯 自己評価

自分の回答

気づき・メモ