找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4237

积分

0

好友

587

主题
发表于 14 小时前 | 查看: 7| 回复: 0

在探讨Go语言的高并发能力时,“work-stealing”(工作窃取)是一个绕不开的核心机制。很多人知道它有助于在多核处理器间平衡负载,但对其在Go运行时(runtime)中的具体实现却了解不多。今天,我们就直接深入到 runtime/proc.go 的源码中,拆解Go调度器是如何完成这场高效“偷窃”的,并理解它为何能成为Go高性能的基石。

为什么需要work-stealing?

让我们先想象一个典型的多核负载失衡场景:

  • 处理器P0的本地队列里塞满了200个待执行的goroutine。
  • 而处理器P1、P2、P3却早已处于空闲状态。

如果没有“偷窃”机制,P1到P3只能干等着,而P0则忙得不可开交。这直接导致多核CPU的利用率极低,完全违背了并发编程的初衷。

work-stealing的核心思想非常直观且有效:

我没活干了?那我就去“偷”别人手里还没干完的活!

但难点在于,如何在多线程环境下实现高效、低延迟且无锁(或近似无锁)的“偷窃”,同时避免活锁并控制开销。Go从1.1版本开始引入了基于P(Processor)的work-stealing调度器(由Dmitry Vyukov主导设计),这个基本框架一直沿用至今(Go 1.24+),并持续优化。

偷窃发生的四个主要时机(findrunnable路径)

当一个工作线程M与其绑定的处理器P发现本地运行队列(runq)为空时,它会进入 findrunnable() 函数寻找可执行的goroutine。其查找路径遵循一个优先级顺序(以下为简化说明):

func findrunnable() (gp *g, inheritTime bool) {
    // 省略大量细节,只列主要路径优先级

    1. 本地 runq 出队(最快,最常用)
       ← 如果有,直接返回

    2. 从全局 runq 批量取(一次最多 1/2 容量或 256 个)
       ← 全局队列有的话,拿一批进来本地

    3. netpoller / pollDesc 里是否有就绪的 fd 事件(网络轮询器)
       ← 有的话转成 goroutine 执行

    4. 遍历所有其他 P,尝试偷窃(核心 work-stealing 部分)
       for _, p2 := range allp {
           if p2 == _p_ { continue }
           // 尝试从 p2 偷
           if gp := runqsteal(_p_, p2, false); gp != nil {
               return gp, false
           }
       }

    5. 再尝试偷 timer(1.14+ 后 timer 也可以被偷)
       ...

    6. 实在没活 → 进入 spinning / park 流程
}

可以看到,在尝试了本地队列、全局队列和网络轮询器后,“偷窃其他P的任务”是调度器寻找工作的核心手段之一。而真正执行“偷窃”动作的函数是 runqsteal()

runqsteal 源码核心逻辑解析

runqsteal 函数的职责是从另一个处理器p2那里偷取goroutine到当前处理器p。其核心逻辑体现了Go调度器在工程上的精巧取舍。

// 从 p2 偷取 goroutine 到 p(当前 processor)
// returns the goroutine stolen, or nil
func runqsteal(p, p2 *p, stealRunNextG bool) *g {
    // 先尝试偷 runnext(p2 正在准备执行的那个 G,优先级更高)
    if stealRunNextG {
        // 优先尝试偷 p2->runnext(单次 CAS)
        if gp := runqstealrunnext(p2); gp != nil {
            return gp
        }
    }

    // 再偷本地队列(批量)
    for attempts := 0; attempts < 4; attempts++ {  // 通常尝试 4 次
        qsize := atomic.Load(&p2.runqsize)
        if qsize <= 1 {  // 太少了,不值得偷
            break
        }

        // 决定偷一半(四舍五入)
        n := qsize / 2
        if n > len(p.runq)/2 {  // 不要偷得太多,免得把本地也撑爆
            n = len(p.runq) / 2
        }
        if n == 0 {
            break
        }

        // 真正执行批量偷窃(无锁环形队列实现)
        n = runqgrep(p2, p, n)   // 从 p2 尾部弹出 n 个,推入 p 尾部

        if n > 0 {
            // 成功偷到,至少返回第一个
            gp := runqget(p)
            return gp
        }
    }

    return nil
}

我们可以从中总结出几个关键设计点:

  1. 优先偷 runnext:即将执行的那个goroutine(G)优先级最高,因为它“最热”,且只需一次原子操作(CAS)即可完成,延迟极低。
  2. 批量偷取一半:从目标P的本地队列中偷取约一半的任务(n = qsize / 2)。这种批量操作减少了频繁偷取单个任务带来的开销。
  3. 无锁环形队列:通过原子操作和环形队列结构,实现了高效的、近似无锁的出队和入队。
  4. 数量限制:偷取的数量不能超过自己本地队列剩余容量的一半(n > len(p.runq)/2),防止把“小偷”自己的队列也撑满。
  5. 尝试次数有限:最多尝试4次左右,避免了在高核数机器上因遍历所有P而消耗过多时间。

Go的work-stealing为何特别高效?

很多人评价Go的调度器“简单粗暴”,但这恰恰是其在工程实践上深思熟虑的结果。其高效性源于一系列精妙的设计组合:

  • 延续偷取而非子任务偷取:Go选择偷取“continuation”(即将要执行的后续代码),而非像某些框架那样偷取“子任务”。这能带来更好的CPU缓存局部性。
  • runnext 单次CAS优先:优先处理最高热度的任务,且代价极小。
  • 可控的自旋与唤醒机制:系统允许大约 GOMAXPROCS/2 个自旋的M(工作线程)去寻找工作(包括偷窃)。一旦找到工作,就按需唤醒更多线程,保证了“工作守恒”,同时避免了不必要的CPU空转。
  • 全局队列作为缓冲区:大部分调度压力由本地队列和偷窃机制承担,全局队列更多是新创建goroutine的临时落脚点。这极大地减少了全局锁的竞争,是Go能在高并发场景下保持低调度开销的重要原因之一。
  • 定时器亦可偷取:从Go 1.14开始,timer也能被其他P偷取执行,有效解决了定时器因P繁忙而产生的延迟问题,进一步提升了系统的公平性和响应速度。

总结:务实的设计哲学

Go的work-stealing实现体现了一种务实的工程哲学

  • 它没有采用学术界或某些框架中过于复杂的双端无锁队列。
  • 而是选择了简单的环形数组 + 批量偷一半的策略。
  • 并通过优先级(runnext > 批量 > timer)自旋线程上限 来平衡吞吐量与功耗。

这套组合拳在真实的互联网服务负载(大量短连接、I/O与计算混杂)下,表现出了极高的多核利用率和极低的调度开销。

所以,当被问及“Go的并发模型为什么高效”时,你现在可以给出一个更底层的答案:因为它内置的 work-stealing调度器,能在多个处理器核心之间“偷”得又快、又准、又有分寸。如果你想深入探讨更多Go的底层机制,例如 channel 的通信原理或 context 包的设计,欢迎在云栈社区的Go板块继续交流。




上一篇:陶瓷谐振器与滤波器应用解析:如何在成本敏感型电路中实现稳定频率控制
下一篇:打破误区:AI大模型推理速度反超小模型的关键因素
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-16 21:42 , Processed in 0.478514 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表