runqsteal 是工作窃取的实际执行者,在 findrunnable 中显式调用,仅当本地队列、全局队列和 netpoll 均空时触发;它从其他 P 本地队列尾部窃取一半 G(向下取整,上限 32 个),失败则返回 nil,M 随后解绑 P 并休眠。

runqsteal 函数是工作窃取的实际执行者
工作窃取不是靠定时轮询或事件驱动触发的,而是在 findrunnable 调度主循环中显式调用 runqsteal 完成的。当当前 P 的本地队列(runq)为空、全局队列也取不到 G、网络轮询器(netpoll)也没有就绪 G 时,才会进入窃取逻辑。
关键点在于:它只从其他 P 的本地队列尾部“偷一半”,而不是全拿——既保留被窃取 P 的局部性缓存热度,又避免单次窃取过多导致负载不均。
-
runqsteal会遍历所有其他 P(通过allpSnapshot快照),跳过当前 P 和已处于 _Pidle 状态的 P - 每次尝试窃取时,只取目标 P 队列长度的一半(向下取整),且最多取 32 个(硬编码上限)
- 窃取成功后,被偷的 G 会直接入当前 P 的本地队列尾部,后续由
runqget按 FIFO 取出 - 若所有 P 都没可偷的 G,函数返回
nil,调度器继续检查是否该进入休眠或触发 GC
为什么 stealRunNextG 参数控制是否优先偷 runnext
runnext 是 P 上一个“预设优先级最高”的 G,通常用于刚唤醒或刚创建的 goroutine(比如 go f() 后立即放入 runnext 而非队列尾)。在窃取时是否包含它,取决于 stealRunNextG 这个布尔参数:
- 当
stealRunNextG == true(如findrunnable中首次窃取),会先尝试把目标 P 的runnext抢过来,再偷队列里的一半 - 但这个操作是非原子的:
runnext可能已被其他 M 抢走,所以实际会用atomic.Casuintptr尝试交换,失败则跳过 - 这种设计让高优先级 G 更快被调度,但也意味着
runnext不是强保证——它本质是优化,不是语义承诺
工作窃取失败后 M 会解绑 P 进入休眠
窃取不是无限重试的。如果 runqsteal 返回 nil,且全局队列、netpoll 都无任务,findrunnable 会判定当前 M “无事可做”。此时调度器不会让 M 空转,而是执行标准收尾流程:
- M 主动调用
handoffp,将绑定的 P 设置为 _Pidle 状态,并放入空闲 P 链表(pidle) - M 自身状态变为 _MIdle,随后调用
stopm进入休眠(底层是futex或semacquire) - 注意:P 并未销毁,只是释放给其他 M 复用;下一次有新 G 到来(比如系统调用返回、channel 唤醒),会唤醒某个 idle M 并重新绑定 P
- 这一步直接影响高并发低负载场景下的线程数弹性——M 数量会随活跃 G 动态收缩,而非固定占用
容易忽略的边界:P 数量变化时的窃取一致性
GOMAXPROCS 可运行时修改,P 数组可能扩容或缩容。但 runqsteal 使用的是 allpSnapshot ——一个在进入调度前拷贝的 P 指针快照,而非实时遍历 allp 全局数组。
- 快照机制避免了遍历时对
allp加锁,也防止因 P 动态增减导致指针失效或越界 - 但这也意味着:刚新增的 P 在本次窃取中不可见;刚被销毁的 P 指针在快照里仍存在(需检查其
status是否为 _Pidle) - 真正影响调度公平性的不是快照延迟,而是 P 队列长度统计本身是近似值——
runqsize字段未加锁更新,runqsteal读到的可能是旧值
这种松散一致性是性能与正确性之间的典型权衡:工作窃取本就是尽力而为的负载均衡,不需要强一致,只要不长期饥饿即可。


















