Theory · Golang · 面试复习

Golang Channel
底层原理

hchan 结构体 · 环形缓冲区 · sendq/recvq 与 sudog · 发送/接收/关闭全流程 · select 与内存模型

复习提示:本 deck 按「宏观定位 → 数据结构 → 收发流程 → close/select → 内存模型 → 行为矩阵与 QA」组织,背诵主线是 chansend / chanrecv 的分支顺序。

Why · 先看一个具体场景

两个 goroutine 之间传 100 万条链接

场景:一个爬虫,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) // ← 罪魁祸首
}
  • ① 忙等只能二选一:sleep 1ms → 每条链接平均多等 0.5ms;去掉 sleep → 一个 CPU 核被空转吃满。两条路都不对——「等」这件事本该交给调度器,不该写在业务代码里。
  • ② 没有"结束"信号:A 产完了,B 不知道该退出,只能再加一个 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)。拆完之后,前面那些"诡异行为"都能从源码分支一条条推出来,而不用背结论。

动机页:先用「共享切片 + mutex + 轮询」的手写版本暴露四个问题(忙等、无结束信号、忘加锁、无背压),再给三行 channel 版本逐条消掉。落点是「channel 换掉了什么」——它就是那段代码的运行时封装,因此所有异常行为都可从底层结构推出,顺势衔接后面的 hchan 拆解。

Agenda

五条主线吃透一个 channel

01 · 宏观定位

CSP 模型、channel 的本质:带锁的环形缓冲区 + 等待队列 + 调度器联动。

02 · 数据结构

hchan 字段逐个拆、内存布局图解、makechan 三种分配策略。

03 · 收发全流程

chansend / chanrecv 的每条分支,重点:直接拷贝(跨 goroutine 写栈)。

04 · 关闭与多路复用

closechan 的唤醒协议、select 的随机轮询与有序加锁、happens-before。

05 · 工程与 QA

行为矩阵、goroutine 泄漏、优雅关闭、选型对比、12 道高频面试题。

阅读约定

← → 翻页 · T 换主题 · S 演讲者模式。代码均出自 Go 1.22.5 源码,出处随页标注。

复习提示:先建立「channel 不是魔法管道,而是一个 mutex 保护的环形队列 + 挂起队列」的物理直觉,后面所有行为(panic、阻塞、零值)都能从源码分支推出来。

Prerequisites & Glossary

先把这十个词认全

下面每一页都会用到它们。不用背——忘了就翻回这一页。

CSP
Communicating Sequential Processes
一种并发模型:各方不共享变量,只通过「管道」互发消息。Go 的并发口号就源于它
goroutine(协程)Go 运行时自己调度的执行单元,初始栈仅 2KB,开几十万个也扛得住;不等于操作系统线程
hchanruntime 里代表「一个 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 多路等待」,全是这一个动作的变体。读的时候只要问自己:现在走到哪一步了?

前置页:十个术语先定义再使用,四条前置链接指向同仓库 deck。右下「最小心智模型」把后面全部流程压成一个动作(拿锁→看对端→看缓冲→挂起→放锁),读者带着这一句往下看,五级判定与 close/select 都成了同一件事的变体。

宏观定位 · Philosophy

用通信共享内存:channel 是什么

回到开场那个爬虫:把「共享切片 + 锁 + 轮询」换成一条管道,背后的设计主张是什么。

  • CSP 模型(Communicating Sequential Processes,Hoare 1978):Go 的口号是 "Don't communicate by sharing memory; share memory by communicating"——不靠共享内存通信,而靠通信来共享内存。
  • channel 是 goroutine 之间的类型安全管道:既传数据,也是同步/信号原语。数据以值拷贝交接,天然形成「所有权转移」语义。
  • 四种形态:无缓冲(同步汇合 rendezvous)、有缓冲(异步解耦)、单向 <-chan / chan<-(编译期约束方向)、nil channel(收发永久阻塞)。
  • 本质:一把 mutex 保护的环形缓冲区 + 收/发两个等待队列 + 与 GMP 调度器的挂起/唤醒联动。它不是无锁结构——锁被封装进了 runtime。

一句话物理图像

一个 chan T 在堆上就是一块 hchan:环形数组 buf 负责缓存数据,sendq/recvq 两列「等待的 goroutine」负责同步,lock 保证以上一切操作的原子性;阻塞 = 把当前 G 挂到队列上并让出 M。

面试误区:「channel 无锁所以快」——错。hchan 内部就是 mutex(源码可见),热路径有少量免锁快速判断,但完整操作全程持锁。

复习提示:能一句话说出「channel = mutex + ring buffer + waitq + gopark/goready」就已经超过多数候选人;再补充值拷贝与所有权语义。

Under the hood · 编译器视角

语法糖最终都变成 runtime 调用

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 前不引入额外复杂度。

复习提示:面试时先说「所有 channel 语法都是编译器对 runtime 函数的调用」,再展开 chansend/chanrecv 分支,条理感立刻出来。

数据结构 · runtime/chan.go

hchan:一个 channel 的全部家当

对照术语页那个动作:「拿锁」拿的是 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 创建后只读,可用于无锁判断。
  • bufunsafe.Pointer——与 hchan 可能同块内存(见 makechan 页),由 elemtype 决定拷贝大小与 GC 行为。
  • recvq/sendq互斥的存在证据:接收者入队 ⇔ 缓冲为空;发送者入队 ⇔ 缓冲已满(或无缓冲)。由此推出直接拷贝路径的成立条件。
  • lock 是运行时自旋/信号量混合锁(futex);持锁期间禁止 goready,防止与栈收缩(shrinkstack)死锁。
  • waitq 是 FIFO:enqueue 入队尾、dequeue 出队头,先阻塞先服务;sudog 同时被信号量(sync.Mutex 底层)复用,是 runtime 通用等待结点。
  • 版本注:Go 1.23 起 hchan 新增 timer *timer 字段(time.Timer 的内部通道改为无缓冲实现);其余结构不变。
复习提示:把 12 个字段背成 4 组——队列状态(qcount/dataqsiz/buf/sendx/recvx)、类型(elemsize/elemtype)、状态(closed)、队列与锁(recvq/sendq/lock)。

图解 · Memory Layout

hchan + 环形缓冲区 + 两条等待队列

hchan 内存布局图 左侧为 hchan 结构体字段卡,右侧上方为含 8 个槽位的环形缓冲区并标注 recvx 与 sendx 指针,下方为 recvq 与 sendq 两条 sudog 等待队列及说明。 hchan runtime/chan.go · Go 1.22.5 队列状态 qcount 元素个数(len) dataqsiz 总容量(cap) buf → 环形缓冲区 sendx 下一个写入下标 recvx 下一个读取下标 类型与状态 elemsize/elemtype 拷贝与 GC 依据 closed 0 / 1 关闭标志 等待队列 recvq 等待接收 sudog 链 sendq 等待发送 sudog 链 lock mutex 保护全部字段与阻塞 G 的 sudog buf · 环形缓冲区 dataqsiz=8 · qcount=5 e0 e1 e2 e3 e4 recvx=0 读 sendx=5 写 推进:(idx+1) % dataqsiz 回绕 recvq · 等待接收 (此时缓冲必为空) sudog{G5} elem→ep sudog{G8} elem→ep sendq · 等待发送 (缓冲满 / 无缓冲) sudog{G7} elem→v FIFO 链表:先来先服务, 被唤醒即被“替它完成” ◆ 阻塞 = sudog 挂入队列 + gopark 让出 M;唤醒 = 对端 goroutine 替它拷完数据后 goready ◆ makechan:元素不含指针时,buf 紧跟 hchan 一次性连续分配 —— GC 无需扫描 ◆ 单向类型 <-chan / chan<- 只是编译期约束,运行时仍是同一个 *hchan
复习提示:记住互斥不变式——「有接收者在等 ⇒ 缓冲必空」「有发送者在等 ⇒ 缓冲必满」,这是理解直接拷贝与同槽互换两处巧思的钥匙。

数据结构 · makechan

makechan:三种内存分配策略

mem == 0

无缓冲(或元素大小为 0)

只分配 hchanSize 大小,不分配 buf;c.buf = c.raceaddr() 仅供 race detector 做同步标记。同步全靠等待队列,数据走直接拷贝。

元素不含指针

hchan + buf 一次连续分配

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."

元素含指针

hchan 与 buf 分开分配

new(hchan) + mallocgc(mem, elem, true) 单独分配 buf,并携带元素类型信息,让 GC 能扫描 buf 中的指针(如 chan stringchan *T)。

复习提示:三种策略用「元素里有没有指针」这一条线串起来记;追问会到 GC,答出「避免扫描」即满分。

核心机制 · Ring Buffer

sendx / recvx:环形队列如何推转

环形缓冲区推转状态图 8 个槽位的环形队列:槽 0 与 1 已消费待复用,槽 2 至 6 为有效数据,槽 7 空闲;sendx 指向槽 7 的写入位置,recvx 指向槽 2 的读取位置,并解释读写推进与回绕。 qcount = 5 · dataqsiz = 8 已消费可复用 已消费可复用 a b c d e 空闲 idx 0idx 7 sendx = 7 下一个写入位置 recvx = 2 下一个读取位置 写入:buf[sendx]=v → sendx=(sendx+1)%8;读后槽位清零(typedmemclr) 读取:ep=buf[recvx] → recvx=(recvx+1)%8;qcount ±1 到尾部即回绕到 0(模运算),已消费槽位循环复用 —— 数组固定,无需搬移 为什么用环形数组而非链表:一次性预分配、O(1) 下标定位、缓存友好、不给 GC 制造新对象
复习提示:口算一遍推转过程(写满 8 个回绕、读空回绕),并强调「读后清槽」对 GC 的意义——buf 里不留悬垂指针。

收发流程 · chansend

chansend:五级判定,顺序即优先级

chansend 发送流程判定图 发送流程从上到下五级判定:nil 通道永久阻塞、已关闭通道 panic、有等待接收者则直接拷贝并唤醒、缓冲未满则写入环形队列,否则 sudog 入 sendq 挂起。 ch <- v ⇒ chansend(c, ep, block) block=false(select+default): 不满足即 return false,绝不挂起 c == nil 向 nil 通道发送 gopark(nil, …, waitReasonChanSendNilChan, traceBlockForever) 永久阻塞、永不恢复 —— close 也救不了;无其他 G 可调度时报 deadlock c.closed != 0 通道已关闭 unlock → panic("send on closed channel") 发送对已关闭通道零容忍;close 会先唤醒 sendq 中阻塞的发送者,醒来后同样 panic c.recvq.first != nil 有接收者在等(缓冲必空) send(c, sg, ep):sendDirect → goready(recvG) 跳过缓冲:把值从发送者栈直接 memmove 到接收者栈,随即唤醒接收者 —— return true c.qcount < c.dataqsiz 缓冲区未满 typedmemmove(buf[sendx], ep);sendx=(sendx+1)%cap;qcount++ 写入环形缓冲区即返回(异步)—— return true 否则:满且无接收者 (或无缓冲且无接收者) acquireSudog → sendq.enqueue(mysg) → gopark 挂起让出 M;未来的接收者替它完成拷贝(close 唤醒则醒来 panic)
复习提示:五级顺序=「nil → closed → 对端等待 → 缓冲 → 挂起」。面试白板手写这棵判定树是高频题,注意第 3 级即使有缓冲也走直接拷贝。

收发流程 · Direct Copy

“手递手”:跨 goroutine 写栈

直接拷贝路径图 无缓冲同步路径:发送方 G1 栈上的值经一次 memmove 直接写入接收方 G2 栈的接收变量;对照有缓冲路径需要 G1 写入 buf、G2 再从 buf 读出两次拷贝。 G1 · 发送方 _Grunning 运行中 v = 42(栈上) G2 · 接收方 _Gwaiting → 被 goready ep(接收变量,栈上) memmove(dst=ep, src=&v, elemsize) sendDirect / recvDirect —— 一次拷贝直达,不落 buf;sudog.elem 即对方栈槽位地址 对照 · 有缓冲路径(两次拷贝) G1 copy ① buf 槽位 copy ② G2 源码注释:Sends and receives on unbuffered or empty-buffered channels are the only operations where one running goroutine writes to the stack of another running goroutine.
复习提示:亮点句——「channel 是 Go 运行时里唯一允许一个 goroutine 写另一个 goroutine 栈的地方」,出处即源码注释。

收发流程 · chanrecv

chanrecv:对称但有独门技巧

chanrecv 接收流程判定图 接收流程从上到下五级判定:nil 通道永久阻塞、已关闭且空返回零值、有等待发送者则直接取数并唤醒、缓冲有数据则出队,否则 sudog 入 recvq 挂起。 v := <-ch ⇒ chanrecv(c, ep, block) (selected, received) 快速路径:!block && empty(c) → 无锁判空 + closed 判断 c == nil 从 nil 通道接收 gopark(nil, …, waitReasonChanReceiveNilChan, traceBlockForever) 永久阻塞 —— 与发送对称;select 中可利用此特性「禁用」某个 case closed && qcount==0 已关闭且缓冲已排干 typedmemclr(ep) → return (true, false) 返回零值 + ok=false —— v, ok := <-ch 的 ok 与 range 退出的判定来源 c.sendq.first != nil 有发送者在等(未关闭) recv(c, sg, ep):dataqsiz==0 → recvDirect | 满缓冲 → 首尾同槽互换 → goready 无缓冲:从发送者栈直拷;有缓冲:ep←队头槽,发送者的值写入同一槽(见下页注) c.qcount > 0 缓冲区有数据 ep=buf[recvx];typedmemclr(槽);recvx=(recvx+1)%cap;qcount-- 从队头消费并清槽(不留悬垂指针)—— return (true, true) 否则:空且无发送者 (且未关闭) acquireSudog → recvq.enqueue(mysg) → gopark 挂起等待:未来的发送者直接写进 ep,或 close 唤醒后收到零值
复习提示:与 chansend 对照记忆,差异在第 2 级(closed 后接收不 panic 而是给零值)。return 值 (selected, received) 分别服务于 select 与 v, ok。

收发流程 · 阻塞与唤醒

sudog:channel 与 GMP 的接口

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)
  • sudog 是“等待票据”:一个 G 阻塞在某个 channel 上时,创建一个 sudog 记录(谁、在哪个通道、数据放哪),串进 recvq/sendq。
  • 对象池化acquireSudog/releaseSudogsched.sudogcache 取还,避免热路径分配。
  • 交接协议:唤醒方设置 gp.param = sgsg.success;被唤醒的 G 检查 mysg != gp.waiting 判断队列一致性,用 success 区分「拿到数据」还是「因 close 醒来」。
  • 调度联动:gopark 把 G 置为 _Gwaiting 并解绑 M → M 立刻去跑别的 G;goready 置为 _Grunnable 放入 P 的本地运行队列。这就是「channel 阻塞 ≠ 线程阻塞」——OS 线程从不空等。
  • 全局死锁检测:所有 G 都 _Gwaiting 且无定时任务时,fatal error: all goroutines are asleep - deadlock!(proc.go)。
复习提示:把 sudog 讲成「channel 世界里的挂号单」最形象;gopark/goready 与 GMP 的关系若被追问,衔接同目录 gmp-scheduler.html(GMP 调度模型)。

关闭与多路复用 · closechan

closechan:一次 close,全队列清算

  1. 1校验close(nil) → panic;closed != 0panic("close of closed channel")(二次关闭)。通过后置 closed = 1
  2. 2清算接收者:逐个出队 recvq,把对方 ep 清零(typedmemclr)、success=false → 醒来收到零值 + ok=false
  3. 3清算发送者:逐个出队 sendq,elem=nilsuccess=false → 醒来即 panic("send on closed channel")
  4. 4统一唤醒:先 unlock,再用 glist 逐个 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 保证恰好关一次。

复习提示:close 的清算顺序「先收后发」和「unlock 后再 goready」是两个能体现源码功底的细节;落点永远是「谁发送谁关闭」。

工程与 QA · 行为矩阵

nil / 已关闭 / 正常:全行为速查表

操作nil channel已关闭 channel正常 channel
发送 ch <- v永久阻塞panic缓冲未满或有接收者 → 成功;否则阻塞
接收 <-ch永久阻塞先排干缓冲(ok=true);空则零值(ok=false有数据或有发送者 → 成功;否则阻塞
close(ch)panicpanic(二次关闭)成功,唤醒全部等待者
len / cap0 / 0len=缓冲剩余量 / cap 不变qcount / dataqsiz(无锁快照)
for range永不结束消费完缓冲后正常退出消费完当前数据后等待新数据
select 中该 case 永远不会被选中receive case 始终就绪(零值)就绪性由数据与对端决定

nil 的妙用:select 中把已完成分支的 channel 置 nil,让该 case 永久阻塞(相当于禁用),常用于「先处理 a,处理完只监听 b」的收敛逻辑。

记忆抓手:nil 通道「进不去也出不来」;已关闭通道「只出不进」。对已关闭通道:写=panic,读=善后(排干后零值)。

复习提示:这张表是面试陷阱题题库的总答案(nil 阻塞、关后发送 panic、关后接收排干再零值)。逐格复述一遍。

关闭与多路复用 · select

selectgo:随机轮询 + 有序加锁

  • 两个顺序,两个目的pollorder —— 对所有 case 做 Fisher-Yates 式随机洗牌,多 case 就绪时等概率选择,保证公平防饥饿;lockorder —— 按通道地址排序(堆排序)后依次加锁,全局一致的加锁顺序杜绝多通道场景下的 AB-BA 死锁。
  • 执行流程:按 lockorder 全部加锁 → 按 pollorder 逐个尝试,命中就绪 case 立即执行并解锁 → 都不就绪且无 default:为每个通道 enqueue 一个 isSelect=true 的 sudog → gopark
  • 唯一赢家:任一对端到来时,sudogCAS selectDone 竞争胜出,赢者完成通信,其余 sudog 作废出队 —— 保证多路等待只有恰好一个 case 执行。
  • select + default ≡ 以 block=false 走 chansend/chanrecv 快速路径,不满足立即返回;select{}(空块)= 永久阻塞。
  • 细节:同一个通道出现在多个 case 合法(会被去重合并);case 多时「锁全部通道」开销可观,热点路径避免巨型 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 是公平的」。

复习提示:两个顺序记成「公平靠洗牌、不死锁靠排序」;CAS selectDone 是 select 与普通收发在 sudog 上的关键差异(isSelect=true)。

关闭与多路复用 · 内存模型

channel 的 happens-before 保证

① 发送先于接收完成

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),发送返回时接收已拿到数据,天然是同步点

③ close 先于零值接收

The closing of a channel happens before a receive that returns a zero value because the channel is closed. —— 收到 ok=false 即可断言 close 前的全部写操作可见。

④ 容量 C 的窗口保证

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 通道)不改变以上四条。

复习提示:能背出 ①③ 两条即可应付多数面试,②④ 是加分项;追问「为什么传指针也安全」就答 happens-before 屏障。

工程与 QA · Pitfalls

高频坑与最佳实践

goroutine 泄漏三连

发送后无人收 / 接收后无人发 / 忘记 close 导致 range 不退出。防御:ctx.Done() 参与 select、超时兜底、select+default 降级、用带缓冲 channel 削峰。

谁来 close

只由发送方 close;多发送方用 signal channel(由主控方关闭)或 sync.Once。与 send 并发的 close 会 panic,与 recv 并发则安全。

值拷贝与共享底层

channel 传值拷贝:大结构体有拷贝开销;slice/map/指针共享底层数据——发指针须自行保证无竞态,别以为「过了 channel 就安全了」之后又并发改它。

buffered ≠ 无限队列

满了照样阻塞,这是特性(背压)不是 bug。无界消费需求应显式建模(slice + 条件导出,或带 dropping 策略),别指望加大 cap 解决。

channel+mutex 混用

同一份状态又上锁又走 channel,复杂度爆炸。一个数据一种保护手段;channel 管「交接与编排」,mutex 管「状态保护」。

单向类型是文档

函数签名用 <-chan T(只读)/ chan<- T(只写)表达契约,编译期即可抓住「接收方 close」这类反模式;运行时仍是同一个 hchan。

复习提示:泄漏是 channel 相关面试的必考延伸——先给现象(pprof goroutine 堆积在 gopark),再给三种防御手段。

工程与 QA · 选型

channel vs mutex vs atomic

场景推荐理由
goroutine 间传递数据流 / 所有权转移channel值交接自带 happens-before,语义清晰
保护共享状态(map / 计数 / 缓存)mutex / RWMutex临界区短;套 channel 反而绕、更慢
单变量原子增减 / 标志位atomic开销最小,无调度参与
编排:超时、取消、多路等待select + ctx.Done()channel+select 是为控制流而生
生产者-消费者解耦削峰buffered channel天然背压;注意容量与内存占用
一对一实时握手(请求-应答)unbuffered channelrendezvous 语义,直接拷贝零缓冲

性能清醒剂:channel 底层有 mutex + 可能的挂起/唤醒(gopark/goready),单次操作成本远高于 atomic 或无竞争 mutex。选 channel 的理由是正确性与可组合性,不是性能;Rob Pike 的口号解决的是工程问题,不是吞吐问题。

复习提示:答选型题的结构=「场景 → 手段 → 为什么」;被追问性能时给出「channel 单次收发 ≈ 微秒级(含调度),atomic ≈ 纳秒级」的量级感即可,别背具体数字。

Cheat Sheet

速查:判定顺序 · panic 清单 · 五条不变式

回看只需这一页;「nil / 已关闭 / 正常」的逐格行为见前面行为矩阵页。

① 收发五级判定 —— 顺序即优先级,白板默写就写这张

发送 ch <- v接收 <-ch
1c == nil → 永久阻塞c == nil → 永久阻塞
2已 closed → panic已 closed 且缓冲空 → 零值, ok=false
3recvq 有人等 → 直接拷贝+goreadysendq 有人等 → 直接取数 + 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 两个顺序

  • close:校验(nil/二次关 → panic)→ 置 closed=1 → 清算 recvq(给零值,ok=false)→ 清算 sendq(醒来 panic)→ 先 unlock 再统一 goready
  • selectpollorder 随机洗牌保公平(多 case 就绪等概率)+ lockorder 按通道地址排序防死锁;带 default = 以 block=false 走同一套函数

④ 五条不变式 —— 所有行为都能由它们推出

  • 有接收者在等 ⇒ 缓冲必为空有发送者在等 ⇒ 缓冲必已满(或无缓冲)。这条是"直接拷贝"成立的前提
  • 阻塞发送者的数据不在缓冲区,而在它自己的栈上(地址记在 sudog.elem),由未来的接收者来取
  • 读走后必须清槽typedmemclr):buf 里不留悬垂指针,GC 才不会误留对象存活
  • 持锁期间不得 goready:源码明确注释,防与栈收缩(shrinkstack)死锁 —— 所以 close 先 unlock 再唤醒
  • channel 有锁:hchan 里就是 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() 或超时兜底。

速查页:五级判定表是白板默写的主目标;panic 清单把"只有三种 panic"钉死;五条不变式是从源码推行为的工具(尤其前两条支撑直接拷贝)。行为矩阵页专管 nil/closed/正常 的逐格查表,本页管流程与不变式,两页互补不重复。

工程与 QA · 高频面试题

面试 QA(一):结构与流程

先自答再对照:每题先在心里说一遍,卡住的那题回去翻对应页,比直接读答案有用得多。

Q1channel 的底层数据结构是什么?

hchan:环形缓冲区 buf + qcount/dataqsiz/sendx/recvx,recvq/sendq 两条 sudog 双向链表,elemsize/elemtype、closed 标志,以及 lock mutex。Go 1.23 起另有 timer 字段。

Q2无缓冲和有缓冲在底层有何区别?

dataqsiz=0 时不分配 buf,收发必须汇合(rendezvous):sendDirect/recvDirect 在两个 goroutine 栈之间直接 memmove;有缓冲则异步,且 sendq 非空必然意味着缓冲已满。

Q3向已关闭 channel 发送/接收会怎样?

发送:panic("send on closed channel")(阻塞中的发送者被 close 唤醒后同样 panic)。接收:不 panic,先以 received=true 排干缓冲,空了返回零值 received=false。

Q4对 nil channel 收发 / 关闭会怎样?

收发 gopark 永久阻塞(可触发全局死锁检测),close(nil) panic。利用 nil 永不就绪的特性,可在 select 中把 channel 置 nil 来禁用某个 case。

Q5channel 是并发安全的吗?内部有锁吗?

安全。hchan 有 mutex 保护所有字段(含阻塞 G 的 sudog 部分),且持锁期间不得 goready(防栈收缩死锁)。“channel 无锁”是误区——热路径只是有免锁的快速判断。

Q6阻塞的发送方,数据暂存在哪里?

不在缓冲区。发送者把值的栈地址记进 sudog.elem 后挂入 sendq;接收者到来时直接从发送者栈 memmove 取走,再 goready 唤醒——所以 close 唤醒它时 elem 已作废。

复习提示:Q1/Q5/Q6 是“底层三板斧”,每题都能引到 chansend/chanrecv 的具体分支,答题时主动画 hchan 小图加分。

工程与 QA · 高频面试题

面试 QA(二):行为与工程

同样先自答再对照:这一组考的是行为边界工程习惯,答案都能回溯到速查页的五条不变式。

Q7什么时候会报 deadlock?

所有 goroutine 都 _Gwaiting 且无定时器可期时,runtime 抛 fatal error: all goroutines are asleep - deadlock!(如单 goroutine 向 nil/unbuffered channel 发送)。注意:只要还有别的 G 可运行就不报——可能表现为泄漏而非崩溃。

Q8select 多个 case 就绪怎么选?

随机。selectgo 先对 case 随机洗牌(pollorder)再轮询,等概率选择防饥饿;加锁则按通道地址排序(lockorder)保证全局一致顺序,避免多通道死锁;唤醒时 CAS selectDone 决定唯一赢家。

Q9如何优雅关闭 channel?多发送方呢?

原则:由发送方 close,接收方只读。多发送方:主控方关闭独立的 signal/done channel 通知全体,或 sync.Once + 原子计数保证恰好关一次;接收方关闭属于反模式。

Q10for range channel 何时结束?

等价于循环 chanrecv2:当 received=false(已 close 且缓冲排干)时退出。忘记 close 则 range 永远阻塞 → goroutine 泄漏。

Q11channel 传的是值还是引用?

值拷贝(typedmemmove)。大元素有拷贝成本;slice/map/指针等共享底层数据的类型,跨通道后底层仍共享——并发访问需自行加同步。

Q12channel 有哪些 happens-before 保证?

①send 先于对应 receive 完成;②unbuffered 的 receive 先于 send 完成;③close 先于因 close 收到零值;④容量 C 时第 k 次 receive 先于第 k+C 次 send 完成。因此跨通道传数据天然有序、无需额外同步。

复习提示:Q7 注意「不报错 ≠ 没泄漏」;Q9 能顺手给出「signal channel + sync.Once」两套代码骨架更稳。

Wrap up

相关知识点与参考

相关知识点(同仓库 · 待沉淀后互链)

GMP 调度模型 已沉淀

gopark/goready 的调度语义、_Gwaiting 状态机 —— theory/golang/gmp-scheduler.html

Go 内存模型 待沉淀

happens-before 全景:channel / mutex / atomic

goroutine 泄漏排查 待沉淀

pprof 定位 gopark 阻塞点与泄漏模式

context 与超时控制 待沉淀

ctx.Done() 本质也是一个 channel

栈与队列(数据结构系列) 已沉淀

环形队列判空/判满与背压 —— channel 底层结构的通用版

OS · 进程间通信 IPC 已沉淀

channel 的 OS 版:管道 / 消息队列 / 共享内存——"通信即同步"的内核实现

参考资料

  • Go 1.22.5 源码:runtime/chan.go(hchan/chansend/chanrecv/closechan/makechan)、runtime/select.go(selectgo)、runtime/runtime2.go(sudog)
  • The Go Memory Model — go.dev/ref/mem
  • Go 101 · Channels in Go — go101.org/article/channel.html
  • Go Blog · Share Memory By Communicating — go.dev/blog/codelab-share

一页总结:channel = mutex 保护的环形缓冲区 + 收发等待队列;发送/接收 = 五级判定(nil→closed→对端→缓冲→挂起);close = 全队列清算;select = 随机轮询 + 有序加锁;数据交接自带 happens-before。

复习提示:收尾用「一页总结」自测——不看 deck 复述五个要点,即为过关。