Work Stealing 工作窃取算法
在 Go 的 GMP 调度模型中,Work Stealing 是维系多核负载均衡的核心机制。当一个 Processor(P)的本地运行队列(LRQ)耗尽时,它不会闲着,而是主动去"偷"其他 P 的任务——这种自底向上的负载均衡策略,让 Go 的调度器在绝大多数场景下都能保持极高的 CPU 利用率。
1. 核心概念与工作原理
1.1 为什么需要 Work Stealing
想象四个收银员(P)面对四条结账通道(LRQ)。如果顾客(G)分布不均,有的通道排起长队,有的却空空如也,整个超市的吞吐量就会大打折扣。最直观的方案是让一个中央调度器来统一分配顾客,但这会引入严重的锁竞争和缓存失效问题。
Go 选择的方案是分布式队列 + Work Stealing:
- 每个 P 维护自己的 LRQ,无锁操作(只有当前 M 会访问)
- 当 LRQ 为空时,P 主动从其他 P 或全局队列(GRQ)中寻找任务
- 这种"按需窃取"的策略将负载均衡的开销均摊到空闲的 P 上,而非由中央调度器承担
1.2 偷取目标的优先级
runtime.findrunnable()中,Go 调度器按以下顺序寻找可运行的 G:
- 本地 LRQ— 无锁,O(1) 弹出
- 全局 GRQ— 需要加锁,竞争较大
- 网络轮询器(netpoll)— 检查是否有就绪的网络事件
- 其他 P 的 LRQ(Work Stealing)— 随机化遍历,偷取尾部一半
- 再次检查全局 GRQ
- 解析 GC 任务或降频休眠
1.3 "偷一半"策略
Go 的 Work Stealing 遵循一个精妙的规则:偷取受害者队列尾部的一半任务。
为什么是尾部?因为队列头部的任务大概率已经加载到 CPU 缓存中(局部性原理),受害者 P 很快会用到它们。偷尾部既能让窃贼获得足够的工作量,又最大限度减少了对受害者缓存局部性的破坏。
为什么是"一半"而非"一个"?如果只偷一个,窃贼可能很快再次耗尽,频繁触发偷取逻辑反而增加开销;如果全偷光,受害者 P 后续可能无事可做。"一半"在两者之间取得了最优平衡。
1.4 随机化遍历顺序(stealOrder)
多个 P 同时陷入空闲时,如果它们总是按固定顺序扫描其他 P,很容易发生集体碰撞——多个窃贼同时盯上同一个受害者。Go 的解决方案是:每个 P 在偷取前生成一个伪随机遍历顺序,将冲突概率降到最低。
这个随机化不是用昂贵的真随机数生成器,而是基于 P 的 ID 和一个预定的轮询表(stealOrder)计算出一个确定但看起来随机的访问序列。
2. 关键规则与机制
| 规则 | 说明 |
|---|---|
| 偷尾部一半 | 保留受害者头部任务的缓存局部性,窃贼获得可持续的工作量 |
| 随机化遍历 | 通过stealOrder避免多窃贼同时竞争同一个受害者 P |
| 从 GRQ 偷取 | 当从其他 P 偷不到时,会尝试从全局队列批量取走一批 G |
| Handoff 机制 | 当 M 进入系统调用阻塞时,其 P 会被移交给其他空闲 M 继续使用 |
| 自旋 M 限制 | 没有工作的 M 不会无限自旋,而是进入休眠,由 sysmon 或事件唤醒 |
3. 深度代码演练(完整可运行示例)
下面的程序用 Go 语言模拟了一个简化版的 Work Stealing 调度器。我们创建了 4 个 Processor,初始时只有 P0 拥有 20 个任务。随后 P1、P2、P3 依次尝试通过 Work Stealing 获取任务,观察"偷一半"和"随机化遍历"的效果。
packagemainimport("fmt""math/rand""sync""time")// LocalQueue 模拟 P 的本地运行队列(LRQ)typeLocalQueuestruct{tasks[]int// 存储任务 IDmu sync.Mutex}// Push 向队列尾部添加任务func(lq*LocalQueue)Push(taskint){lq.mu.Lock()deferlq.mu.Unlock()lq.tasks=append(lq.tasks,task)}// Pop 从队列头部弹出任务(FIFO,模拟真实调度)func(lq*LocalQueue)Pop()(int,bool){lq.mu.Lock()deferlq.mu.Unlock()iflen(lq.tasks)==0{return0,false}task:=lq.tasks[0]lq.tasks=lq.tasks[1:]returntask,true}// Len 返回当前队列长度func(lq*LocalQueue)Len()int{lq.mu.Lock()deferlq.mu.Unlock()returnlen(lq.tasks)}// Snapshot 返回队列当前快照(仅用于打印)func(lq*LocalQueue)Snapshot()[]int{lq.mu.Lock()deferlq.mu.Unlock()out:=make([]int,len(lq.tasks))copy(out,lq.tasks)returnout}// StealHalf 偷取队列尾部的一半任务,返回偷到的任务切片// 真实 Go 调度器中这是无锁的,这里用锁模拟并发安全func(lq*LocalQueue)StealHalf()[]int{lq.mu.Lock()deferlq.mu.Unlock()n:=len(lq.tasks)ifn<2{returnnil}// 偷取后半部分:保留前半部分以维护缓存局部性half:=n/2stolen:=make([]int,half)copy(stolen,lq.tasks[n-half:])lq.tasks=lq.tasks[:n-half]returnstolen}// Processor 模拟调度器中的 PtypeProcessorstruct{idintlrq*LocalQueue}// Scheduler 模拟 Work Stealing 调度器typeSchedulerstruct{ps[]*Processor}// NewScheduler 创建包含 n 个 P 的调度器funcNewScheduler(nint)*Scheduler{ps:=make([]*Processor,n)fori:=0;i<n;i++{ps[i]=&Processor{id:i,lrq:&LocalQueue{}}}return&Scheduler{ps:ps}}// stealOrder 生成伪随机化的偷取遍历顺序// 真实 Go 源码中使用基于轮询表的确定性随机序列func(s*Scheduler)stealOrder(thiefIDint,rng*rand.Rand)[]int{n:=len(s.ps)order:=make([]int,0,n-1)fori:=0;i<n;i++{ifi!=thiefID{order=append(order,i)}}// 随机打乱,避免多窃贼同时竞争同一受害者rng.Shuffle(len(order),func(i,jint){order[i],order[j]=order[j],order[i]})returnorder}// StealFrom 尝试从指定受害者偷取任务func(s*Scheduler)StealFrom(thiefID,victimIDint)[]int{stolen:=s.ps[victimID].lrq.StealHalf()iflen(stolen)>0{// 将偷到的任务放入窃贼的 LRQfor_,task:=rangestolen{s.ps[thiefID].lrq.Push(task)}}returnstolen}funcmain(){// Go 1.20+ 推荐显式创建本地随机源,避免全局锁rng:=rand.New(rand.NewSource(time.Now().UnixNano()))sched:=NewScheduler(4)// 初始状态:只有 P0 拥有 20 个任务(模拟负载不均)fori:=1;i<=20;i++{sched.ps[0].lrq.Push(i)}fmt.Println("=== 初始状态 ===")for_,p:=rangesched.ps{fmt.Printf("P%d: %d 个任务 %v\n",p.id,p.lrq.Len(),p.lrq.Snapshot())}// P1, P2, P3 依次尝试 Work StealingforthiefID:=1;thiefID<=3;thiefID++{order:=sched.stealOrder(thiefID,rng)fmt.Printf("\nP%d 开始偷取,遍历顺序: %v\n",thiefID,order)for_,victimID:=rangeorder{stolen:=sched.StealFrom(thiefID,victimID)iflen(stolen)>0{fmt.Printf(" → 从 P%d 偷到 %d 个任务: %v\n",victimID,len(stolen),stolen)break// 偷到即停止本次尝试}}}fmt.Println("\n=== 偷取后状态 ===")for_,p:=rangesched.ps{fmt.Printf("P%d: %d 个任务 %v\n",p.id,p.lrq.Len(),p.lrq.Snapshot())}// 进一步演示:所有 P 各自执行一个任务fmt.Println("\n=== 各 P 执行一个任务后 ===")for_,p:=rangesched.ps{iftask,ok:=p.lrq.Pop();ok{fmt.Printf("P%d 执行任务 #%d\n",p.id,task)}else{fmt.Printf("P%d 无任务可执行\n",p.id)}}fmt.Println("\n=== 最终队列状态 ===")for_,p:=rangesched.ps{fmt.Printf("P%d: %d 个任务 %v\n",p.id,p.lrq.Len(),p.lrq.Snapshot())}}4. 输出解读与分析
程序某次运行的输出如下(由于随机化遍历顺序,每次结果会略有不同):
=== 初始状态 === P0: 20 个任务 [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20] P1: 0 个任务 [] P2: 0 个任务 [] P3: 0 个任务 [] P1 开始偷取,遍历顺序: [2 0 3] → 从 P0 偷到 10 个任务: [11 12 13 14 15 16 17 18 19 20] P2 开始偷取,遍历顺序: [3 1 0] → 从 P1 偷到 5 个任务: [16 17 18 19 20] P3 开始偷取,遍历顺序: [1 2 0] → 从 P1 偷到 2 个任务: [13 14] === 偷取后状态 === P0: 10 个任务 [1 2 3 4 5 6 7 8 9 10] P1: 3 个任务 [11 12 15] P2: 5 个任务 [16 17 18 19 20] P3: 2 个任务 [13 14] === 各 P 执行一个任务后 === P0 执行任务 #1 P1 执行任务 #11 P2 执行任务 #16 P3 执行任务 #13 === 最终队列状态 === P0: 9 个任务 [2 3 4 5 6 7 8 9 10] P1: 2 个任务 [12 15] P2: 4 个任务 [17 18 19 20] P3: 1 个任务 [14]逐条解读
| 现象 | 原理说明 |
|---|---|
P1 从 P0 偷到 10 个(后半部分[11..20]) | 偷一半策略:P0 原 20 个,偷走尾部 10 个,P0 保留头部 10 个 |
P2 的遍历顺序[3 1 0]先跳过 P0 | 随机化遍历:P2 随机到了先检查 P3(空)→ P1(有 10 个) |
| P2 从 P1 偷到 5 个 | P1 有 10 个,尾部一半是 5 个 |
| P3 从 P1 偷到 2 个 | P1 此时剩 5 个,尾部一半是 2 个(整数除法) |
负载从[20, 0, 0, 0]变为[10, 3, 5, 2] | Work Stealing 在极短时间内将极端不均衡转化为相对均衡 |
5. 小结
| 要点 | 内容 |
|---|---|
| 核心目标 | 在无中央调度器的前提下,实现多核间的负载自动均衡 |
| 关键策略 | 空闲 P 从其他 P 的 LRQ 尾部偷取一半任务 |
| 随机化 | stealOrder伪随机遍历避免多窃贼集体碰撞 |
| 局部性保护 | 偷尾部而非头部,保留受害者即将执行任务的 CPU 缓存局部性 |
| 真实源码位置 | runtime/proc.go:findrunnable()、runtime/proc.go:stealWork() |