AI 时代重新学 Go(五):Channel 底层实现与 select 的随机性
“不要通过共享内存来通信,而是通过通信来共享内存。”
这句话写 Go 的人都背得出来。Channel 就是这句话的具体实现——goroutine 之间通过 channel 传递数据,而不是用共享变量 + 锁。
但 channel 本身是怎么工作的?我以前觉得”就是个线程安全的队列吧”,大方向没错但细节差得远。
type hchan struct { qcount uint // 缓冲区中的元素数量 dataqsiz uint // 缓冲区的容量(make 时指定的) buf unsafe.Pointer // 环形缓冲区的指针 elemsize uint16 // 元素大小 closed uint32 // 是否已关闭 sendx uint // 发送索引(下一个写入位置) recvx uint // 接收索引(下一个读取位置) recvq waitq // 等待接收的 goroutine 队列 sendq waitq // 等待发送的 goroutine 队列 lock mutex // 互斥锁}
画出来长这样:
ch := make(chan int, 4) ch <- 1 ch <- 2 ch <- 3 hchan: ┌──────────────────────────────────────┐ │ qcount = 3 dataqsiz = 4 │ │ sendx = 3 recvx = 0 │ │ closed = 0 │ │ │ │ buf (环形缓冲区): │ │ ┌─────┬─────┬─────┬─────┐ │ │ │ 1 │ 2 │ 3 │ │ │ │ └─────┴─────┴─────┴─────┘ │ │ ↑ ↑ │ │ recvx=0 sendx=3 │ │ │ │ recvq: (空) │ │ sendq: (空) │ └──────────────────────────────────────┘
有缓冲的 channel 用一个环形缓冲区存数据,sendx 和 recvx 分别是写和读的位置,绕一圈后回到 0。
无缓冲的 channel(make(chan int))的 dataqsiz 是 0,没有缓冲区,数据直接从发送者拷贝到接收者。
关键:channel 有锁
是的,channel 内部有一把互斥锁。每次发送和接收都要加锁。所以 channel 不是什么 lock-free 的黑科技,它就是用锁保护的队列。
但这不意味着它慢。Go 的 channel 锁是运行时自己实现的轻量级锁(不是 sync.Mutex),在低竞争场景下开销很小。
发送和接收的流程
发送 ch <- data
ch <- data │ ├─→ recvq 里有等待的接收者? │ │ │ ├─ 有 → 直接把数据拷给接收者,唤醒它(不经过缓冲区) │ │ │ └─ 没有 → 缓冲区有空位? │ │ │ ├─ 有 → 数据写入缓冲区 │ │ │ └─ 没有 → 当前 goroutine 挂到 sendq 队列 │ 进入休眠,等待被唤醒
接收 data := <-ch
data := <-ch │ ├─→ sendq 里有等待的发送者? │ │ │ ├─ 有 → 情况一:无缓冲 channel │ │ 直接从发送者拷贝数据,唤醒发送者 │ │ │ │ 情况二:有缓冲 channel(缓冲区满了才会有等待的发送者) │ │ 从缓冲区头部取数据,把发送者的数据放到缓冲区尾部 │ │ 唤醒发送者 │ │ │ └─ 没有 → 缓冲区有数据? │ │ │ ├─ 有 → 从缓冲区取 │ │ │ └─ 没有 → 当前 goroutine 挂到 recvq 队列 │ 进入休眠
有一个细节:当 recvq 有等待者时,发送者会直接把数据拷贝给接收者,跳过缓冲区。这是一个性能优化——少了一次内存拷贝。
无缓冲 channel 的同步语义
ch := make(chan int) // 无缓冲// goroutine Ach <- 42 // 阻塞,直到有人接收// goroutine Bv := <-ch // 接收到 42,A 被唤醒
无缓冲 channel 的发送和接收必须配对,双方都到场了才能完成。这让它天然具有同步(synchronization)的语义——发送方知道接收方已经拿到了数据。
关闭 channel
close(ch)
关闭 channel 时:
-
1. 设置 closed = 1 -
2. 唤醒所有在 recvq里等待的接收者(它们会收到零值) -
3. 唤醒所有在 sendq里等待的发送者(它们会 panic)
ch := make(chan int, 2)ch <- 1ch <- 2close(ch)v1 := <-ch // 1(缓冲区里的数据照样能读)v2 := <-ch // 2v3 := <-ch // 0(缓冲区空了,channel 已关闭,返回零值)v, ok := <-ch // v=0, ok=false(ok=false 表示 channel 已关闭且无数据)
面试常问的几个规则:
操作 | nil channel | 已关闭 channel | 正常 channel ─────────────┼─────────────┼────────────────┼────────────── 发送 ch<-v | 永远阻塞 | panic | 正常/阻塞 接收 <-ch | 永远阻塞 | 返回零值 | 正常/阻塞 关闭 close | panic | panic | 正常
向已关闭的 channel 发送数据会 panic——这是最常见的 channel 相关 panic。所以关闭操作应该由发送方负责,接收方不要关闭。
select
select 让你同时等待多个 channel 操作,哪个先就绪就执行哪个。
select {case v := <-ch1: fmt.Println("从 ch1 收到", v)case ch2 <- 42: fmt.Println("发送到 ch2")case <-time.After(5 * time.Second): fmt.Println("超时")default: fmt.Println("都没就绪")}
多个 case 同时就绪时是随机的
ch1 := make(chan int, 1)ch2 := make(chan int, 1)ch1 <- 1ch2 <- 2// ch1 和 ch2 都有数据,会随机选一个select {case v := <-ch1: fmt.Println("ch1:", v)case v := <-ch2: fmt.Println("ch2:", v)}// 多次运行,有时候是 ch1 有时候是 ch2
这是故意的。Go 在编译 select 时会对 case 的顺序做随机打乱(用的是 fastrandn),防止你依赖顺序导致某些 channel 总是优先或者饿死。
select 的底层实现(简化版)
1. 把所有 case 打乱顺序(随机化) 2. 按打乱后的顺序逐个检查每个 case 是否就绪 - 如果有就绪的,执行它(如果多个就绪,第一个被检查到的赢) - 如果有 default,执行 default 3. 都没就绪且没有 default: - 把当前 goroutine 挂到所有 channel 的等待队列上 - 休眠 - 被某个 channel 唤醒后,从其他 channel 的等待队列中删除自己 - 执行对应的 case
select 的常见模式
超时控制:
select {case result := <-doWork(): fmt.Println(result)case <-time.After(3 * time.Second): fmt.Println("超时了")}
非阻塞尝试:
select {case v := <-ch: fmt.Println(v)default: fmt.Println("channel 没数据,不等了")}
退出信号:
done := make(chan struct{})gofunc() { for { select { case <-done: fmt.Println("收到退出信号") return case task := <-taskCh: process(task) } }}()// 需要退出时close(done) // 所有等待 done 的 goroutine 都会被唤醒
用 chan struct{} 做退出信号是 Go 的惯用法。struct{} 占零字节,close 一个 channel 能同时通知所有等待者。
限制并发数:
sem := make(chan struct{}, 10) // 最多 10 个并发for _, url := range urls { sem <- struct{}{} // 获取令牌(第 11 个会阻塞) gofunc(url string) { deferfunc() { <-sem }() // 释放令牌 fetch(url) }(url)}
用有缓冲 channel 当信号量,简单有效。
channel 的性能考量
channel 好用但不是零开销。每次操作都要加锁,有缓冲区的还涉及内存拷贝。
在高性能场景下,如果只是简单的计数器或者标志位,sync/atomic 比 channel 快得多:
// 用 channel 做计数器(慢)ch := make(chan int, 1)ch <- 0gofunc() { v := <-ch ch <- v + 1}()// 用 atomic 做计数器(快)var count int64gofunc() { atomic.AddInt64(&count, 1)}()
经验法则:需要传递数据或者协调多个 goroutine 时用 channel,只是共享一个简单值时用 atomic 或 mutex。
下一篇是 Go 系列最后一篇——Context 的设计和 defer/panic/recover 的底层机制。
夜雨聆风