这是「Go 并发原理实战」系列的第二篇。第一篇:从一次 K8s Controller OOM 聊起——彻底搞懂 Go GMP 调度模型
一个线上事故
你的团队维护一个 Kubernetes admission webhook,负责在 Pod 创建时做合规检查:镜像是否来自可信仓库、是否设了资源限制、是否有安全上下文。
架构很简单:webhook 收到请求后,把 Pod spec 同时发给三个检查器(镜像检查、资源检查、安全检查),等所有结果回来再汇总返回。经典的 fan-out/fan-in 模式。
某天业务方反馈:高峰期大量 Pod 创建失败,报 webhook 超时(30s)。但你检查单个检查器的逻辑,每个都在毫秒级完成。三个加起来也不可能要 30 秒。
问题出在哪?出在 channel 上。
先搞清楚:Channel 到底是什么?
上一篇我们讲了 GMP——goroutine 怎么被调度到线程上执行。但 goroutine 之间怎么通信?怎么传递数据?怎么协调"你先做完我再做"?
答案就是 channel。
用传送带比喻
还是用上一篇的大楼比喻。大楼里有很多员工(goroutine)在不同办公室干活。他们之间怎么传递文件?
Channel 就是两个办公室之间的传送带。
- 无缓冲 channel = 没有传送带,只有一个窗口。发送方必须亲手把文件递过去,接收方必须同时伸手接。两个人必须同时在窗口,否则先到的那个就得等着。这是一次"面对面交接"。
- 有缓冲 channel = 窗口下面有个小柜子(缓冲区)。发送方可以把文件放进柜子就走,不用等对方。但柜子满了,发送方也得等。
ch := make(chan int) // 无缓冲:窗口交接,必须同步
ch := make(chan int, 5) // 有缓冲:柜子能放 5 份文件
Channel 的底层结构
传送带的比喻帮你建立直觉,但面试官会问底层。Channel 在 Go runtime 中是一个叫 hchan 的结构体:
type hchan struct {
qcount uint // 柜子里现在有几份文件
dataqsiz uint // 柜子最多能放几份(缓冲区大小)
buf unsafe.Pointer // 柜子本身(环形缓冲区)
elemsize uint16 // 每份文件多大
closed uint32 // 传送带是否已关闭
sendx uint // 下一份文件放在柜子的哪个格子
recvx uint // 下一次从柜子的哪个格子取
recvq waitq // 在窗口等着取文件的人的排队队列
sendq waitq // 在窗口等着放文件的人的排队队列
lock mutex // 锁,同一时间只能有一个人操作传送带
}
一句话总结:channel = 一个带锁的环形队列 + 两个等待队列。
环形队列(buf)就是柜子,大小固定。两个等待队列(sendq、recvq)就是窗口两边排队的人。锁保证同一时间只有一个 goroutine 在操作 channel。
注意:等待队列的底层是链表,没有长度限制,不会满——来一个阻塞的 goroutine 就挂一个节点。这意味着真正的风险不是"等待队列满了",而是大量 goroutine 阻塞在等待队列上永远不被唤醒,造成 goroutine 泄漏(参见 GMP 篇中的 K8s controller OOM 案例)。
为什么是环形队列?
因为高效。不需要移动元素,只需要移动两个指针(sendx 和 recvx):
buf: [ 空 | 空 | 数据A | 数据B | 数据C | 空 | 空 | 空 ]
↑ ↑
recvx sendx
(下次从这取) (下次往这放)
取一个数据:从 recvx 位置取,recvx 往右移一格。放一个数据:往 sendx 位置放,sendx 往右移一格。到末尾了就绕回开头——所以叫"环形"。
环形队列的大小在 make(chan T, n) 时就固定了,运行时不能扩容。这是有意为之的设计——channel 的核心目的是同步和通信,不是当容器用。固定大小迫使你思考"满了怎么办",这就是背压(backpressure)机制:生产者太快就让它停下来等消费者,避免无限堆积数据最终 OOM。如果你需要动态大小的队列,应该用 slice 或第三方的无界队列,而不是 channel。
Channel 的方向:限制只发或只收
创建 channel 时默认是双向的(既能发也能收),但在函数参数中可以限制方向,编译期就能防止误用:
chan int // 双向 channel,既能发也能收
chan<- int // 只发 channel(箭头指向 chan,数据流入)
<-chan int // 只收 channel(箭头从 chan 出来,数据流出)
记忆方法:看箭头方向。chan<- int 箭头朝 channel 里指,数据只能往里送;<-chan int 箭头从 channel 出来,数据只能往外取。
实际使用时,通常创建双向 channel,传给函数时通过参数类型限制方向:
ch := make(chan int) // 双向
// 编译器自动将双向 channel 转为单向
go producer(ch) // 传入后只能发
go consumer(ch) // 传入后只能收
func producer(ch chan<- int) { // 只能往 ch 发数据,试图 <-ch 编译报错
ch <- 42
}
func consumer(ch <-chan int) { // 只能从 ch 收数据,试图 ch<- 编译报错
v := <-ch
}
这是 Go 类型系统的一个精妙设计:用编译期约束代替运行时错误。如果一个函数只应该发送,就把参数声明为 chan<-,有人不小心在里面写了接收代码,编译直接不过。
发送和接收的完整流程
这是面试高频题:往 channel 发送数据的时候,底层发生了什么?
发送流程(ch <- value)
Go runtime 按优先级检查三种情况:
情况 1:窗口对面有人在等着收(recvq 非空)
这是最快的路径。有人已经在窗口等文件了,直接把数据从发送方的栈拷贝到接收方的栈上,然后唤醒接收方。数据不经过柜子(buf)。
为什么不放柜子里再让对方取?因为多一次拷贝。直接发比"放进去再取出来"快。
情况 2:柜子有空位(qcount < dataqsiz)
没人等着收,但柜子没满。把数据放进 buf[sendx],移动指针,走了。
情况 3:柜子满了(或者根本没柜子——无缓冲 channel)
发送方被包装成一个 sudog(等待者描述符),挂到 sendq 队列上。然后调用 gopark()——上一篇讲过,这会把当前 goroutine 的状态从 _Grunning 改为 _Gwaiting,让出 P,goroutine 休眠。
等到有人从 channel 接收数据时,发送方才会被唤醒。
接收流程(value <- ch)
完全镜像对称:
情况 1:有人等着发(sendq 非空)
- 无缓冲 channel:直接从发送方栈拷贝数据到接收方栈
- 有缓冲 channel:从
buf[recvx]取数据,然后把 sendq 里等着的发送方的数据放入 buf(因为 buf 一定是满的,才会有人在 sendq 里等)
情况 2:柜子里有数据
从 buf[recvx] 取走,移动指针。
情况 3:柜子空了
挂到 recvq,gopark() 休眠。等有人发送数据时被唤醒。
回到那个事故
有了这些知识,我们来看 webhook 的代码:
func (w *Webhook) validate(ctx context.Context, pod *corev1.Pod) (bool, error) {
ch := make(chan checkResult) // 无缓冲 channel
// Fan-out:同时启动三个检查
go func() { ch <- w.checkImage(pod) }()
go func() { ch <- w.checkResources(pod) }()
go func() { ch <- w.checkSecurity(pod) }()
// Fan-in:收集三个结果
for i := 0; i < 3; i++ {
result := <-ch
if !result.allowed {
return false, result.reason
}
}
return true, nil
}
看起来没问题对吧?三个 goroutine 并发检查,主 goroutine 收集三个结果。但这里有一个隐蔽的 bug。
Bug 在哪?
当第一个或第二个结果就不合规时,return false 直接返回了。此时只收了 1 个或 2 个结果,但有 3 个 goroutine 在往 channel 发送。
场景:第一个结果就是 not allowed
主 goroutine:
<-ch → 收到 checkImage 的结果,not allowed → return false
(函数返回,ch 不再有人读)
剩下两个 goroutine:
checkResources 完成 → ch <- result → 无缓冲 channel,没人收 → 阻塞
checkSecurity 完成 → ch <- result → 无缓冲 channel,没人收 → 阻塞
又是 goroutine 泄漏。 和上一篇 GMP 文章中的 controller 案例一模一样的根因——无缓冲 channel,发送方阻塞,永远不会被唤醒。
为什么平时没事,高峰期才出问题?
低峰期大部分 Pod 都合规,三个结果都是 allowed,循环完整跑完,三个 goroutine 都正常退出。
高峰期很多 Pod 不合规(比如用了未授权镜像),触发提前 return,goroutine 开始泄漏。webhook 进程的 goroutine 数量持续增长,GC 压力增大(上一篇讲过:GC 需要扫描所有 goroutine 的栈),响应变慢,最终超时。
用 pprof 确认
kubectl port-forward deploy/webhook 6060:6060
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutine.txt
goroutine profile: total 52381
goroutine 12847 [chan send, 23 minutes]:
main.(*Webhook).validate.func2(...) ← checkResources 的 goroutine
/app/webhook.go:45
goroutine 12848 [chan send, 23 minutes]:
main.(*Webhook).validate.func3(...) ← checkSecurity 的 goroutine
/app/webhook.go:46
chan send — 结合上面的知识:这些 goroutine 卡在 ch <- result,状态是 _Gwaiting,挂在 channel 的 sendq 链表上。sendq 持有 goroutine 的引用,GC 无法回收。
修复
func (w *Webhook) validate(ctx context.Context, pod *corev1.Pod) (bool, error) {
ch := make(chan checkResult, 3) // ← 缓冲 = goroutine 数量
go func() { ch <- w.checkImage(pod) }()
go func() { ch <- w.checkResources(pod) }()
go func() { ch <- w.checkSecurity(pod) }()
for i := 0; i < 3; i++ {
result := <-ch
if !result.allowed {
return false, result.reason
// 即使提前返回,剩余 goroutine 发送到缓冲 channel
// 不会阻塞,正常退出
// channel 没有引用后会被 GC 回收
}
}
return true, nil
}
核心改动:make(chan checkResult, 3) — 缓冲区大小 = 生产者数量。即使没人接收,发送也不阻塞,goroutine 正常退出,channel 随后被 GC 回收。
经验法则:fan-out 模式中,channel 的缓冲大小至少等于 goroutine 数量,除非你能保证一定会读完所有结果。
无缓冲 vs 有缓冲:什么时候用哪个?
这个事故的根因是用错了 channel 类型。那什么时候用哪种?
无缓冲 channel:同步握手
ch := make(chan int)
发送和接收必须同时就绪。本质是两个 goroutine 的一次同步握手——“我把数据交给你,确认你拿到了我再走”。
适用场景:
- 信号通知:
done := make(chan struct{}),一方完成后close(done)通知另一方 - 确保交付:你需要确认对方拿到了数据才能继续
- 请求-响应:一个 goroutine 发请求,等另一个返回结果
有缓冲 channel:异步邮箱
ch := make(chan int, 100)
发送方只要柜子没满就不阻塞。解耦生产者和消费者的速率。
适用场景:
- 削峰:生产者短时间内产出很多数据,消费者慢慢处理
- worker pool:用 channel 当任务队列,多个 worker 消费
- 限制并发:
sem := make(chan struct{}, 10)当信号量用,最多同时 10 个 - fan-out/fan-in:像上面 webhook 的例子,缓冲 = 生产者数量
选择口诀
需要"面对面交接" → 无缓冲 需要"放进去就走" → 有缓冲
Select 多路复用
webhook 的代码其实还有一个问题:没有超时控制。如果某个检查器卡住了,主 goroutine 会永远等在 <-ch 上。加上 select 和 context:
// 创建一个 5 秒超时的 context,超时后 ctx.Done() 的 channel 会被关闭
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for i := 0; i < 3; i++ {
select {
case result := <-ch: // 正常收到数据就处理
if !result.allowed {
return false, result.reason
}
case <-ctx.Done(): // 5 秒超时了,走这个分支,不会永远卡住
return false, fmt.Errorf("validation timeout: %w", ctx.Err())
}
}
Select 的底层实现
select 是 Go 的多路复用器——同时监听多个 channel,谁先就绪就执行谁。底层是 selectgo 函数:
- 按 channel 地址排序所有 case(多个 channel 加锁时统一顺序,防死锁)
- 随机打乱轮询顺序(保证公平,不是总选第一个)
- 遍历所有 case,检查哪个能立即完成
- 多个就绪 → 随机选一个(所以 select 的行为是不确定的)
- 都没就绪且没 default → 把当前 goroutine 挂到所有 case 的 channel 的等待队列上,休眠
- 被任一 channel 唤醒后 → 从其他所有 channel 的等待队列中移除自己
第 5 步和第 6 步是关键:一个 goroutine 可以同时排在多个 channel 的等待队列上。被唤醒后要做清理,从其他队列中移除,否则同一个 goroutine 会被多次唤醒。
select + default 的用途
select {
case ch <- data:
// 发送成功
default:
// channel 满了或没人收,走这里(不阻塞)
}
加了 default 就变成非阻塞操作。上一步的第 5 步变成"直接走 default",不休眠。常用于"试一下,不行就算了"的场景。
Channel 关闭:规则与陷阱
关闭后的行为
close(ch) 后:
✅ 接收:继续接收,先取完缓冲区剩余数据,然后返回零值 + false
❌ 发送:panic: send on closed channel
❌ 再次关闭:panic: close of closed channel
用 close 做广播通知
这是 Go 中最优雅的模式之一。上面说无缓冲 channel 发送只能通知一个接收者,但 close 可以同时唤醒所有等待者:
quit := make(chan struct{})
// 启动 100 个 worker
for i := 0; i < 100; i++ {
go func() {
for {
select {
case <-quit: // quit 关闭后,所有 worker 都能收到
return
case task := <-taskCh:
process(task)
}
}
}()
}
// 需要停止时
close(quit) // 一次 close,100 个 worker 全部退出
为什么 close 能做广播?因为 close 会遍历 recvq 中所有等待的 goroutine,逐个唤醒。而普通发送只唤醒 recvq 中的第一个。
close 的本质:close 不是往 channel 里发一个特殊值,而是把 hchan 结构体的 closed 字段设为 1。一旦关闭,所有对它的接收操作都会立即返回该类型的零值(int → 0,string → "",struct{} → struct{}{}),永远不会阻塞。
从接收方的视角看,效果确实就像收到了一个零值。要区分"真的收到了数据 0"还是"channel 关了",用双返回值:
v, ok := <-ch
// ok == true → 正常数据(哪怕值恰好是 0)
// ok == false → channel 已关闭,v 是零值
这也是为什么广播通知推荐用 chan struct{} —— 你根本不关心收到的值,只关心"channel 关了"这个信号。struct{} 占 0 字节,纯粹当信号用,是 Go 社区公认的 best practice。
关闭的核心原则
只有发送方关闭 channel,永远不要在接收方关闭。
因为发送方知道"没有更多数据了",接收方不知道。如果接收方关闭了 channel,发送方继续发送就会 panic。
多个发送方的情况下,用一个单独的协调者来关闭,或者用 context 取消来代替 close:
// ❌ 危险:多个发送方,谁来关闭?
func producer(ch chan<- int) {
defer close(ch) // 多个 producer 都 defer close → panic
// ...
}
// ✅ 安全:用 context 通知停止,不关闭数据 channel
func producer(ctx context.Context, ch chan<- int) {
for {
select {
case <-ctx.Done():
return // 收到取消信号,直接返回
case ch <- data:
}
}
}
for range channel
for v := range ch {
fmt.Println(v)
}
// 等价于:
for {
v, ok := <-ch
if !ok { break } // channel 关闭且缓冲区空
fmt.Println(v)
}
for range 会一直读直到 channel 被关闭。如果没人关闭 channel,for range 会永远阻塞——又是一种 goroutine 泄漏。
面试常问
Q1:channel 是线程安全的吗? 是的。hchan 内部有 mutex,每次 send/recv 都会加锁。所以你不需要额外加锁来保护 channel 操作。
Q2:无缓冲 channel 发送和接收,数据拷贝了几次? 无缓冲:1 次。直接从发送方的栈拷贝到接收方的栈(
sendDirect/recvDirect),不经过 buf。 有缓冲:2 次。发送方栈 → buf(第 1 次),buf → 接收方栈(第 2 次)。所以无缓冲 channel 在"恰好有人等着收"的场景下反而更快——少一次内存拷贝。
Q3:为什么不建议用 channel 传递大结构体? 因为 channel 的每次 send/recv 都会做数据拷贝(
typedmemmove)。传大结构体就拷贝整个结构体。传指针只拷贝 8 字节。
Q4:向一个 nil channel 发送/接收会怎样? 永远阻塞。不会 panic,就是永远等着。这个特性有时候有用——在 select 中把某个 case 的 channel 设为 nil 可以"关闭"这个 case。
总结
| 概念 | 一句话 |
|---|---|
| Channel 本质 | 带锁的环形队列 + 两个 goroutine 等待队列 |
| 无缓冲 | 同步握手,两方必须同时就绪 |
| 有缓冲 | 异步邮箱,发送方放进去就走 |
| 发送到满 channel | goroutine 挂到 sendq,状态变 _Gwaiting |
| close | 唤醒所有等待者(广播),之后发送会 panic |
| select | 随机轮询 + 可同时挂到多个 channel 的等待队列 |
| 最常见的坑 | 无缓冲 channel + 提前 return = goroutine 泄漏 |
这篇文章的事故和上一篇(GMP)的事故根因完全相同——goroutine 阻塞在 channel 上无法退出。两篇连起来看,你会发现 GMP 和 channel 的知识是一体的:channel 的阻塞行为(挂到 sendq/recvq)直接影响 goroutine 在 GMP 调度器中的状态(_Gwaiting),进而影响内存和 GC。
下一篇:从一次 K8s 级联超时聊起——彻底搞懂 Go Context 的传播机制。