golang channel
channel 不是“并发安全的队列”这么简单。它更准确的角色是:让 goroutine 在传递一个值的同时建立同步关系。发送方把值交出去,接收方把值拿走;如果此刻无法完成交接,其中一方就会等待,直到另一个 goroutine 让这次通信继续。
理解 channel 时,建议始终回答四个问题:
- 值存在哪里:直接交给等待的接收者,还是进入缓冲区?
- 发送或接收不能立即完成时,哪个 goroutine 会等待?
- 谁拥有关闭 channel 的权力?
- 这次等待如何因取消、超时或定时任务而结束?
本文从运行时的实现模型出发,依次讲清楚 goroutine 如何收发数据、select 怎样选择 case,以及定时器为什么能和 channel 自然配合。
先建立正确模型:通信、队列与同步
1 | |
- 无缓冲 channel:发送与接收必须会合。
ch <- v成功,意味着已有接收者接走了v;v := <-ch成功,意味着已有发送者交出了值。 - 有缓冲 channel:缓冲区还有空位时,发送者可先离开;缓冲区还有值时,接收者可先取走。只有“缓冲满时发送”或“缓冲空时接收”才需要等待。
- FIFO 只描述缓冲中的值:多个发送者或接收者并发竞争时,谁先真正完成通信由调度和等待队列决定;不要把它当作跨 goroutine 的全局业务排序器。
下图把两种 channel 的等待点放在一起:区别在于是否有可暂存值的环形缓冲区,而不是并发安全性。
channel 在运行时里长什么样
make(chan T, n) 会在运行时创建一个内部结构(当前运行时名称为 hchan)。它不是 Go 语言规范承诺的 API,字段和优化会随版本调整。下面按运行时源码的字段顺序,使用更容易阅读的 C 风格表示(类型名称做了等价转写):
1 | |
最关键的字段是 buf + sendx + recvx + qcount 组成的环形缓冲区,以及 recvq、sendq 两条 goroutine 等待队列。lock 保护这些字段及相关等待记录;timer 让 Timer、Ticker 的时间事件能走进同一套 channel 收发逻辑。
阻塞的 goroutine 不会傻等。运行时会为它创建一个等待记录(内部称为 sudog),其中保存 goroutine、本次待发送值或接收目标的位置,以及它正在等待的 channel;随后把 goroutine park,让线程去运行别的 goroutine。之后有匹配的收发或 close 发生时,运行时再把它 ready,等待调度器重新运行它。
谁唤醒阻塞的 goroutine
一句话结论:不是 channel 自己创建后台线程轮询,也不是被阻塞的 goroutine 自己醒来;是后来完成配对收发或执行 close 的那个 goroutine,在运行时中调用 goready 将对方标记为可运行。
以接收方先到、发送方后到为例:
源码层面,阻塞发送会把当前 goroutine 的 sudog 放入 sendq,再调用 gopark(chanparkcommit, ...);阻塞接收同理放入 recvq。chanparkcommit 会在 goroutine 已经进入可安全等待的状态后释放 hchan.lock,这样后到的 goroutine 才能拿锁、完成通信并唤醒它。
| 当前谁在等待 | 后来是谁操作同一条 channel | 运行时完成什么 | 谁调用唤醒 |
|---|---|---|---|
发送者在 sendq | 接收者执行 <-ch | 复制值,取出等待发送者 | 接收者所在 goroutine 通过 recv 调用 goready(发送者) |
接收者在 recvq | 发送者执行 ch <- value | 复制值,取出等待接收者 | 发送者所在 goroutine 通过 send 调用 goready(接收者) |
| 发送者或接收者在队列中 | 任意拥有关闭权的 goroutine 执行 close(ch) | 将两条等待队列中的 goroutine 收集起来 | closechan 释放 channel 锁后逐个调用 goready |
goready 的含义是变为可运行,不是“马上在当前调用栈继续执行”。运行时会把目标 goroutine 从 Waiting 标为 Runnable,放入某个 P 的运行队列并通知调度器;它究竟立刻运行,还是在稍后由某个 M 执行,仍由 GMP 调度决定。这样,持有 hchan.lock 的 goroutine 不会在锁内直接切换到对方,避免锁与调度交错造成死锁。
这里可以和 Golang GMP 调度模型 对照理解:channel 决定 G 因通信而 Waiting 或 Runnable,GMP 再从 P 的运行队列中挑选可运行的 G 交给 M 执行。也就是说,goready 不是立即恢复执行,而是把“这次通信已经满足”的 G 交回调度器。
close 是一个特殊的批量唤醒:等待接收者恢复后会观察到 ok == false;等待发送者恢复后会触发“向已关闭 channel 发送”的 panic。因此,close 只表示“发送端永久结束”,不能作为强制停止其他发送 goroutine 的手段。
对于 select,一个 goroutine 可能同时挂在多条 channel 的等待队列里。第一个成功配对的操作会用 selectDone 原子标记赢得唤醒权,再由 goready 唤醒该 goroutine;它恢复后会自行从其余 channel 的等待队列摘除自己的 sudog。这就是多个 case 不会把同一个 goroutine 唤醒多次的原因。
这也是 channel 并发安全的来源:运行时在同一把锁下检查状态、移动数据、维护等待队列,最后再唤醒对方。它不意味着你的业务对象天然安全——例如把同一个 map 指针发给两个 goroutine 后,仍需设计好其后续访问方式。
发送:优先直接交接,其次写缓冲,最后等待
执行 ch <- value 时,可以把运行时逻辑理解成下面的优先级:
- channel 为
nil:普通发送永久阻塞;在select中,这个 case 被禁用。 - channel 已关闭:立即 panic。
- 已有 goroutine 在
recvq等待:把value直接复制到那个接收者的目标位置,唤醒它。即使是有缓冲 channel,这一步也会绕过缓冲区。 - 没有等待接收者,但缓冲未满:把值复制到
buf[sendx],移动sendx,qcount + 1。 - 否则:当前 goroutine 带着待发送值进入
sendq并 park,直到接收者取走它,或 channel 被关闭。
无缓冲 channel 的容量为零,因此必然落在第 3 或第 5 步:它就是一次严格的发送—接收会合。
发送端必须为取消留出口
下面的生产者只拥有 out 的发送权,因此也由它负责关闭。关键不在 go 关键字,而在第二个 select:当消费者已经返回时,ctx.Done() 能让生产者不再永久卡在发送处。
1 | |
chan<- int 是只发送 channel。它不是新建了一条 channel,而是把同一条 channel 的“关闭与发送能力”明确留在生产者侧,避免接收方误发送或误关闭。
接收:优先取缓冲,其次直接取等待发送者
执行 value, ok := <-ch 时,逻辑与发送相对:
- channel 为
nil:普通接收永久阻塞;在select中,这个 case 被禁用。 - 缓冲区非空:从
buf[recvx]取值,移动recvx,qcount - 1。 - 缓冲为空但有 goroutine 在
sendq等待:直接从等待发送者取得值,唤醒发送者。 - channel 已关闭且缓冲已空:立刻得到元素零值与
ok == false。 - 否则:当前 goroutine 进入
recvq并 park,等待发送、关闭或取消路径。
一个容易遗漏的细节:缓冲已满且有发送者在等待时,接收并不是简单地“取走一个值”。运行时会把队头元素交给当前接收者,同时把等待发送者的值补到刚空出来的槽位,再唤醒发送者。这样既保持缓冲 FIFO,也让等待发送者立即继续。
接收端也要响应取消
接收时使用双返回值,才能区分“发送者真的发送了零值”和“channel 关闭且已耗尽”。
1 | |
in 是只接收 channel。调用方可以把同一条双向 channel 传进来,但函数内部既不能发送,也不能关闭它。方向约束让“谁负责什么”直接体现在签名上。
把两个函数组合起来,生命周期就清晰了:
1 | |
close 是“不会再发送”,不是“删除 channel”
关闭只是一条广播式的状态变化:它表示以后不会再有新值进入。已经在缓冲区中的值仍按 FIFO 被接收;等缓冲耗尽后,接收立刻返回零值与 ok == false。因此 for value := range ch 会在“已关闭且已取空”时结束。
| channel 状态 | 发送 | 接收 | close |
|---|---|---|---|
nil | 永久阻塞 | 永久阻塞 | panic |
| 打开、缓冲可用或有配对方 | 可继续 | 可继续 | 成功 |
| 打开、无法立刻配对 | 阻塞 | 阻塞 | 成功并唤醒等待者 |
| 已关闭、缓冲未空 | panic | 继续取缓冲值 | panic |
| 已关闭、缓冲为空 | panic | 立即得到零值,ok == false | panic |
通常遵守一个简单的所有权规则就够了:由唯一发送者关闭;多个发送者时,由能确认所有发送都结束的协调者关闭;接收者不关闭。 close 会唤醒所有等待接收者和发送者,但被唤醒的发送者会因“向已关闭 channel 发送”而 panic,所以它不能用来粗暴终止仍在发送的 goroutine。
select:在多个通信机会中挑一个
select 是 channel 操作的多路复用器。它只关心 case 中的发送或接收是否现在就能完成:
1 | |
语义可以记为:
- 所有 case 的 channel 操作数(以及发送值表达式)会先求值一次;真正选中的 case 才进行收发、左侧赋值与语句体。
- 没有就绪 case 时:有
default就立刻执行它;没有default,当前 goroutine 阻塞。 - 有多个就绪 case 时,Go 规范要求在它们中作均匀伪随机选择。因此
select不能保证优先级、公平轮转或消息顺序。 - 对已关闭 channel 的接收永远就绪;如果在循环里不处理
ok == false,会变成高速空转。 nilchannel 的 case 永远不会就绪,可用来动态禁用一个分支。
运行时如何避免“多头等待”
以 select { case <-a: case <-b: } 为例,运行时会大致执行以下流程:
- 收集非
nilcase,并打乱轮询顺序,避免固定从第一个 case 开始造成偏向。 - 按 channel 地址排序后依次加锁,避免多个 goroutine 同时 select 多条 channel 时发生锁顺序死锁。
- 按已打乱的顺序检查:是否有等待发送者、缓冲值、空位或关闭状态;若有,立即完成一个 case。
- 如果没有就绪 case 且存在
default,直接返回default。 - 否则把同一个 goroutine 的等待记录分别挂到每条相关 channel 的发送或接收队列,然后 park。
- 任一通信把它唤醒后,运行时只保留胜出的那一个等待记录,并从其余 channel 的队列删除记录,再继续执行对应 case。
所以 select 不是轮询加 time.Sleep,也不是起多个 goroutine 去抢结果;它由运行时把“等待任一个事件”的状态原子地管理起来。
两个实用模式
非阻塞尝试只适合确实允许丢弃或稍后重试的场景:
1 | |
不要把 default 当作“更高性能的 channel”。它改变的是语义:原来要等待的背压,现在变成了丢弃、重试或忙等的责任。
动态合并多个输入可将已经耗尽的输入置为 nil,让它退出后续选择:
1 | |
定时器:把“时间到了”变成一个 channel 事件
time 包把时间事件暴露为只读 channel,因此可以直接放入 select。这让“结果先到、取消先到、超时先到”可以写在同一个等待点。
| 工具 | 含义 | 适用场景 |
|---|---|---|
time.After(d) | d 后向返回的 channel 发送一次时间 | 一次性的简单超时 |
time.NewTimer(d) | 可 Stop、Reset 的一次性计时器 | 需要取消或重复复用计时器 |
time.NewTicker(d) | 每隔 d 产生一次 tick | 周期性检查、刷新、批处理触发 |
time.AfterFunc(d, f) | d 后在自己的 goroutine 调用 f | 明确需要回调时;需自行协调回调并发 |
一次超时:让等待有终点
对于一个局部、一次性的等待,time.After 最直接:
1 | |
如果调用方要取消、提前结束或在循环中重设同一个截止时间,用 Timer 更合适:
1 | |
从 Go 1.23 起,未引用的 Timer 和 Ticker 可以被垃圾回收,Timer.C 的语义也消除了旧版本 Stop / Reset 后可能读到陈旧 tick 的问题。不过 Stop 仍有明确业务意义:你知道计时器已不该继续触发时,应停止它;Ticker.Stop 尤其重要,因为它不会关闭 C,等待它的 goroutine 仍须通过 context 或 done 离开。
周期任务:tick 不是“必须执行一次”的任务队列
Ticker 的接收者处理得比周期慢时,运行时会调整间隔或丢弃 tick,避免无限堆积。因此它适合“定期看一眼状态”,不适合“每一秒的任务都必须执行一次”。后者应保存任务时间点并通过持久化队列补偿。
下面是一个可停止、可等待退出的周期循环。stop 由外部关闭来广播停止,done 表示 goroutine 已完成收尾;两者不要混用。
1 | |
实际服务通常以 ctx.Done() 代替 stopCh,把周期任务纳入请求、组件或进程的统一生命周期管理。
一个完整的 worker pool:收发、关闭与收尾
下面的结构把所有权放在正确的位置:任务提交方关闭 jobs;协调者等待所有 worker 结束后关闭 results;消费者只读取 results。每一个可能阻塞的收发都同时监听 ctx.Done()。
1 | |
注意结果顺序不保证与 inputs 一致:多个 worker 的完成时间本来就不同。若业务需要按输入顺序返回,应连同索引一起发送,再在接收端归位。
写 channel 代码前的检查清单
- 这个 goroutine 在什么条件下退出?调用方能否等待它退出?
- 谁发送、谁接收、谁拥有关闭权?多发送者时谁来统一关闭?
- 每个可能阻塞的发送和接收,取消后是否都能返回?
- 缓冲区大小对应什么实际容量或背压策略?不要用“大一点试试”掩盖消费速度问题。
select中有default时,满/空 channel 的数据该如何处理?是否会忙等?- 这是一次超时、可重置的 deadline,还是可丢 tick 的周期检查?分别选
After、Timer或Ticker。
总结
channel 的核心不是语法 <-,而是运行时对数据复制、缓冲状态、等待队列和 goroutine 唤醒的协作。发送会优先交给等待接收者,接收会优先取出缓冲值;不能继续时,goroutine 被 park 而不是占用线程空转。select 把“等待任一个通信事件”变成一个原子选择,定时器则把时间也接入同一套事件模型。
当你能明确回答“值去哪、谁会等、谁关闭、如何取消”时,channel 代码通常就已经具备了正确的骨架。






