Theory · Golang · 面试复习
hchan 结构体 · 环形缓冲区 · sendq/recvq 与 sudog · 发送/接收/关闭全流程 · select 与内存模型
Why · 先看一个具体场景
场景:一个爬虫,goroutine A 产出待抓链接、goroutine B 负责抓取。先不用 channel,就用最朴素的「共享一个切片 + 一把锁」——看看会撞上什么。
// 手写版:共享切片 + mutex(mutex = 互斥锁,一次只让一个 goroutine 进) var mu sync.Mutex var queue []string // 生产者 A mu.Lock(); queue = append(queue, url); mu.Unlock() // 消费者 B:队列空了怎么办?只能自己轮询 for { mu.Lock() if len(queue) > 0 { u := queue[0]; queue = queue[1:] mu.Unlock(); fetch(u); continue } mu.Unlock() time.Sleep(time.Millisecond) // ← 罪魁祸首 }
done bool,再加一处加锁去读它。mu.Lock() 编译照过,线上偶发脏数据,只有 go run -race 才抓得到。// channel 版:同一件事,三行 ch := make(chan string, 100) // 100 = 背压阀门 go func() { for _, u := range urls { ch <- u }; close(ch) }() for u := range ch { fetch(u) } // close 后自动退出
但 channel 不是魔法:它换掉的正是上面那段手写代码——把「一把锁 + 一个队列 + 谁该睡谁该醒」搬进了 runtime。所以它也继承了那段代码的全部脾气:会 panic、会永久阻塞、会给你零值。
四个问题一次消失:「等」交给运行时——没数据时这个 goroutine 被摘下线程去睡觉(占 0 CPU),来数据再被叫醒;close 就是结束信号,range 自动收尾;数据交接自带内存可见性,不必再纠结加不加锁;缓冲容量就是背压阀门,满了自动让上游停下。
所以本 deck 干的事是:把这台机器拆开——先看它的零件(hchan / 环形缓冲 / 等待队列),再看它的动作(发送、接收、关闭、select)。拆完之后,前面那些"诡异行为"都能从源码分支一条条推出来,而不用背结论。
Agenda
CSP 模型、channel 的本质:带锁的环形缓冲区 + 等待队列 + 调度器联动。
hchan 字段逐个拆、内存布局图解、makechan 三种分配策略。
chansend / chanrecv 的每条分支,重点:直接拷贝(跨 goroutine 写栈)。
closechan 的唤醒协议、select 的随机轮询与有序加锁、happens-before。
行为矩阵、goroutine 泄漏、优雅关闭、选型对比、12 道高频面试题。
← → 翻页 · T 换主题 · S 演讲者模式。代码均出自 Go 1.22.5 源码,出处随页标注。
Prerequisites & Glossary
下面每一页都会用到它们。不用背——忘了就翻回这一页。
| CSP Communicating Sequential Processes | 一种并发模型:各方不共享变量,只通过「管道」互发消息。Go 的并发口号就源于它 |
| goroutine(协程) | Go 运行时自己调度的执行单元,初始栈仅 2KB,开几十万个也扛得住;不等于操作系统线程 |
| hchan | runtime 里代表「一个 channel」的结构体。你写的 ch 变量,本质是指向它的一个指针 |
| 环形缓冲区 ring buffer | 定长数组 + 读写下标取模回绕:写到尾部就绕回 0,已消费的槽位循环复用,元素永不搬移 |
| sudog | 一张「挂号单」:记录哪个 goroutine 在等这个 channel、它的数据该放到哪个地址 |
| gopark / goready | 运行时的「睡下 / 叫醒」:把当前 G 置为等待并让出线程 / 把某个 G 放回可运行队列 |
| rendezvous(汇合) | 无缓冲 channel 的收发必须双方同时到场才算完成——先到的一方要等对方 |
| happens-before 先行发生 | 内存模型术语:若 A happens-before B,则 A 之前的写一定被 B 看见(不会读到旧值) |
| 背压(backpressure) | 下游处理不过来时让上游慢下来。「缓冲满了就阻塞发送方」就是背压 |
| panic | 运行时崩溃:中断当前 goroutine 并向上传播,没人 recover 就整个进程退出 |
这些词若还有陌生的,先读这几篇
最小心智模型 · 记住这一个动作
每一次 channel 操作都是同一套动作:拿锁 → 看对面有没有人在等(有:手递手把数据交给他并叫醒他)→ 没人:看缓冲区有没有位置(有:放进去 / 取出来)→ 都不行:把自己挂到等待队列上睡着,让未来的对端替我完成 → 放锁。
后面的「发送五级判定」「接收五级判定」「close 全队列清算」「select 多路等待」,全是这一个动作的变体。读的时候只要问自己:现在走到哪一步了?
宏观定位 · Philosophy
回到开场那个爬虫:把「共享切片 + 锁 + 轮询」换成一条管道,背后的设计主张是什么。
<-chan / chan<-(编译期约束方向)、nil channel(收发永久阻塞)。一句话物理图像
一个 chan T 在堆上就是一块 hchan:环形数组 buf 负责缓存数据,sendq/recvq 两列「等待的 goroutine」负责同步,lock 保证以上一切操作的原子性;阻塞 = 把当前 G 挂到队列上并让出 M。
面试误区:「channel 无锁所以快」——错。hchan 内部就是 mutex(源码可见),热路径有少量免锁快速判断,但完整操作全程持锁。
Under the hood · 编译器视角
ch <- v ⇒ chansend1(c, &v) → chansend(c, elem, block=true, callerpc) v := <-ch ⇒ chanrecv1(c, &v) → chanrecv(c, elem, block=true) v, ok := <-ch ⇒ chanrecv2(c, &v) → 返回 (selected, received) close(ch) ⇒ closechan(c) ch := make(chan T, n) ⇒ makechan(t, n) for v := range ch { … } ⇒ 循环调用 chanrecv2,received == false 时退出 len(ch) / cap(ch) ⇒ 编译器内联读取 qcount / dataqsiz(无锁快照,读到即过时) select { … } ⇒ selectgo(随机轮询 + 按地址排序加锁)
block 参数:普通收发恒为 true;select + default 会以 block=false 调用同一套函数,这就是「非阻塞收发」的底层实现。
入口 go:nosplit:chansend1/chanrecv1 等入口禁止栈增长检查,保证进入 runtime 前不引入额外复杂度。
数据结构 · runtime/chan.go
对照术语页那个动作:「拿锁」拿的是 lock,「看对面有没有人等」看的是 recvq/sendq,「看缓冲有没有位置」看的是 qcount/dataqsiz——字段就是那句话的物化。
type hchan struct { qcount uint // 队列中当前元素个数 dataqsiz uint // 环形队列总容量(cap) buf unsafe.Pointer // 指向环形队列 elemsize uint16 closed uint32 // 关闭标志 0/1 elemtype *_type // 元素类型(拷贝/GC 依据) sendx uint // 发送下标(写位置) recvx uint // 接收下标(读位置) recvq waitq // 等待接收的 sudog 链表 sendq waitq // 等待发送的 sudog 链表 // lock 保护 hchan 所有字段,以及阻塞在 // 本 channel 上的 sudog 的部分字段。 // 持锁期间不得改其他 G 状态(不得 ready G), // 否则可能与栈收缩死锁。 lock mutex } type waitq struct { // 双向链表,结点为 *sudog first *sudog last *sudog }
qcount/dataqsiz 正是 len/cap 的取值来源;dataqsiz 创建后只读,可用于无锁判断。buf 是 unsafe.Pointer——与 hchan 可能同块内存(见 makechan 页),由 elemtype 决定拷贝大小与 GC 行为。recvq/sendq 是互斥的存在证据:接收者入队 ⇔ 缓冲为空;发送者入队 ⇔ 缓冲已满(或无缓冲)。由此推出直接拷贝路径的成立条件。lock 是运行时自旋/信号量混合锁(futex);持锁期间禁止 goready,防止与栈收缩(shrinkstack)死锁。waitq 是 FIFO:enqueue 入队尾、dequeue 出队头,先阻塞先服务;sudog 同时被信号量(sync.Mutex 底层)复用,是 runtime 通用等待结点。timer *timer 字段(time.Timer 的内部通道改为无缓冲实现);其余结构不变。图解 · Memory Layout
数据结构 · makechan
mem == 0
只分配 hchanSize 大小,不分配 buf;c.buf = c.raceaddr() 仅供 race detector 做同步标记。同步全靠等待队列,数据走直接拷贝。
元素不含指针
mallocgc(hchanSize+mem) 一次搞定,buf = add(c, hchanSize) 紧跟结构体。整块内存无指针 → GC 完全不扫描,少一次分配、缓存局部性更好。源码注释:"Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers."
元素含指针
new(hchan) + mallocgc(mem, elem, true) 单独分配 buf,并携带元素类型信息,让 GC 能扫描 buf 中的指针(如 chan string、chan *T)。
≥ 64KB 直接 throw(elem.Size_ ≥ 1<<16);容量溢出(mem > maxAlloc-hchanSize)或负数 → panic("makechan: size out of range")。elemsize/elemtype/dataqsiz,lockInit(&c.lock, lockRankHchan)。之后 dataqsiz 永不改变。核心机制 · Ring Buffer
收发流程 · chansend
收发流程 · Direct Copy
c.lock,对方 G 处于 _Gwaiting(栈不会移动/收缩);且挂起前 gp.parkingOnChan=true 封死栈收缩窗口,配合写屏障(typeBitsBulkBarrier)保证 GC 正确。recvDirect 从发送者栈直接搬到自己的 ep。收发流程 · chanrecv
收发流程 · 阻塞与唤醒
type sudog struct { g *g // 被挂起的 goroutine next, prev *sudog // waitq 链表前后指针 elem unsafe.Pointer // 数据地址:发送值 / 接收缓冲 acquiretime, releasetime int64 ticket uint32 isSelect bool // select 场景:CAS 竞争唤醒 success bool // 唤醒原因:通信成功 or 因 close c *hchan // 阻塞在哪个通道上 … // waitlink 等信号量复用字段 } // 挂起(G → _Gwaiting,解绑 M) gp.parkingOnChan.Store(true) gopark(chanparkcommit, &c.lock, waitReasonChanSend, …) // 唤醒(G → _Grunnable,进运行队列) gp.param = unsafe.Pointer(sg) sg.success = true goready(gp, skip+1)
acquireSudog/releaseSudog 从 sched.sudogcache 取还,避免热路径分配。gp.param = sg、sg.success;被唤醒的 G 检查 mysg != gp.waiting 判断队列一致性,用 success 区分「拿到数据」还是「因 close 醒来」。fatal error: all goroutines are asleep - deadlock!(proc.go)。关闭与多路复用 · closechan
close(nil) → panic;closed != 0 → panic("close of closed channel")(二次关闭)。通过后置 closed = 1。ep 清零(typedmemclr)、success=false → 醒来收到零值 + ok=false。elem=nil、success=false → 醒来即 panic("send on closed channel")。goready —— 源码特意注释持锁期间不得 ready G(防与栈收缩死锁)。接收方的完整规则
close 不会丢弃缓冲区数据:先继续按 received=true 排干 buf,空了才返回零值 received=false。因此 v, ok := <-ch 的 ok 判定 = “关闭 且 缓冲已空”;for range 在此时退出。
三个必 panic
① close(nil) ② close 已关闭通道 ③ 向已关闭通道发送(含 sendq 中被唤醒的发送者)。从已关闭通道接收永不 panic。
原则:由发送方关闭,接收方只读。多发送方场景用「额外 signal channel」或 sync.Once 保证恰好关一次。
工程与 QA · 行为矩阵
| 操作 | nil channel | 已关闭 channel | 正常 channel |
|---|---|---|---|
| 发送 ch <- v | 永久阻塞 | panic | 缓冲未满或有接收者 → 成功;否则阻塞 |
| 接收 <-ch | 永久阻塞 | 先排干缓冲(ok=true);空则零值(ok=false) | 有数据或有发送者 → 成功;否则阻塞 |
| close(ch) | panic | panic(二次关闭) | 成功,唤醒全部等待者 |
| len / cap | 0 / 0 | len=缓冲剩余量 / cap 不变 | qcount / dataqsiz(无锁快照) |
| for range | 永不结束 | 消费完缓冲后正常退出 | 消费完当前数据后等待新数据 |
| select 中 | 该 case 永远不会被选中 | receive case 始终就绪(零值) | 就绪性由数据与对端决定 |
nil 的妙用:select 中把已完成分支的 channel 置 nil,让该 case 永久阻塞(相当于禁用),常用于「先处理 a,处理完只监听 b」的收敛逻辑。
记忆抓手:nil 通道「进不去也出不来」;已关闭通道「只出不进」。对已关闭通道:写=panic,读=善后(排干后零值)。
关闭与多路复用 · select
pollorder —— 对所有 case 做 Fisher-Yates 式随机洗牌,多 case 就绪时等概率选择,保证公平防饥饿;lockorder —— 按通道地址排序(堆排序)后依次加锁,全局一致的加锁顺序杜绝多通道场景下的 AB-BA 死锁。isSelect=true 的 sudog → gopark。sudog 以 CAS selectDone 竞争胜出,赢者完成通信,其余 sudog 作废出队 —— 保证多路等待只有恰好一个 case 执行。block=false 走 chansend/chanrecv 快速路径,不满足立即返回;select{}(空块)= 永久阻塞。// select.go · 两个顺序的生成 for i := range pollorder { // cheaprandn: 随机洗牌 j := cheaprandn(uint32(n+i)+uint32(n)) pollorder[norder] = pollorder[j] pollorder[j] = uint16(i) } for i := range lockorder { // 按通道地址堆排序 c := scases[pollorder[i]].c … c.sortkey() … // = 通道地址 } sellock(scases, lockorder) // 全加锁
为什么随机:按源码顺序轮询会让排前面的 case 永远优先(饥饿);随机化让每个就绪 case 被选中概率均等 —— 「select 是公平的」。
关闭与多路复用 · 内存模型
A send on a channel happens before the corresponding receive from that channel completes. —— 接收方读到的一定是发送方写完的值,无需任何额外同步。
A receive from an unbuffered channel happens before the send on that channel completes. —— 双方会合(rendezvous),发送返回时接收已拿到数据,天然是同步点。
The closing of a channel happens before a receive that returns a zero value because the channel is closed. —— 收到 ok=false 即可断言 close 前的全部写操作可见。
The kth receive on a channel with capacity C happens before the (k+C)th send completes. —— 第 k+C 次发送拿到槽位,意味着第 k 次接收已取走数据。
推论:用 channel 交接数据(哪怕传指针)本身就是同步屏障——这正是「用通信共享内存」能替代手工锁的底层依据。规则全文见 go.dev/ref/mem;Go 1.23 对 time.Timer 的改造(无缓冲 timer 通道)不改变以上四条。
工程与 QA · Pitfalls
发送后无人收 / 接收后无人发 / 忘记 close 导致 range 不退出。防御:ctx.Done() 参与 select、超时兜底、select+default 降级、用带缓冲 channel 削峰。
只由发送方 close;多发送方用 signal channel(由主控方关闭)或 sync.Once。与 send 并发的 close 会 panic,与 recv 并发则安全。
channel 传值拷贝:大结构体有拷贝开销;slice/map/指针共享底层数据——发指针须自行保证无竞态,别以为「过了 channel 就安全了」之后又并发改它。
满了照样阻塞,这是特性(背压)不是 bug。无界消费需求应显式建模(slice + 条件导出,或带 dropping 策略),别指望加大 cap 解决。
同一份状态又上锁又走 channel,复杂度爆炸。一个数据一种保护手段;channel 管「交接与编排」,mutex 管「状态保护」。
函数签名用 <-chan T(只读)/ chan<- T(只写)表达契约,编译期即可抓住「接收方 close」这类反模式;运行时仍是同一个 hchan。
工程与 QA · 选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| goroutine 间传递数据流 / 所有权转移 | channel | 值交接自带 happens-before,语义清晰 |
| 保护共享状态(map / 计数 / 缓存) | mutex / RWMutex | 临界区短;套 channel 反而绕、更慢 |
| 单变量原子增减 / 标志位 | atomic | 开销最小,无调度参与 |
| 编排:超时、取消、多路等待 | select + ctx.Done() | channel+select 是为控制流而生 |
| 生产者-消费者解耦削峰 | buffered channel | 天然背压;注意容量与内存占用 |
| 一对一实时握手(请求-应答) | unbuffered channel | rendezvous 语义,直接拷贝零缓冲 |
性能清醒剂:channel 底层有 mutex + 可能的挂起/唤醒(gopark/goready),单次操作成本远高于 atomic 或无竞争 mutex。选 channel 的理由是正确性与可组合性,不是性能;Rob Pike 的口号解决的是工程问题,不是吞吐问题。
Cheat Sheet
回看只需这一页;「nil / 已关闭 / 正常」的逐格行为见前面行为矩阵页。
① 收发五级判定 —— 顺序即优先级,白板默写就写这张
| 级 | 发送 ch <- v | 接收 <-ch |
|---|---|---|
| 1 | c == nil → 永久阻塞 | c == nil → 永久阻塞 |
| 2 | 已 closed → panic | 已 closed 且缓冲空 → 零值, ok=false |
| 3 | recvq 有人等 → 直接拷贝+goready | sendq 有人等 → 直接取数 + goready |
| 4 | 缓冲未满 → 写 buf[sendx],qcount++ | 缓冲有数据 → 读 buf[recvx] 并清槽 |
| 5 | 否则 sudog 入 sendq → gopark | 否则 sudog 入 recvq → gopark |
② panic 清单 —— 只有三种,其余都不 panic
必 panic:① 向已关闭通道发送(含 sendq 中被 close 唤醒的发送者)② close(nil) ③ 二次 close
永不 panic:从已关闭通道接收(先排干缓冲,空了给零值 + ok=false);nil 通道收发(只是永久阻塞)
③ close 清算四步 · select 两个顺序
pollorder 随机洗牌保公平(多 case 就绪等概率)+ lockorder 按通道地址排序防死锁;带 default = 以 block=false 走同一套函数④ 五条不变式 —— 所有行为都能由它们推出
sudog.elem),由未来的接收者来取typedmemclr):buf 里不留悬垂指针,GC 才不会误留对象存活mutex。"channel 无锁所以快"是误区;选它是为正确性与可组合性,不是吞吐⑤ happens-before 四条 —— 一句话版
send 先于对应 receive 完成|无缓冲的 receive 先于 send 完成|close 先于「因关闭而收到零值」|容量 C 时第 k 次 receive 先于第 k+C 次 send 完成。推论:过一次 channel 就等于过一道内存屏障,不必再加锁。
⑥ 工程三条底线
谁发送谁 close(多发送方用 signal channel 或 sync.Once)|用 range 收就必须有人 close,否则 goroutine 泄漏|长期等待一律带 ctx.Done() 或超时兜底。
工程与 QA · 高频面试题
先自答再对照:每题先在心里说一遍,卡住的那题回去翻对应页,比直接读答案有用得多。
hchan:环形缓冲区 buf + qcount/dataqsiz/sendx/recvx,recvq/sendq 两条 sudog 双向链表,elemsize/elemtype、closed 标志,以及 lock mutex。Go 1.23 起另有 timer 字段。
dataqsiz=0 时不分配 buf,收发必须汇合(rendezvous):sendDirect/recvDirect 在两个 goroutine 栈之间直接 memmove;有缓冲则异步,且 sendq 非空必然意味着缓冲已满。
发送:panic("send on closed channel")(阻塞中的发送者被 close 唤醒后同样 panic)。接收:不 panic,先以 received=true 排干缓冲,空了返回零值 received=false。
收发 gopark 永久阻塞(可触发全局死锁检测),close(nil) panic。利用 nil 永不就绪的特性,可在 select 中把 channel 置 nil 来禁用某个 case。
安全。hchan 有 mutex 保护所有字段(含阻塞 G 的 sudog 部分),且持锁期间不得 goready(防栈收缩死锁)。“channel 无锁”是误区——热路径只是有免锁的快速判断。
不在缓冲区。发送者把值的栈地址记进 sudog.elem 后挂入 sendq;接收者到来时直接从发送者栈 memmove 取走,再 goready 唤醒——所以 close 唤醒它时 elem 已作废。
工程与 QA · 高频面试题
同样先自答再对照:这一组考的是行为边界与工程习惯,答案都能回溯到速查页的五条不变式。
所有 goroutine 都 _Gwaiting 且无定时器可期时,runtime 抛 fatal error: all goroutines are asleep - deadlock!(如单 goroutine 向 nil/unbuffered channel 发送)。注意:只要还有别的 G 可运行就不报——可能表现为泄漏而非崩溃。
随机。selectgo 先对 case 随机洗牌(pollorder)再轮询,等概率选择防饥饿;加锁则按通道地址排序(lockorder)保证全局一致顺序,避免多通道死锁;唤醒时 CAS selectDone 决定唯一赢家。
原则:由发送方 close,接收方只读。多发送方:主控方关闭独立的 signal/done channel 通知全体,或 sync.Once + 原子计数保证恰好关一次;接收方关闭属于反模式。
等价于循环 chanrecv2:当 received=false(已 close 且缓冲排干)时退出。忘记 close 则 range 永远阻塞 → goroutine 泄漏。
值拷贝(typedmemmove)。大元素有拷贝成本;slice/map/指针等共享底层数据的类型,跨通道后底层仍共享——并发访问需自行加同步。
①send 先于对应 receive 完成;②unbuffered 的 receive 先于 send 完成;③close 先于因 close 收到零值;④容量 C 时第 k 次 receive 先于第 k+C 次 send 完成。因此跨通道传数据天然有序、无需额外同步。
Wrap up
相关知识点(同仓库 · 待沉淀后互链)
gopark/goready 的调度语义、_Gwaiting 状态机 —— theory/golang/gmp-scheduler.html
happens-before 全景:channel / mutex / atomic
pprof 定位 gopark 阻塞点与泄漏模式
ctx.Done() 本质也是一个 channel
环形队列判空/判满与背压 —— channel 底层结构的通用版
channel 的 OS 版:管道 / 消息队列 / 共享内存——"通信即同步"的内核实现
参考资料
runtime/chan.go(hchan/chansend/chanrecv/closechan/makechan)、runtime/select.go(selectgo)、runtime/runtime2.go(sudog)一页总结:channel = mutex 保护的环形缓冲区 + 收发等待队列;发送/接收 = 五级判定(nil→closed→对端→缓冲→挂起);close = 全队列清算;select = 随机轮询 + 有序加锁;数据交接自带 happens-before。