「Go语言面试题」26 - Channel 底层实现全解析:hchan 环形队列、sudog 等待队列与阻塞唤醒机制


引言

「channel 底层是什么数据结构?」「向已满的 channel 发送数据会发生什么?」「channel 为什么不需要加锁就是并发安全的?」—— 这是 Golang 面试中区分"用过 channel"和"懂 channel"的分水岭问题。

我们在第 7 期讲过 channel 的类型与使用场景,第 16 期讲过如何安全关闭 channel,本期直达 src/runtime/chan.go,把 hchan、sudog、环形缓冲区和阻塞唤醒机制一次讲透。

一、核心数据结构:hchan

channel 在运行时的表示是 hchan 结构体(多份源码解析一致):

type hchan struct {
    qcount   uint           // 缓冲区中当前元素个数
    dataqsiz uint           // 缓冲区容量(循环队列长度)
    buf      unsafe.Pointer // 指向底层数组的指针
    elemsize uint16         // 元素大小
    closed   uint32         // 是否已关闭的标志位
    elemtype *_type         // 元素类型信息
    sendx    uint           // 下一次发送写入的位置
    recvx    uint           // 下一次接收读取的位置
    recvq    waitq          // 接收等待队列(阻塞的接收者)
    sendq    waitq          // 发送等待队列(阻塞的发送者)
    lock     mutex          // 保护以上所有字段的互斥锁
}

type waitq struct {
    first *sudog
    last  *sudog
}

qcount、dataqsiz、buf、sendx、recvx 五个字段共同构成环形缓冲区:发送时写入 buf[sendx] 然后 sendx++,到达末尾就回绕到 0;接收时从 buf[recvx] 读出然后 recvx++。qcount 单独记录元素个数,既解决了环形队列"队空队满"的二义性,又让 len(ch) 可以 O(1) 返回。

二、为什么 channel 并发安全

答案就在结构体最后一行:整个 hchan 由一把 lock 互斥锁保护。发送、接收、关闭操作都要先拿到这把锁,所以对 channel 的一切并发操作都是串行化的。换句话说,channel 本质上是一个自带锁的有界队列。第 10 期 《互斥锁(Mutex)和读写锁(RWMutex)的区别》 讲过锁的代价,channel 的 lock 也一样——这也是高频小数据传递时,有人用 sync 包手工同步性能更好的原因。

三、发送流程:chansend 的四个分支

ch <- v 编译后进入 runtime.chansend,简化逻辑如下:

  1. recvq 非空 → 直接从等待队列中取出一个接收者,把值绕过缓冲区拷贝给它(直传优化),然后 goready 唤醒这个接收者;
  2. 缓冲区有空位(qcount < dataqsiz)→ 写入 buf[sendx],sendx 回绕,qcount++;
  3. 非阻塞模式(select 的 default 分支)→ 直接返回 false;
  4. 以上都不满足 → 当前 goroutine 被包装成 sudog,挂进 sendq,调用 gopark 让出 CPU,陷入阻塞。

四、阻塞与唤醒:sudog 登场

sudog 是对阻塞中 goroutine 的封装,关键字段包括 g(指向 goroutine)、elem(数据指针)、next/prev(构成双向链表)、c(关联的 channel)。发送方阻塞时:

获取当前 G →  acquireSudog() → 挂入 sendq → gopark(挂起)

将来某个接收者执行了接收操作,发现 sendq 里有等待者,就把数据直接拷贝到对方的 elem 指向的内存,再调用 goready 把对方唤醒。等待队列是 FIFO 的,先阻塞的先被服务。

接收(chanrecv)流程与发送完全对称:先看 sendq(有等待的发送者就直接拿它的数据)、再看缓冲区、再非阻塞判断、最后挂入 recvq 阻塞。

五、可运行示例

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 2)
    ch <- 1
    ch <- 2
    fmt.Println("len:", len(ch), "cap:", cap(ch)) // len: 2 cap: 2

    // 第三个发送会阻塞,放到 goroutine 里做
    go func() {
        time.Sleep(100 * time.Millisecond)
        ch <- 3 // 主协程取走一个后,这里写入 buf[0](sendx 回绕)
    }()

    fmt.Println(<-ch) // 1(buf[0],recvx 移到 1)
    fmt.Println(<-ch) // 2
    fmt.Println(<-ch) // 3 —— 演示了环形队列的回绕复用
}

再验证无缓冲 channel 的"直传"特性——发送方和接收方必须同时就位:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int) // 无缓冲

    go func() {
        time.Sleep(50 * time.Millisecond)
        fmt.Println("发送方:等待接收者就位...")
        ch <- 42 // 没有接收者时,这里会阻塞并挂入 sendq
    }()

    time.Sleep(100 * time.Millisecond)
    v := <-ch // 取出后唤醒发送方
    fmt.Println("接收方:", v)
}

如果结合 select 做超时控制,建议回看第 22 期 《深入剖析Go select:多路复用背后的设计与实现原理》;关闭 channel 的安全姿势见第 16 期 《如何安全地关闭一个正在被多个goroutine读写的channel》。

速查清单

问题 一句话答案
channel 底层结构? runtime.hchan:环形缓冲区 + 两个等待队列 + 一把锁
环形队列怎么实现的? buf 数组 + sendx/recvx 下标回绕,qcount 记录元素数
谁保护并发安全? hchan 内的 lock 互斥锁,所有收发关闭操作串行化
发送何时阻塞? 无缓冲无接收者,或缓冲已满(qcount == dataqsiz)
阻塞的 goroutine 去哪了? 被封装成 sudog,挂入 sendq/recvq,gopark 挂起
怎么被唤醒? 对端操作到来时直接拷贝数据 + goready,FIFO 顺序
直传优化是什么? 有等待者时绕过缓冲区,直接把数据拷贝给对方的栈

结语

channel 的考点可以浓缩成一句话:“一把锁 + 一个环形队列 + 两条 sudog 等待队列”。面试时画出 hchan 结构、讲清发送四分支和 gopark/goready 唤醒流程,这道题就稳了。

如果觉得文章对你有帮助,请点赞、收藏、关注三连支持!


本文是「Go语言面试题」系列第 26 篇,全系列持续更新中,已发布 26 篇。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo