前三篇聊了基础语法、代码审查陷阱和 Java→Go 的思维转变。但有一个话题一直没展开——Channel 到底是怎么工作的? 这篇从 hchan 数据结构出发,把 channel 的底层机制讲透,所有案例来自 slg-go 项目中的真实用法。

背景:为什么必须懂 Channel 底层?

slg-go 战斗调度器里有这样一个关键设计:

1
2
3
4
5
6
7
8
// internal/battle/scheduler/scheduler.go:126-143
type Scheduler struct {
requests chan *BattleRequest // 有缓冲 channel,任务队列
// ...
}

// 创建时指定缓冲大小
s.requests = make(chan *BattleRequest, s.queueSize) // 默认 1000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// internal/battle/scheduler/scheduler.go:337-356 — worker 从 channel 取任务
func (s *Scheduler) worker(ctx context.Context, id int) {
defer s.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case req, ok := <-s.requests: // 阻塞等待任务
if !ok { return }
s.processBattle(ctx, req)
}
}
}

来源:internal/battle/scheduler/scheduler.go

这里用了一个 1000 容量的有缓冲 channel 做任务队列。为什么选 1000 而不是 1 或无缓冲?select 在底层到底做了什么?这些问题不搞清楚,写出来的并发程序就是「能跑」和「靠谱」的区别。


Q1:Channel 本质是什么?—— 一个环形队列 + 两组 goroutine 等待队列

Go 的 channel 在运行时的数据结构叫 hchan(定义在 runtime/chan.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// runtime/chan.go — hchan 核心结构(简化)
type hchan struct {
qcount uint // 当前队列中的元素个数
dataqsiz uint // 环形队列容量(make 时指定的 size)
buf unsafe.Pointer // 环形队列指针(dataqsiz > 0 时才分配)
elemsize uint16 // 元素大小
closed uint32 // 是否已关闭

sendx uint // 发送索引(队尾)
recvx uint // 接收索引(队头)

recvq waitq // 等待接收的 goroutine 队列(阻塞在 <-ch 上)
sendq waitq // 等待发送的 goroutine 队列(阻塞在 ch<- 上)

lock mutex // 保护所有字段的互斥锁
}

内存布局示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────┐
│ hchan │
├──────┬──────┬──────┬────────┬──────┬──────┬─────┤
│qcount│datasq│ buf │ sendx │ recvx│recvq │sendq│
│ 3 │ 5 │ [○●●○]│ 3 │ 0 │ [] │ [] │
└──────┴──────┴──────┴────────┴──────┴──────┴─────┘

buf(环形队列,capacity=5):
┌───┬───┬───┬───┬───┐
│ ○ │ ● │ ● │ ○ │ ● │ recvx=0, sendx=3
└───┴───┴───┴───┴───┘
↑ ↑
接收端 发送端

关键点:channel 内部有一把全局锁 lock。每次发送或接收都需要加锁,所以 channel 不是”无锁”的数据结构。它的性能优势在于通过阻塞/唤醒机制减少了自旋竞争,而不是省掉了锁。

无缓冲 vs 有缓冲的本质区别

无缓冲 make(chan T) 有缓冲 make(chan T, N)
内存分配 不分配 buf 分配 N * sizeof(T) 的连续内存
send 行为 必须等到有人 recv 才返回(同步握手) 缓冲未满立即返回(异步发送)
典型用途 goroutine 同步、信号传递 任务队列、流量控制
slg-go 用例 stopCh chan struct{}(停止信号) requests chan *BattleRequest(战斗队列)
1
2
3
4
5
6
7
8
9
10
11
// slg-go 中两种 channel 的实际使用

// 无缓冲 —— 用于信号传递
stopCh: make(chan struct{}) // scheduler.go:166

// 有缓冲 —— 用于任务队列
requests: make(chan *BattleRequest, 1000) // scheduler.go:180

// 有缓冲小容量 —— 用于消息传递
msgCh: make(chan *chat.Message, 128) // ws.go:45
controlCh: make(chan wsEnvelope, 64) // ws.go:46

来源:scheduler.go:166,180 / ws.go:45-46


Q2:向 Channel 发送数据的完整流程是怎样的?

以 ch <- value 为例,完整流程分三种情况:

情况一:直接交付(最快路径)

当有 goroutine 正在等待接收(recvq 非空)时,发送方直接把数据拷贝到接收方的栈上,完全不经过 buf:

1
2
3
4
5
6
// 伪代码:runtime chansend 核心逻辑
if sg := c.recvq.dequeue(); sg != nil {
// 直接拷贝到接收方的栈空间,绕过 buf
send(c, sg, ep, func() { unlock(&c.lock) })
return true
}

这就是无缓冲 channel 为什么能做同步握手的原因——发送和接收一定在同一时刻发生。

情况二:入队缓冲区(异步路径)

没有等待的接收者,且缓冲区没满 → 数据进入环形队列,发送方立即返回。

1
2
3
4
5
6
7
8
// scheduler.go:272-276 — 典型的非阻塞发送(带 default)
select {
case s.requests <- req:
battlemetrics.SetInflight(inflight)
return nil
default:
// 队列满,走降级处理
}

来源:scheduler.go:272-287

这里用 select + default 做非阻塞发送尝试:队列有空位就发进去,满了就走 default 分支回滚状态并返回错误。这是生产环境中最常见的背压模式。

情况三:发送者挂起(阻塞路径)

缓冲区满了且没人接收 → 发送者加入 sendq,调用 Gopark 挂起。

Gopark 是什么? 把当前 goroutine 的状态从 _Grunning 改为 _Gwaiting,然后调度器切走这个 M 去执行别的 G。被接收者唤醒后再恢复执行。(Q8 会详细讲调度器)


Q3:从 Channel 接收数据的流程?

value := <- ch 和发送是对称的,也分三种情况:

和发送的关键区别:接收时如果 sendq 非空(有挂起的发送者),除了取数据外,还会顺便把 sendq 头部的一个发送者的数据搬进 buf(如果有缓冲区的话),实现”搬一个补一个”的优化。

二值接收的妙用

1
2
case req, ok := <-s.requests:    // scheduler.go:345
if !ok { return } // channel 已关闭且取完数据

来源:scheduler.go:345

ok 的含义:

  • true:正常接收到数据
  • false:channel 已关闭,且缓冲区的数据已经被取完了

这是 worker 退出循环的标准写法——当 Stop() 调用 close(s.requests) 后,每个 worker 会把剩余请求处理完毕,读到 ok=false 时退出。


Q4:关闭 Channel 后会发生什么?

close(ch) 触发的行为是很多 bug 的源头:

1
2
3
4
5
6
7
8
9
// scheduler.go:204-213 — Stop 方法
func (s *Scheduler) Stop() {
s.stopOnce.Do(func() {
close(s.stopCh) // 通知 worker 停止
s.wg.Wait()
close(s.requests) // 通知 worker 退出循环
_ = s.resultPublisher.Close()
})
}

来源:scheduler.go:204-213

close(ch) 的精确行为:

操作 关闭后的行为
close(ch) 设置 closed=1,唤醒所有 recvq 和 sendq 中的 goroutine
<-ch(接收) 返回零值,ok=false;缓冲区有数据则先正常返回
ch <- x(发送) panic!向已关闭的 channel 发送会 panic
close(ch)(重复关闭) panic!

常见陷阱:生产者-消费者模型中,谁负责 close?答案是——只有生产者应该 close,而且最好只 close 一次(用 sync.Once 保护)。scheduler.go 的 stopOnce 就是干这个的。

广播机制的实现

关闭一个无 buffered channel 可以同时唤醒所有等待的 receiver——这就是 Go 里最简单的广播原语:

1
2
3
4
5
6
// scheduler.go:166 + :204-206
stopCh := make(chan struct{})

func Stop() {
close(stopCh) // 一行代码,所有监听 stopCh 的 worker 同时收到信号
}

N 个 worker 各自从同一个 channel 接收,close(stopCh) 让它们全部收到”零值+ok=false”,同时退出循环。比 Java 里 CountDownLatch.countDown() 还简洁。


Q5:Select 的底层怎么实现的?

select 不是简单的 if-else 链,它在编译期和运行期都有特殊处理:

编译期做了什么?

1
2
3
4
5
6
7
// 你写的代码
select {
case req := <-s.requests:
process(req)
case <-ctx.Done():
return
}

编译器会把这段代码重写成对 runtime.selectgo() 的调用,传入所有 case 的信息。

运行期 selectgo 的算法(简化版)

1
2
3
4
5
6
7
8
1. 用 fastrand() 对所有 case 做随机洗牌(避免饥饿)
2. 按顺序扫描每个 case:
a. 能立即执行的(channel 可收/发) → 加锁执行,返回
b. 如果有 default → 执行 default,返回
3. 都不能执行:
a. 把当前 G 加入所有相关 channel 的 recvq/sendq
b. Gopark 挂起
c. 被任一 channel 唤醒后,解除其他注册,返回匹配的 case

关键细节:随机洗牌。这保证了多个 channel 同时就绪时不会总是优先执行第一个 case(避免饥饿)。这也是为什么 select 的 case 执行顺序是不确定的。

多 channel 监听的真实案例

1
2
3
4
5
6
7
8
9
10
11
12
13
// cmd/chat/ws.go:51-71 — 写 goroutine 监听 3 个 source
go func() {
for {
select {
case <-ctx.Done(): // 1. 连接取消
return
case msg := <-msgCh: // 2. 聊天消息到达
websocket.JSON.Send(ws, ...)
case env := <-controlCh: // 3. 系统信封(订阅/取消等)
websocket.JSON.Send(ws, ...)
}
}
}()

来源:cmd/chat/ws.go:51-71

三个 source 任意一个就绪都会触发对应的分支。ctx.Done() 优先级最高——一旦连接断开,无论消息是否处理完都立即退出。

另一个更复杂的多路选择:

1
2
3
4
5
6
7
8
9
10
11
// cmd/chat/ws.go:149-157 — 非阻塞发送带丢弃保护
func pushEnvelope(ctx context.Context, out chan<- wsEnvelope, env wsEnvelope) {
select {
case <-ctx.Done():
return // 已取消,不发
case out <- env:
return // 成功发出
default:
logger.Warn("envelope dropped:", ...) // 缓冲满,丢弃
}
}

来源:cmd/chat/ws.go:149-157

这是一个经典的背压+丢弃模式:优先发送,发不了就丢掉(WebSocket 信封不是核心业务数据,丢了可以接受)。对比 scheduler.go 的 submit() 方法——那里队列满是返回错误而不是丢弃,因为战斗请求不能丢。


Q6:Nil Channel 和 Closed Channel 的行为总结

这两个特殊状态在实际项目中经常导致难以排查的 bug:

操作 Nil Channel (var ch chan T) 已关闭 Channel
<-ch 永久阻塞(goroutine 泄漏!) 返回零值 + ok=false
ch <- x 永久阻塞(goroutine 泄漏!) panic
close(ch) panic panic
len(ch) 0 返回剩余元素数
cap(ch) 0 返回原始容量

nil channel 永久阻塞 这个特性其实非常有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 动态启停某个 select case 的惯用模式
var taskCh chan Task = nil // 初始 nil,对应 case 永远阻塞

for {
select {
case task := <-taskCh: // taskCh=nil 时,这个分支永远不会选中
process(task)
case <-quit:
return
}
}

// 需要启用任务处理时
taskCh = make(chan Task, 10)

// 需要禁用时
taskCh = nil // 再次变成永远阻塞

这在 slg-go 的 scheduler 里虽然没有直接用到,但在需要动态管理 select 分支的场景下是非常常见的模式。


Q7:Channel 性能基准:什么时候该换 sync.Mutex 或 atomic?

Channel 不是银弹。不同场景下的性能差异很大:

场景 推荐 原因
goroutine 间传递所有权 channel ✅ 天然同步点,语义清晰
保护共享状态(低频访问) sync.RWMutex ✅ 更简单,无内存分配
保护共享状态(高频计数器) atomic ✅ 零开销
任务队列(1对N) buffered channel ✅ 内置背压
广播通知(1对N) close + select ✅ 最简广播原语
单次信号传递 chan struct{} ✅ 零宽度类型,几乎无内存开销

slg-go 里的选型对照

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 任务队列 → channel(内置背压 + goroutine 安全)
requests chan *BattleRequest // scheduler.go:128

// 2. 共享状态 → RWMutex(多读少写)
mu sync.RWMutex // scheduler.go:132
states map[int64]*battleState

// 3. 信号通知 → chan struct{}
stopCh chan struct{} // scheduler.go:141

// 4. 只执行一次 → sync.Once
stopOnce sync.Once // scheduler.go:142

来源:scheduler.go:128-142

注意 scheduler.go 里的组合用法:channel 做通信,mutex 做状态保护,once 做幂等。三者各司其职,没有混用。这是 Go 并发设计的标准范式。


总结

理解 Channel 底层之后,你会发现它本质上就是:

一把锁 + 一个环形数组 + 两个等待队列 + 一套挂起/唤醒机制

不需要把它想得太神秘。你写的每一行 ch <- value 和 v := <-ch,背后都在执行上面 Q2/Q3 描述的三种路径之一。

判断你的 channel 用法是否正确的三个问题:

  1. 谁负责 close?(只有发送者,且只 close 一次)
  2. 缓冲大小合理吗?(太小频繁阻塞,太大延迟高、内存大)
  3. 有没有 nil channel 导致的泄漏风险?

下篇聊 Go 内存模型的 happens-before 规则——有了 channel 的底子之后,理解内存序就顺理成章了。