Go

Goのchannel(チャネル)入門|close・select・デッドロックの挙動をGo 1.27で検証

Goのchannel(チャネル)入門|close・select・デッドロックの挙動をGo 1.27で検証

Go言語のchannel(チャネル)は、goroutine同士が値を受け渡すための型付きの通り道です。make(chan int) で作り、ch <- v で送り、v := <-ch で受け取ります。つまずきやすいのは書き方より「いつ止まるか」「いつpanicするか」のほうです。この記事では、言語仕様とメモリモデルの記述を根拠に、Go 1.27.1で実際に動かしたコードと実測値でchannelの挙動を整理します。

まとめ:Goのchannelで押さえる要点

  • バッファなしchannelは送信側と受信側がそろうまで止まる。バッファ付きは満杯(送信)か空(受信)のときだけ止まる
  • closeは送信側が1回だけ行う。閉じたchannelへの送信と二重closeはpanic、nil channelの送受信は永久に止まる
  • closeが必要なのは受信側に「もう値は来ない」と伝えるときだけ。for range の終了と一斉通知に使う
  • selectは準備できたcaseから一様ランダムに1つ選ぶ。タイムアウトはcontext、case の無効化はnil channelで書く
  • バッファを0から64に増やすと1件あたり約4倍速くなったが、64と1024ではほぼ差がなかった(実測)
  • 取り残されたgoroutineは、Go 1.27で正式版になった goroutineleak プロファイルで見つけられる

Goのchannelの仕組みとmakeによる作成

channelは要素の型を持つ値で、make(chan T) または make(chan T, 容量) で作ります。言語仕様は容量について「If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready.」と定めています。容量を省くとバッファなしになり、送信は受信側が現れるまで進みません。

package main

import "fmt"

func main() {
	ch := make(chan string) // バッファなし
	go func() {
		ch <- "hello" // 受信側が受け取るまでここで止まる
	}()
	msg := <-ch
	fmt.Println(msg, len(ch), cap(ch)) // hello 0 0
}

仕様は「A single channel may be used in send statements, receive operations, and calls to the built-in functions cap and len by any number of goroutines without further synchronization.」とも書いています。複数のgoroutineから同じchannelへ送受信しても、追加のロックは要りません。goroutine自体の起動やスケジューリングはゴルーチン(Goroutine)とは?Go言語の並行処理を支える軽量スレッドの仕組みと使い方で扱っています。

送信専用・受信専用の方向指定

引数の型を chan<- int(送信専用)や <-chan int(受信専用)にすると、関数の役割を型で縛れます。受信専用channelを close するとコンパイルエラーになるため、「閉じるのは送信側」という約束をコンパイラに守らせられます。

func produce(out chan<- int) {
	for i := range 3 {
		out <- i
	}
	close(out) // 送信側が閉じる
}

func consume(in <-chan int) {
	for v := range in { // close されるまで受信を続ける
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)     // 0 1 2
	v, ok := <-ch
	fmt.Println(v, ok) // 0 false
}

バッファなしとバッファ付きchannelの違い

同期のタイミングとメモリモデルの保証

違いは「送信がいつ完了するか」です。Effective Goは「If the channel is unbuffered, the sender blocks until the receiver has received the value. If the channel has a buffer, the sender blocks only until the value has been copied to the buffer」と説明しています。

この違いは、どの書き込みが相手から見えるかの保証にも関わります。Goメモリモデルが定める規則は次の3つです。

  • 「A send on a channel is synchronized before the completion of the corresponding receive from that channel.」=送信前の書き込みは、受信した側から必ず見える
  • 「A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.」=バッファなしなら逆向きも保証される
  • 「The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel.」=容量Cのchannelは同時実行数Cのセマフォとして使える

3つ目の規則が、後述するバッファ付きchannelによる同時実行数制限の根拠です。

バッファサイズと1件あたりの受け渡しコスト(実測)

送信側と受信側のgoroutineを1本ずつ置き、1件あたりの時間をバッファサイズ別に測りました。環境はgo1.27.1 darwin/amd64、Intel Core i9-9880H(8コア16スレッド)で、-count 5 の中央値です。

条件 -cpu 1 -cpu 16
バッファなし 525.0 ns 523.5 ns
バッファ1 396.6 ns 399.0 ns
バッファ64 134.1 ns 138.1 ns
バッファ1024 131.4 ns 158.3 ns
同じgoroutineで送って受ける(バッファ1) 124.2 ns 123.1 ns
参考:sync.Mutexで加算 37.61 ns 37.02 ns

バッファなしでは、1件ごとに送信側と受信側がそろう必要があります。容量64の受け渡しは約134 nsで、同じgoroutine内で送受信した場合(約124 ns)に近い値でした。1024では-cpu 1で131.4 nsとほぼ変わらず、-cpu 16では158.3 nsと遅くなりました。この測定条件では、64から1024への増量による一貫した高速化は見られません。バッファ容量は、一時的に滞留させたい量とメモリ使用量から決めます。効果は処理内容や送受信の速度差で変わるため、自分の処理で測り直してください。測定は go test -bench . -count 5 -cpu 1,16 で行い、送信側が b.Loop() の回数だけ整数を送り、受信側は for range で読み捨てるだけの構成です。

runtimeのhchan構造とロック

channelの実体は runtime/chan.go の hchan 構造体です。go1.27.1のソースでは、要素数 qcount、リングバッファの長さ dataqsiz と先頭 buf、送受信位置 sendx・recvx、待機中の受信者・送信者の列 recvq・sendq、そして全フィールドを守る lock mutex を持ちます。なお、上の表ではchannelの送受信と、競合のないsync.Mutexによる加算を測っています。処理内容が異なるため、カウンタ実装同士の性能差や、その原因を直接示す比較ではありません。

nil・open・closed別のchannelの挙動一覧

channelの事故の大半は、状態ごとの挙動を取り違えたときに起きます。次の表は言語仕様の記述を、go1.27.1で1つずつ実行して確かめたものです。

操作 nil channel open(通常) closed
送信 ch <- v 永久に止まる 受信側か空きが出るまで待つ panic: send on closed channel
受信 <-ch 永久に止まる 値が来るまで待つ 残りの値を返し、尽きたらゼロ値とfalse
close(ch) panic: close of nil channel 成功 panic: close of closed channel
for range ch 永久に止まる closeまで受信を続ける 残りの値を読んで終わる

バッファに値が残ったまま閉じたchannelからは、まず残りの値が ok=true で返ります。仕様の「after any previously sent values have been received」がこの順序を定めています。閉じたかどうかを知るには、値だけでなく v, ok := <-ch の ok を見る必要があります。

closeの正しい使い方と一斉通知

closeが必要な場面と不要な場面

A Tour of Goは「Only the sender should close a channel, never the receiver.」と書き、さらに「Channels aren’t like files; you don’t usually need to close them. Closing is only necessary when the receiver must be told there are no more values coming, such as to terminate a range loop.」と続けます。closeは後片付けではなく、受信側への合図です。どこからも参照されなくなったchannelはcloseしなくてもガベージコレクションで回収されます。

送信者が複数いるときのclose

送信者が複数いる場合、どれか1本がcloseした後に別の送信者が送信すると、その送信でpanicします。送信者全員の終了を sync.WaitGroup で待ち、別のgoroutineから1回だけ閉じます。WaitGroup.Go はGo 1.25で追加されたメソッドで、Add と Done を書かずに済みます。

results := make(chan int)
var wg sync.WaitGroup
for i := range 3 {
	wg.Go(func() { results <- i * i })
}
go func() {
	wg.Wait()
	close(results) // 送信者が全員終わってから1回だけ閉じる
}()
sum := 0
for v := range results {
	sum += v
}
fmt.Println(sum) // 5

close(done)による一斉通知(broadcast)

1つの値の送信は1人の受信者にしか届きません。複数のgoroutineへ一度に止まれと伝えるには、閉じたchannelからの受信がすぐ完了する性質を使います。メモリモデルも「The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed.」と保証しています。要素型は値を運ばない struct{} にします。

done := make(chan struct{})
var wg sync.WaitGroup
for id := range 3 {
	wg.Go(func() {
		<-done // close されるまで待つ
		fmt.Println("stop", id)
	})
}
close(done) // 待っている全員に一度に届く
wg.Wait()

同じ目的には context.Context の Done() も使えます。キャンセル理由や期限も運びたいならcontextを選びます。書き方はGo言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装にまとめています。

selectで複数のchannelを待つ書き方

選ばれ方とdefaultによる非ブロッキング操作

selectは複数の送受信のうち、進めるものを1つ実行します。仕様は「If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection.」と定めています。上に書いたcaseが優先されるわけではありません。どのcaseも進めないときは default があればそこへ進み、なければ待ちます。

タイムアウトとキャンセルの組み込み

待ち時間の上限はcontextで付けます。次のコードで slow() が容量1のchannelを返しているのは、タイムアウト後に誰も受け取らなくなっても、1回の送信を完了できるようにするためです。ただし、contextが止めるのは結果待ちであり、slow内の処理自体は続きます。

func slow() <-chan int {
	ch := make(chan int, 1) // 受け手がいなくなっても送信で止まらないよう1つ空ける
	go func() {
		time.Sleep(200 * time.Millisecond)
		ch <- 42
	}()
	return ch
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	select {
	case v := <-slow():
		fmt.Println("result:", v)
	case <-ctx.Done():
		fmt.Println("give up:", ctx.Err()) // give up: context deadline exceeded
	}
}

Go 1.23以降のtimer channelはバッファなし

time.After や time.Timer.C を使う場合、Go 1.23の変更でtimerのchannelがバッファなし(容量0)になった点に注意します。この変更はgo.modのgo行が1.23以上のモジュールで有効になります。リリースノートによると、これにより Reset や Stop の呼び出し後に古い値を受け取らないことが保証されました。副作用として len(t.C) と cap(t.C) は1ではなく0を返します。長さで受信可否を判定していたコードは、selectの default を使った非ブロッキング受信に書き換えます。Go 1.23〜1.26では GODEBUG=asynctimerchan=1 で旧挙動に戻せましたが、Go 1.27でこの設定は削除され、常にバッファなしになりました。go1.27.1で time.NewTimer(time.Hour).C の len と cap を出力すると、どちらも0でした。

nil channelで終わったcaseを外すマージ処理

2本のchannelを1本にまとめるとき、閉じた側を読み続けるとゼロ値が延々と返ります。閉じたと分かった変数に nil を代入すると、そのcaseは二度と選ばれなくなります。この例は、両入力が最終的に閉じられ、呼び出し側が出力を最後まで受信する前提です。途中で受信をやめる場合は、入力側も含めたキャンセル処理が必要になります。

func merge(a, b <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for a != nil || b != nil {
			select {
			case v, ok := <-a:
				if !ok {
					a = nil // 閉じた側は nil にして select の対象から外す
					continue
				}
				out <- v
			case v, ok := <-b:
				if !ok {
					b = nil
					continue
				}
				out <- v
			}
		}
	}()
	return out
}

channelを使う並行処理パターン

ワーカープールの固定並行数と終了順序

仕事を流す jobs と結果を返す results の2本を用意し、固定数のワーカーで処理します。closeの順序が肝で、投入側が jobs を閉じるとワーカーの range が抜け、全ワーカーの終了後に results を閉じます。

jobs := make(chan int)
results := make(chan int)

var wg sync.WaitGroup
for range 4 { // ワーカー4本
	wg.Go(func() {
		for n := range jobs {
			results <- n * n
		}
	})
}
go func() {
	for n := range 100 {
		jobs <- n
	}
	close(jobs) // これでワーカーの range が終わる
}()
go func() {
	wg.Wait()
	close(results) // ワーカー全員が抜けてから閉じる
}()

sum := 0
for r := range results {
	sum += r
}
fmt.Println(sum) // 328350

バッファ付きchannelによる同時実行数の制限

goroutineを各仕事に対して起動し、その中のworkを同時に実行する数だけ絞りたい場面では、容量Nのchannelをセマフォにします。起動済みのgoroutine数や待機数は制限されません。先に引いたメモリモデルの「k+Cth send」の規則がこの書き方を保証しています。10本を起動して同時実行数の最大値を記録すると、容量どおり3でした。

sem := make(chan struct{}, 3) // 同時に3つまで
var wg sync.WaitGroup
for i := range 10 {
	wg.Go(func() {
		sem <- struct{}{}        // 空きが出るまで待つ
		defer func() { <-sem }() // 終わったら1つ返す
		work(i) // 任意の処理
	})
}
wg.Wait()

同じキーへの重複呼び出しをまとめたい場合は、channelで自作するより golang.org/x/sync/singleflight が確実です。singleflightとは何か?Go言語における重複呼び出し抑制メカニズムの概要とパフォーマンス向上効果で解説しています。

デッドロックとgoroutineリークの見つけ方

「all goroutines are asleep」が出る条件

受信側のいないバッファなしchannelへmainから送ると、ランタイムが次のように停止します。

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()

このエラーは、すべてのgoroutineが止まったときにしか出ません。HTTPサーバーのように他のgoroutineが動いているプログラムでは、channelで待機しているgoroutineがあっても、それだけでプロセスは終了しません。待機を解除する経路が失われ、終了できなくなった状態がgoroutineリークで、デッドロックより見つけにくい障害です。

Go 1.27のgoroutineleakプロファイル

Go 1.26で実験機能として入った goroutineleak プロファイルは、Go 1.27で正式版になり、GOEXPERIMENT の指定が不要になりました。リリースノートによると、ガベージコレクタを使い「goroutine G is blocked on concurrency primitive P, and P is unreachable from any runnable goroutine」の条件を満たすものをリークと判定します。net/http/pprof を組み込んでいれば /debug/pprof/goroutineleak からも取得できます。

次は、エラーが返った時点で早期returnし、残りの送信者を置き去りにする典型的なリークです。

func run(n int) error {
	errc := make(chan error) // バッファなし
	for i := range n {
		go func(c chan<- error) { c <- fmt.Errorf("task %d failed", i) }(errc)
	}
	for range n {
		if err := <-errc; err != nil {
			return err // 早期 return で残りの送信者が取り残される
		}
	}
	return nil
}

func main() {
	fmt.Println(run(3))
	time.Sleep(100 * time.Millisecond) // 残りの送信者が止まるまで待つ
	pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 2)
}

go1.27.1で実行すると、最初のエラーでreturnした後、取り残された2本の送信者の行に goroutine 19 [chan send (leaked)]: のように (leaked) が付きました(5回実行して5回とも2件)。直し方は2通りです。errc を容量nのバッファ付きにして送信が必ず完了するようにするか、contextのキャンセルを送信側のselectに入れます。なお、グローバル変数や動作中のgoroutineのローカル変数から届くchannelで止まっている場合は、到達可能と判断されるため検出されません(リリースノートに明記)。

channelとsync.Mutexの使い分け

Go公式Wikiの「Use a sync.Mutex or a channel?」は、判断基準を「Use whichever is most expressive and/or most simple.」とし、channelに向く用途として「passing ownership of data, distributing units of work, communicating async results」、Mutexに向く用途として「caches, state」を挙げています。Effective Goも「Reference counts may be best done by putting a mutex around an integer variable」と書いています。

判断は次のとおりです。値やタスクを別のgoroutineへ渡すならchannel、共有された状態を読み書きするならMutex。単純なカウンタ更新やキャッシュの排他制御だけが目的なら、まずMutexを選びます。状態の所有権や処理順序も一つのgoroutineに集約したい場合は、channel経由の設計も候補になります。前述の実測ではchannelの受け渡しが1件あたり124〜525 ns、Mutexでの加算が約37 nsでしたが、処理内容が異なるため、この数値だけで実際のカウンタやキャッシュの性能差は判断できません。読み取りが多い状態の保護では、Goのsync.MutexとRWMutexの違いと使い分け|実測ベンチで選ぶ排他制御の測定結果を参考にしてください。

よくある質問

channelはcloseしないとメモリリークしますか?

しません。どこからも参照されなくなったchannelは、closeしていなくても回収されます。リークするのはchannelで止まったままのgoroutineのほうで、closeの有無とは別の問題です。

channelが閉じているかを送信前に調べる方法はありますか?

標準の手段はありません。調べてから送るまでの間に他のgoroutineが閉じる可能性があるため、仮に調べられても安全になりません。閉じる役を送信側の1か所に決める設計で防ぎます。

len(ch)で受信できるかを判定してよいですか?

判定の直後に他のgoroutineが値を取る可能性があるため、目安にしかなりません。受信できるか試したいときは、selectの default を使った非ブロッキング受信にします。

バッファサイズはいくつにすればよいですか?

同時実行数の制限なら上限値そのもの、受け渡しの平準化なら送信側の瞬間的な山の大きさで決めます。本記事の測定条件では64と1024でほぼ差がなく、それ以上増やす速度面の理由は見つかりませんでした。

channelで送った値はコピーされますか?

値そのものがコピーされます。ポインタやスライス、mapを送った場合はコピーされるのは参照だけなので、送った後に送信側で中身を書き換えないようにします。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.07.21 テックブログ Apache Tomcatの脆弱性一覧【2026年10月】最新版11.0.27・10.1.61・9.0.123とCVE-2026-59083ほかの影響確認・対応手順
  2. 2026.10.21 テックブログ NEC委託先の不正アクセスとは|顧客システム暗号化・情報漏えいの経緯と委託元が点検する正規アカウント
  3. 2026.10.21 テックブログ ふるさとチョイス不正アクセスで445万件流出:対象確認・パスワード変更と再開時期
  4. 2026.10.22 テックブログ Tomcatバージョン一覧とサポート期限【2026年10月】9.0系は2027年3月末で終了・9.1系と11.0系への移行判断
  5. 2024.07.05 コラム XAMPPの脆弱性とは?危険性とCVE-2024-4577への対策をわかりやすく解説【2026年10月】

RELATED POSTS 関連記事

目次