没有缓冲,值去哪里?
容量 0:G1 发送 7;再选 G2 接收。
先观察 G1 阻塞,再观察两者配对,len 始终为 0。channel 同时做两件事:传值,让不同 goroutine 在明确的通信事件上同步。用好它,关键是能判断一次发送或接收会不会等待,以及谁有权宣布“以后不再有值”。这篇文章从这两个问题展开,再处理 nil、select、超时和默认分支。
最基本的入口是 make(chan T)、make(chan T, n)、ch <- v、v := <-ch、v, ok := <-ch 和 close(ch)。chan T 可发送也可接收;chan<- T 只允许发送,<-chan T 只允许接收。函数返回只读 channel 能收窄调用者的能力,不过方向类型不会自动决定生命周期,也不会深拷贝传入的值。[1]
| channel 的状态 | 发送 ch <- v | 接收 <-ch | close(ch) |
|---|---|---|---|
nil | 一直阻塞 | 一直阻塞 | panic |
| 无缓冲、未关闭 | 等待接收者配对 | 等待发送者配对 | 关闭;此后不能发送 |
| 有缓冲、未关闭 | 有空位即可完成,满了等待 | 有值即可取出,空了等待 | 关闭;已缓冲的值仍可读 |
| 已关闭、缓冲仍有值 | panic | 依次取出已有值,ok=true | panic |
| 已关闭、缓冲已空 | panic | 立即得到零值,ok=false | panic |
表中的阻塞指普通发送或接收;放进 select 后,未就绪的分支不会被选中,可以由其他分支或 default 继续。向已关闭 channel 发送则会 panic,select 不会把这种错误变成安全的“未就绪”。[2][3][4][5]
unbuffered := make(chan int)
select {
case unbuffered <- 1:
default:
fmt.Println("unbuffered: not ready")
}实际输出:
unbuffered: not ready没有接收者,发送不能完成,default 才被选中。无缓冲 channel 适合希望交接双方形成同步点的场合,但发送返回只说明接收发生,不说明接收者后续的业务处理已经结束。需要“处理完”的确认,就另发回执或等待完成信号。[6]
buffered := make(chan int, 1)
buffered <- 7
close(buffered)
a, okA := <-buffered
b, okB := <-buffered
fmt.Println(a, okA, b, okB)实际输出:
7 true 0 false第一条发送不需要同时存在接收者,因为还有一个空位。关闭之后,7 没有消失;先把缓冲读完,后续接收才返回 0, false。因此仅凭收到零值不能判断结束,0 也可能是合法数据。[3:1][4:1]
正在绘制图示…
flowchart LR
S[发送者] -->|有空位:写入| Q[容量为 2 的队列]
Q -->|取出一个值:腾出空位| R[接收者]
S -. 队列满:等待接收释放空位 .-> Q
C[唯一关闭者] -->|保证不再发送后 close| Q
Q -->|已关闭且读空:返回零值和 false| E[接收循环结束]
缓冲容量决定可以暂存多少个元素。它能吸收短时间的生产速度波动;长期生产快于消费,最终仍然会满。满了让上游等待就是背压。盲目扩大缓冲只会把等待挪进内存,还可能延长数据处理延迟。这里的容量建议是根据上表行为推导出的设计取舍,不是运行时给出的最佳参数。
len(ch) 返回当时排队的元素数,不能当作后续操作一定安全的保证。检查完之后,其他 goroutine 可能已发送、接收或关闭 channel。要尝试立即发送,直接用带 default 的 select 表达这个动作;要保证关闭安全,则要设计发送者之间的协调。[7][5:1]
让 G1~G4 共用一个 chan int。你决定哪个 G 先行动:观察值去了哪里、谁在等待,再试着关闭 channel。
修改容量会创建一个新 channel,并清空本轮记录;Go 中已有 channel 的容量不能修改。
已创建容量为 1 的 chan int。选择一个 G,尝试发送、接收或关闭。
打开 · 缓冲 空 / 容量 1
G1:可继续
G2:可继续
G3:可继续
G4:可继续
发送把值交给接收者或缓冲,无法交付时等待。接收取出下一个值,同时返回 ok;关闭会保留缓冲数据并唤醒等待者,重复关闭会 panic。
第 0 步:已创建容量为 1 的 chan int。选择一个 G,尝试发送、接收或关闭。
缓冲:左侧为队首,先入先出;右侧空位可以接纳新值。
没有发送者等待。
没有接收者等待。
还没有操作。阻塞操作会保留在这里;配对或关闭后,同一条记录会更新结果。
以下是操作指导,不会替你切换容量或执行动作。请先重建 channel,再逐步尝试。
容量 0:G1 发送 7;再选 G2 接收。
先观察 G1 阻塞,再观察两者配对,len 始终为 0。容量 1:G1 发送 7;G2 发送 8;G3 接收。
G3 得到 7,G2 的 8 才进入缓冲,缓冲仍满。容量 1:G1 发送 0 后关闭;用 G2 连续接收两次。
第一次 ok=true,第二次 ok=false。也可先让几个 G 等待,再由另一个 G 关闭。这是 JavaScript 教学状态机,没有运行 Go。每次点击选择一个普通 channel 操作;本例按进入等待队列的顺序处理配对,把通信结果与相关唤醒合并成一步。“可继续”只表示本例已记下结果,不表示真实 G 立即获得 CPU,也不保证它先返回。
缓冲按本例已经发生的发送顺序出队。不同 G 谁先到达、谁先恢复,不由 G 的编号决定;不要据此推断所有 Go 程序的输出顺序或调度公平性。这里只演示 make 得到的非 nil channel,不包含 select、取消、真实并行或完整的 panic/recover 控制流。为便于查看,每轮最多记录 200 步;到达上限后需重建,不会悄悄删掉旧记录。
close 唤醒等待接收者,让它们得到 0, false;它也唤醒等待发送者,但结果是 panic,并不会替它们完成发送。真实运行时在等待者恢复执行后体现这些结果。关闭者必须协调好发送生命周期;关闭成功本身不能证明这样做安全。
槽位显示的是逻辑队列,读取后会左移以便观察;并非 Go 运行时环形缓冲区的物理内存布局。len 也是本步快照,不能用来保证后续并发操作安全。
不操作也能读懂:无缓冲要配对;缓冲满了发送会等,空且未关闭时接收会等;close 保留已有值;读空后返回 0, false;关闭后发送和重复关闭会 panic。
依据:Go 规范:channel、接收、发送、关闭、panic/recover;runtime/chan.go 的等待队列与唤醒路径(核对日期:2026-10-14,源码链接随版本更新)。
发送完 close(ch) 表示后续不再发送。接收者可以用 for v := range ch 读到结束。channel 不必为了垃圾回收而关闭;通常在接收者需要知道“没有更多值”时才需要 close。[8]
“发送方关闭”是很好的所有权惯例,但语言并没有检查“谁是发送方”。更准确的要求是:关闭者必须能够证明所有发送已经结束,而且只关闭一次。
| 发送结构 | 关闭责任 | 为什么这样安排 |
|---|---|---|
| 一个生产者 | 生产者发送完后关闭 | 它知道自己不会再发送 |
| 多个生产者共享输出 | 协调者等所有生产者退出后关闭 | 任一生产者先关闭都可能撞上其他发送者 |
| 消费者决定不再读 | 消费者发取消信号,不直接关闭上游数据 channel | 上游可能还在发送;需要让发送者先退出 |
| 多个接收者等结束通知 | 由一个所有者关闭专门的 done channel | 每个接收者都能观察到关闭状态 |
多发送者常用 WaitGroup 记录发送者的结束,再由一个协调 goroutine 执行 Wait(); close(out)。登记计数必须发生在等待可能返回之前。不能让一个生产者在 defer close(out) 里抢先关闭共享输出。[9][10]
关闭输出和取消计算也有区别。关闭一个没有数据的 done channel 可以广播停止请求,但每个工作者仍得写出接收它并返回的逻辑。关闭业务数据 channel 不会自动撤回缓冲中的任务,更不会强制中断正在处理的任务。
未初始化的 channel 变量是 nil:
var disabled chan int
select {
case disabled <- 1:
panic("unreachable")
default:
fmt.Println("nil channel: default")
}实际输出:
nil channel: defaultnil channel 在 select 里相当于禁用该通信分支。这在合并多个输入时很实用:某个输入关闭以后,把本地变量设为 nil,就不再反复选到它。[1:1][5:2]
case v, ok := <-a:
if !ok {
a = nil
continue
}
out = append(out, v)文末的 Merge 对 a、b 都这样做,外层条件为 a != nil || b != nil。两个输入都关闭后循环结束。本次把 a 中的 1、3 和 b 中的 2、4 合并,再排序打印:
merged, sorted: [1 2 3 4]这里专门写了“再排序”:不同 channel 之间没有全局发送顺序,合并的自然结果不保证有序。Merge 还假设所有非 nil 输入最终都会关闭;如果输入可能永远等不到下一条数据,这个版本仍需增加 context 取消分支。关闭输入后不置 nil,则已关闭分支一直可接收,可能形成空转并不断读到零值。[3:2][5:3]
select 会先求值各分支的 channel 表达式和发送值表达式,再从能够进行的通信中选择一个;若有多个就绪分支,规范规定按均匀伪随机方式选择。没有就绪分支时,有 default 就执行它,没有则阻塞。[5:4]
由此有三个实际后果。
第一,select 没有“第一条分支优先”的承诺。把 ctx.Done() 写在最前面,不能保证取消一到就总先执行它;也不能据此承诺每个工作者或请求在有限时间内得到公平服务。
第二,发送右侧的函数会先求值。写 case out <- expensive():,即使最后选择了取消分支,expensive() 也已被调用。因此昂贵计算最好有自己的取消检查,别把 select 当成延迟求值语法。[5:5]
第三,default 是“现在做不了就继续”,不是“稍后再试”。循环中不断进入 default 而不做阻塞等待,可能让 CPU 持续空转。丢弃日志、拒绝超额请求可以有明确的 default 策略;希望等待数据时,一般应让 select 阻塞。
先看哪些接收现在能进行,再亲手选择一个合法分支。试试读完缓冲、关闭 channel 和赋 nil,观察就绪集合怎样改变。
A:nil 的通信永远不能进行。 缓冲:−999。修改后已重建。A:没有值,也没有其他发送者。 缓冲:−999。修改后已重建。A:仍可立即接收零值,ok=false。 缓冲:−999。修改后已重建。A:缓冲里的值可以立即取出。 缓冲:−999。修改后已重建。A:缓冲里的值可以立即取出。 缓冲:−999。修改后已重建。A:缓冲里的值可以立即取出。 缓冲:7。修改后已重建。
B:nil 的通信永远不能进行。 缓冲:−999。修改后已重建。B:没有值,也没有其他发送者。 缓冲:−999。修改后已重建。B:仍可立即接收零值,ok=false。 缓冲:−999。修改后已重建。B:缓冲里的值可以立即取出。 缓冲:−999。修改后已重建。B:缓冲里的值可以立即取出。 缓冲:−999。修改后已重建。B:nil 的通信永远不能进行。 缓冲:空。修改后已重建。
初始值范围 −999~999。修改配置会创建新 channel 并清空记录;已关闭的 channel 不能重新打开。
A、Done 同时就绪,任选其中一条都合法。书写顺序不决定优先级,Done 也不会自动抢先。
正在等待。下面的外部事件可以让接收就绪,阻塞不是错误或结束。
没有通信就绪,也没有 default。点“等待事件”后,再到下方送入一个值。
还没有进入等待。这次 select 正在等待;送入值或触发 Done 后,再选就绪分支。进入 select:没有通信就绪,也没有 default,开始阻塞,等待外部事件。外部事件:关闭 Done。取消分支已就绪,但没有自动选中它。外部事件:关闭 Done。取消分支已就绪,但没有自动选中它。 等待条件已解除;由你选一个合法接收结果。执行 default;没有进行接收,也没有 value / ok。执行 Done 接收:value=struct{}{},ok=false。外部事件:关闭 B。它现在可接收零值,ok=false。在两次 select 之间将 B 赋为 nil;后续 select 不再接收它。赋值没有关闭原 channel。外部事件:向 B 的缓冲送入 -999,尚未由 select 接收。外部事件:关闭 B。它现在可接收零值,ok=false。 等待条件已解除;由你选一个合法接收结果。外部事件:向 B 的缓冲送入 -999,尚未由 select 接收。 等待条件已解除;由你选一个合法接收结果。外部事件:关闭 B。已缓冲的值仍须先读完。执行 B 接收:value=-999,ok=true。执行 B 接收:value=0,ok=false。外部事件:关闭 A。它现在可接收零值,ok=false。在两次 select 之间将 A 赋为 nil;后续 select 不再接收它。赋值没有关闭原 channel。外部事件:向 A 的缓冲送入 -999,尚未由 select 接收。外部事件:关闭 A。它现在可接收零值,ok=false。 等待条件已解除;由你选一个合法接收结果。外部事件:向 A 的缓冲送入 -999,尚未由 select 接收。 等待条件已解除;由你选一个合法接收结果。外部事件:关闭 A。已缓冲的值仍须先读完。执行 A 接收:value=-999,ok=true。执行 A 接收:value=0,ok=false。还没有进入等待。
Go 规范要求从能进行的通信中均匀伪随机地选择一个。本例让你指定一个合法结果,便于对照;点击次数不代表随机频率、有限时间内的公平性或 CPU 耗时。
A:打开,缓冲值 7。缓冲 7。选择一个外部事件,看看这里怎样变。
当前缓冲:7。缓冲里的值可以立即取出。
B:nil。缓冲 空。选择一个外部事件,看看这里怎样变。
当前缓冲:空。nil 的通信永远不能进行。
关闭会让取消分支就绪;不强制替你选择它,也不自动结束本例。
本轮还没有触发 Done。 当前就绪:A、Done。
送入值、关闭和触发 Done 都代表其他 G 的外部事件。赋 nil 是接收方在两次 select 之间执行的赋值,不是关闭原 channel。
进入 select 时,各接收分支的 channel 操作数已经求值。本例不让正在等待的同一个 G 跳到后面的赋值;先让本次接收完成,再把变量赋 nil,影响下一次 select。这里没有其他接收者抢走数据。
第 0 步:尚未执行。先看上方就绪集合,再选择一个合法分支。
下方保留最近一次已完成的 select 结果;外部事件或赋 nil 不会冒充一次接收。
还没有操作;就绪预览和预设配置不计步骤。
这是 JavaScript 接收分支教学模型,浏览器没有运行 Go。A、B 各自最多缓冲一个 int,Done 是只关闭、不发送值的 chan struct{}。这里只展示通信是否能进行,不模拟发送表达式求值、超时或完整运行时调度。
nil 不会就绪;打开且空需要外部事件;关闭后先读完缓冲,再重复得到 0、false。无通信就绪时,有 default 就继续,没有就等待。多个通信同时就绪时,只执行其中一个,取消分支没有书写优先权。
依据:Go 规范:Select statements;Receive operator(核对日期:2026-10-14)。
一个局部等待可以创建 timer,再与输入竞争:
timer := time.NewTimer(d)
defer timer.Stop()
select {
case v, ok := <-in:
return v, ok
case <-timer.C:
return 0, false
}本次对 nil 输入调用 ReceiveWithin(nil, time.Millisecond):
nil receive timed out: true这个教学函数用同一个 false 表示输入关闭或超时,调用者若需要区分两种原因,接口应改成返回显式状态或 error。一毫秒只是让测试有一个很短的到期时间,不是对调度精度的保证。[11]
| 等待范围 | 比较清楚的表达 | 容易改变的语义 |
|---|---|---|
| 当前接收最多等一段时间 | 本次调用创建一个 timer | 重试时重新计时,相当于每次都重新给预算 |
| 整个请求共用一个截止时间 | 传入同一个有 deadline 的 context | 每一层重新建独立根 context,会丢掉总预算 |
| 周期工作 | time.NewTicker 加取消分支 | ticker 不是任务完成确认;慢消费者可能丢 tick |
Go 1.23 起,垃圾回收器可以回收不再被引用的 timer,timer channel 也改为无缓冲语义,避免 Stop/Reset 返回后收到旧值。Go 1.23 的过渡规则曾受模块版本和 GODEBUG=asynctimerchan 影响;本文使用的 Go 1.27.2 已移除该旧行为开关,不能再靠它回到旧模式。这里仍调用 Stop,是为了明确停止不再需要的 timer,不是声称当前版本必须靠 Stop 才能回收。[11:1][12]
channel 传的是值。发送切片、map 或指针只复制这些值本身,不会顺手复制它们指向的数据。发送后双方继续修改同一底层对象,仍需约定独占权或使用锁;通信的同步关系不等于对象从此永远线程安全。[13][6:1]
全部示例在 Go 1.27.2、Linux/amd64 运行,go test -race -count=1 ./...、go vet ./... 通过。测试覆盖两个关闭输入的合并和 nil 输入超时。输出中的合并序列在打印前显式排序,没有用一次运行结果宣称跨 channel 有序或 select 公平。关闭后发送的失败实验放在《Go 并发常见坑》,本文的完整程序均正常退出。
以同一目录为根,按下面标出的相对路径保存文件。使用 Go 1.27.2 运行:
go run .
go test -race -count=1 ./...
go vet ./...module example.com/go-articles/02_channel
go 1.25.0package main
import (
"fmt"
"slices"
"time"
)
// Merge consumes finite, eventually closed inputs. Nil disables an input.
func Merge(a, b <-chan int) []int {
var out []int
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
out = append(out, v)
case v, ok := <-b:
if !ok {
b = nil
continue
}
out = append(out, v)
}
}
return out
}
func ReceiveWithin(in <-chan int, d time.Duration) (int, bool) {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case v, ok := <-in:
return v, ok
case <-timer.C:
return 0, false
}
}
func main() {
var disabled chan int
select {
case disabled <- 1:
panic("unreachable")
default:
fmt.Println("nil channel: default")
}
unbuffered := make(chan int)
select {
case unbuffered <- 1:
default:
fmt.Println("unbuffered: not ready")
}
buffered := make(chan int, 1)
buffered <- 7
close(buffered)
a, okA := <-buffered
b, okB := <-buffered
fmt.Println(a, okA, b, okB)
_, ok := ReceiveWithin(nil, time.Millisecond)
fmt.Println("nil receive timed out:", !ok)
aIn, bIn := make(chan int, 2), make(chan int, 2)
aIn <- 1
aIn <- 3
bIn <- 2
bIn <- 4
close(aIn)
close(bIn)
merged := Merge(aIn, bIn)
slices.Sort(merged)
fmt.Println("merged, sorted:", merged)
}package main
import (
"slices"
"testing"
"time"
)
func TestMergeClosedInputs(t *testing.T) {
a, b := make(chan int, 2), make(chan int, 2)
a <- 1
a <- 3
b <- 2
b <- 4
close(a)
close(b)
got := Merge(a, b)
slices.Sort(got)
if !slices.Equal(got, []int{1, 2, 3, 4}) {
t.Fatalf("got %v", got)
}
}
func TestNilTimeout(t *testing.T) {
if _, ok := ReceiveWithin(nil, time.Millisecond); ok {
t.Fatal("unexpected result")
}
}规范:Channel types,方向、nil、缓冲与 FIFO 条件。 ↩︎ ↩︎
Go Tour:Range and Close。发送者关闭、关闭并非总是必要,是这里的教学惯例。 ↩︎
官方 pipeline 文章,Fan-out, fan-in 与 Stopping short。 ↩︎
time,Go 1.27.2,NewTimer、Timer.Stop、NewTicker。 ↩︎ ↩︎
官方 Go 1.23 Timer Channel Changes说明历史过渡规则;当前版本对照 Go 1.27.2 已移除设置表,asynctimerchan 的 Removed 为 27,旧值 1、2 已不再支持。 ↩︎
规范:Assignments,赋值复制值及共享引用的数据。 ↩︎