「满:等消费者腾地方」——把这句注释实测一遍
前作的归并写者里有一段转发循环,队列满时 runtime.Gosched(),注释写着"满:等消费者腾地方"。有读者追问了两句:这个循环串行阻塞着,为什么还要让出?它和 park/唤醒又是什么关系?
第一问的前提就错了:那个循环根本不是阻塞,是自旋。第二问的答案藏在运行时状态机里。这篇把三种"等"拆开讲清,再用真实数据验证——包括一个被我自己的实验打脸的结论。
1. 阻塞是假象,自旋是真的
Push 失败返回 false,for 立刻重试。这个 goroutine 从头到尾处于 _Grunning:没 park、没睡、没让核,调度器眼里它和正常干活的 G 没有任何区别。
所以问题从来不是"阻塞了要不要让出",而是"自旋时让不让别人跑"。而它等的进度在别人手里:队列满意味着只有消费者 Pop 释放槽位,Push 才可能成功——消费者在另一条 goroutine 上,此刻可能根本没在跑。
GOMAXPROCS=1 时尤其致命:不退出的紧循环占着唯一的 P,消费者从哪冒出来?这就是 Gosched() 存在的理由。
2. park/唤醒:睡的是 G,不是线程
讲让出之前先把 park/唤醒说透——它是 Go 调度器的原子操作,channel、mutex、timer、select 全踩在它上面。G 的状态机简化成三步:
park 侧,简化自 runtime/proc.go:
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer,
reason waitReason, ...) {
gp := getg().m.curg
gp.waitreason = reason // 记下"为什么睡",执行轨迹里可见
mcall(park_m) // 切到 g0 执行收尾
}
func park_m(gp *g) {
casgstatus(gp, _Grunning, _Gwaiting) // ① 标记等待
dropg() // ② G 与 M 解绑
if fn := mp.waitunlockf; fn != nil {
fn(gp, mp.waitlock) // ③ 释放它拿着的锁(如 hchan.lock)
}
schedule() // ④ 换下一个 G 上 P
}
三个细节定义了 park 的性格:
- 睡的是 G,不是线程。M 解绑后立刻去跑别的 G,全程用户态,没有系统调用、没有线程切换——这是 Go 与 1:1 线程模型最大的分野
- 睡在哪由调用方决定。channel 把 G 包成 sudog 挂进
sendq/recvq;mutex、WaitGroup 挂进 semtable 的 treap;timer 挂进每 P 的堆。gopark只管状态机,不管队列 - waitReason 是一等公民。
ChanSend、Semacquire、Sleep、Preempted……execution trace 里你看到的"阻塞原因"就是它
唤醒侧,channel 接收方捡到等待中的发送者时(runtime/chan.go):
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
sendDirect(c.elemtype, sg, ep) // 值直接从发送者栈拷到接收者栈
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
goready(gp, skip+1) // 唤醒
}
func ready(gp *g, traceskip int, next bool) {
casgstatus(gp, _Gwaiting, _Grunnable) // 睡 → 可跑
runqput(mp.p.ptr(), gp, next) // next=true:塞进 runnext 插队
wakep() // 有空闲 P 就拉一个起来
}
唤醒不是"通知",是插队。goready 固定 next=true,被唤醒的 G 直接占据当前 P 的 runnext 槽位,下一个就跑它。加上 sendDirect 的栈对栈拷贝,unbuffered channel 一次交接的热路径就是:拷值 + 改两处状态 + 塞 runnext。
mutex 和 WaitGroup 底层的 sema 也是这套(runtime/sema.go):semtable 是 251 个桶的数组,按信号量地址哈希,每桶一棵 sudog treap;semrelease 从 treap 摘队头,经 readyWithTime 走到 goready。竞争激烈时 mutex 切饥饿模式,handoff 直接把票交给队头,唤醒链路更短。
Tip
所以"park/唤醒微秒级"要拆开说:同 P 直接交接实测约 250ns/次,比一次跨核缓存行乒乓还便宜;贵的是跨 P/跨 M 的完整链路——wakep 拉起新线程要 futex,那时才是微秒级。
3. runtime.Gosched:不睡觉的让位
Gosched 走的是完全另一条路:
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
func goschedImpl(gp *g, preempted bool) {
casgstatus(gp, _Grunning, _Grunnable) // 注意:没经过 _Gwaiting
dropg()
lock(&sched.lock)
globrunqput(gp) // 扔回全局队列——不是本地,更不是 runnext
unlock(&sched.lock)
if mainStarted {
wakep()
}
schedule()
}
两个关键差异:
- 它从不睡觉。状态从
_Grunning直接改_Grunnable,没有_Gwaiting,也就没有"唤醒"一说——它只是把自己摘下来回炉 - 它进的是全局队列。
globrunqput不是递给某个特定 goroutine:Gosched没有"把 P 交给消费者"的语义,只有"我不跑了,大家重新排队"的语义
所以 Gosched 与 park 的关系一句话:park 是"我不行了,有结果叫我";Gosched 是"我还能跑,但让别人先来"。 前者睡到被唤醒,后者立刻回到就绪态重新排队。
还有第三种换班你控制不了:sysmon。这个不需要 P 的后台监控线程巡逻全局,发现某个 G 在 P 上连续跑超过阈值就强制抢占:
Go 1.14 之前,抢占只能发生在函数调用的栈检查点,纯计算紧循环(无调用)不可抢占——GOMAXPROCS=1 下就是真饿死(见 Go 1.14 release notes)。1.14 起改用信号异步抢占,紧循环也能被切开,代价是最多 10ms 的延迟。记住这 10ms,下面的实验里全是它。
4. 实测:1P 是一台照妖镜
完整代码在仓库 example/2026/09/09-1/park-or-spin:07.md 的 SPSC 原样复用,转发者模拟 MergedQ.Run,双端对称地用三种策略等(生产者等满、消费者等空):
cd example
go run ./2026/09/09-1/park-or-spin -exp=stall -procs=1 -n 50000 # 满队列三策略对比
go run ./2026/09/09-1/park-or-spin -exp=stall -procs=2 -n 50000 # 多核对照
go run ./2026/09/09-1/park-or-spin -exp=ladder -procs=1 # 等待成本阶梯
GODEBUG=asyncpreemptoff=1 go run ./2026/09/09-1/park-or-spin -exp=stall -procs=1 -wait=0 # 会挂死,属预期
GOMAXPROCS=1(Go 1.24.13,n=50000,cap=1024):
| 等待策略 | 首元素延迟 | 最大弹出间隔 | >1ms 间隔数 | 总耗时 |
|---|---|---|---|---|
| 双端纯自旋 | 14-20ms | 40-60ms | ~50 | 1.92s |
| 双端自旋+Gosched | 16-122µs | 20.1ms | 48 | 962ms |
| 双端自旋+Sleep(100µs) | 33-130µs | 40.2ms | ~50 | 988ms |
纯自旋完美验证了 10ms 节拍:最大间隔 40-60ms,是 forcePreemptNS 的 4-6 倍——每次换班都在等 sysmon 强制抢占。首元素延迟 14ms:消费者在程序开始后 14ms 才第一次被调度。
Gosched 把首元素降到微秒级——"让出"在起步阶段确实立竿见影。但看总耗时:962ms ≈ 48 × 20ms,每个满/空转换都卡了两个 10ms 节拍。GODEBUG=schedtrace=100 的输出解释了原因:
SCHED 105ms: gomaxprocs=1 idleprocs=0 ... runqueue=1 [0]
SCHED 206ms: gomaxprocs=1 idleprocs=0 ... runqueue=1 [0]
SCHED 508ms: gomaxprocs=1 idleprocs=0 ... runqueue=1 [0]
SCHED 609ms: gomaxprocs=1 idleprocs=0 ... runqueue=0 [1]
全局队列里长期躺着一个 G,P 上跑着另一个。Gosched 扔回全局队列这个动作,在两个互相等对方进度的 G 之间并没有形成想象中的 ns 级乒乓——稳态轮转仍被 sysmon 的 10ms 节拍支配。 更狠的对照:加上 GODEBUG=asyncpreemptoff=1(关掉异步抢占)后,连双端都显式 Gosched 的版本也直接挂死。1P 世界里,调度进展的最终兜底是抢占节拍,不是礼让。
作为对照,单端让出——只有转发者 Gosched、消费者继续纯自旋——总耗时从 7.98s 降到 3.93s(n=200000):让出有用,但只要还有一端在忙等,账单就只打折不清零。
5. 多核世界与成本阶梯
同一份代码换 GOMAXPROCS=2:
| 等待策略 | 首元素延迟 | 最大弹出间隔 | 总耗时 |
|---|---|---|---|
| 双端纯自旋 | 68-75µs | 11-16µs | 9.6-9.8ms |
| 双端自旋+Gosched | 15-117µs | 48-287µs | 9.1-11.2ms |
| 双端自旋+Sleep(100µs) | 16-102µs | ~1ms | 29.5-29.9ms |
200 倍的差距。多一个核,纯自旋立刻从灾难变最优——消费者在另一个核上实时腾地方,生产者的自旋几乎不用空转。这正是前作"核多于线程时自旋是优势,核超订阅时是灾难"的定量版。
等待本身的成本阶梯(ns/往返):
| 等法 | 1P | 2P |
|---|---|---|
| 原子标志乒乓(纯自旋,零调度参与) | — | 260 |
| Gosched 往返 | 335 | 1745 |
| channel 往返(2 次 park/唤醒交接) | 537 ≈ 268/次 | 476 ≈ 238/次 |
park/唤醒的直接交接和一次跨核缓存行乒乓同量级——比多数人以为的便宜得多。而 Gosched 在 2P 下反而贵了 5 倍(1.7µs):全局队列的锁 + 跨 P 唤醒,都是多核才有的税。
6. 注意事项
- ⚠️ 等待策略不属于队列,属于使用者。SPSC 的
Push只负责返回false;自旋、Gosched、Sleep、park 都是调用方的决策,队列自己不该替你选 - ⚠️ 容器配额 1 核就是 1P 世界。CPU limit=1 的 pod 里,第 4 节所有 1P 结论原样生效;Go 1.25 起运行时默认感知 cgroup 配额并动态下调
GOMAXPROCS,这类场景只会更多 - ⚠️ 紧循环的历史包袱:Go 1.14 前无异步抢占,1P + 无调用紧循环 = 真饿死;今天用
asyncpreemptoff=1就能复现当年的世界 - ✅ 真要稳,给等待加终点:要么 park 住(把"满"接成 channel 语义,交给运行时的唤醒链路),要么保证生产者与消费者不在同一份 CPU 预算里抢饭
小结
回到开头两个问题。第一问:那个循环不是阻塞是自旋,让出是因为它等的进度在另一条 goroutine 手里——这半句成立。第二问:Gosched 不经过 _Gwaiting,是"回炉重排"而非"睡觉";park/唤醒是状态机的正路,睡 G 不睡线程,唤醒即插队——机制讲清了。
但我此前那句"消费者立刻被调度"被实验打了一半的脸:起步阶段成立(µs 级),1P 稳态不成立(20ms 节拍)。实测的价值就在这——"让出"的语义是"回到队列重新排队",不是"把 P 递给某个具体的人"。想要确定性交接,得用有配对语义的原语(channel 的 send/recv 配对、sema 的票交接),或者干脆多给一个核。
调度器在 1P 忙等场景下的队列细节(全局队列批搬、runnext 插队、retake 的抢 P 顺序)这次只挖到现象层,值得另写一篇。
最后更新:2026-09-09