乐于分享
好东西不私藏

AI 时代重新学 Go(五):Channel 底层实现与 select 的随机性

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. 1. 设置 closed = 1
  2. 2. 唤醒所有在 recvq 里等待的接收者(它们会收到零值)
  3. 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 的底层机制。