Go 并发模型深入:goroutine、channel 与 select 的底层原理
Go 并发的本质结论:goroutine 是 runtime 托管的用户态协程(初始栈仅 2KB,按需扩缩),靠 GMP 模型做 M:N 调度;channel 就是一个带互斥锁的 hchan 结构——环形缓冲区加两条 sudog 等待队列;select 则是把 case 打乱顺序、按地址排序加锁后的一次「轮询 + 挂起」。理解这三件事,Go 并发就从「会用」变成「知道为什么快、为什么卡」。
goroutine:2KB 起步的协程,由 GMP 三件套调度
结论:goroutine 能开到几十万个,不是因为它轻在「协程」两个字上,而是因为它的调度完全在用户态完成,切换不需要陷入内核。
G 是 goroutine 本身(保存栈指针、程序计数器、状态);M 是真正干活的 OS 线程;P 是逻辑处理器,数量由 GOMAXPROCS 决定,是执行 Go 代码的「许可证」——一个 M 必须绑定 P 才能跑 G。
每个 P 有一个本地运行队列,容量 256。新创建的 goroutine 优先放进当前 P 的 runnext 槽(抢占式地让新任务先跑),队列满了才批量挪一半到全局队列。当一个 P 闲下来,它会去别的 P 偷一半任务(work stealing);每调度 61 次会强制看一眼全局队列,防止全局任务饿死。
真正的杀手锏是阻塞处理。当 goroutine 发起网络 IO,runtime 把它交给 netpoller(底层是 epoll/kqueue/IOCP),M 不阻塞,继续跑别的 G;当发生阻塞式系统调用,M 会和 P 解绑,P 被交给另一个 M 继续执行。所以「一 goroutine 一线程」的模型下会卡死的场景,在 Go 里通常只是挂起一个结构体。
另外从 Go 1.14 起,抢占是异步的:sysmon 监控线程发现某个 G 跑太久,会发信号强制抢占,不再依赖函数调用点检查。结论:死循环不再能永久霸占 P。
channel:一把锁 + 环形缓冲 + 两个等待队列
结论:channel 不是魔法管道,它的底层就一个 hchan 结构,核心字段是 mutex 锁、环形缓冲区 buf、以及 recvq / sendq 两条等待队列。
hchan 关键字段:qcount(当前元素数)、dataqsiz(缓冲容量)、buf(环形数组)、sendx/recvx(读写游标)、closed 标志、lock 互斥锁,外加 recvq 和 sendq——存放被阻塞的 sudog(可以理解为「被打包的 goroutine」)。
发送和接收都走同一套逻辑:先加锁 → 如果对面有等待者,直接内存拷贝交接(无缓冲 channel 就是直接把值拷到接收方栈上,所以它慢不到哪去)→ 如果有缓冲空位就直接入队 → 都不满足就构造 sudog 挂到等待队列并 gopark 让出。
几个必须记牢的规则:
- 向已关闭的 channel 发送 → panic;关闭已关闭的 → panic;关闭 nil channel → panic。
- 从已关闭的 channel 接收不会阻塞,返回零值 + `ok=false`,所以 `for v := range ch` 会在关闭时自然退出。
- nil channel 上的收发永久阻塞。这不是 bug,是特性:select 里把某个 case 的 channel 设为 nil,就等于临时关掉这个分支。
性能上要注意:channel 内部是互斥锁,热点 channel 在极端并发下会退化成锁竞争。高频计数、状态共享这类场景,用 `sync.Mutex` / `atomic` 往往比 channel 快得多——「用通信共享内存」是设计哲学,不是性能教条。
select:打乱顺序、排序加锁,然后挂起
结论:select 的底层是 runtime.selectgo,它把所有 case 收集成 scases 数组,用随机化的 pollorder 轮询,用按 hchan 地址排序的 lockorder 加锁,避免多 channel 场景下的死锁。
具体流程:先把每个 case 的 channel、sudog 都准备好;然后按随机顺序扫一遍看有没有就绪的(这就是 select 公平性的来源,不会永远偏向写在前面的 case);没有就绪就把所有 sudog 挂到各 channel 的等待队列上并 park;任意一个被唤醒后,再把自己从其余队列里摘干净。
几个实战坑:
- 空 `select{}` 会永久阻塞当前 goroutine;如果主 goroutine 只有它,运行时会直接报 all goroutines are asleep - deadlock。
- `select` 里带 `default` 就变成非阻塞轮询,放进 for 循环里就是 100% CPU 空转,务必配 ticker 或阻塞等待。
- select 的 case 数量上限是 65536,正常业务根本碰不到。
实践建议
结论:默认顺序是——能用 context 取消就用 context,能加锁就别硬套 channel,channel 只用于「传递所有权」和「编排流程」。
典型的选型:任务分发、pipeline、超时取消、结果汇聚用 channel + select;计数器、缓存、共享 map 用 Mutex / RWMutex / sync.Map;并发任务编排用 `errgroup.Group` 配合 `context.WithCancel`,比手写 WaitGroup + channel 少一半代码和一半 bug。
最后回到一句话:goroutine 便宜是因为调度在用户态,channel 慢是因为里面真有一把锁,select 公平是因为它每次都打乱顺序。把这三条记住,写并发代码时手会稳很多。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





