Sirius
Sirius

目录

golang GMP模型

本篇写GMP

一直觉得GMP的内容比较散乱,这篇也是在草稿放了很久

还是用问答形式来写了

GMP 模型是 Go 语言运行时 (runtime) 为了实现高效并发而设计的核心调度方案。它是一种先进的 M:N 调度器,旨在以极低的开销管理成千上万的 Goroutine (G),并将它们映射到少量的操作系统线程 (M) 上执行。

GMP 模型由三个核心结构体组成,它们定义在 runtime2.go 中:

代表一个 goroutine,Go 语言中并发执行的基本单元,由 go 关键字创建,包含栈、状态和上下文信息。

  • 代码包含

    • stack: 描述实际的栈内存范围 [lo, hi)
    • atomicstatus: G 的状态(如 _Grunnable, _Grunning, _Gwaiting, _Gsyscall)。
    • m: 当前绑定执行该 G 的 M。
    • sched: gobuf 结构,保存了 SP (Stack Pointer) 和 PC (Program Counter),用于上下文切换。
    • 特点
      • 轻量级:每个 Goroutine 的初始栈空间非常小(通常为 2KB),并且可以根据需要动态伸缩。因此,创建和销毁的成本远低于操作系统线程。
      • 用户态管理:Goroutine 的创建、销毁和切换调度完全由 Go 运行时在用户态完成,不直接依赖操作系统内核。
      • 保存上下文:每个 G 都保存了自己的执行栈、程序计数器和状态信息。

指代操作系统的线程 (Kernel Thread)。是真正执行计算的实体。

  • 代码包含

    • g0: 一个特殊的 G,拥有较大的系统栈。调度逻辑(如 schedule, findRunnable)都在 g0 上执行。
    • curg: 当前 M 上正在运行的用户 G。
    • p: 当前 M 绑定的 P。
    • nextp: 暂存的 P,当 M 从系统调用返回或被唤醒时可能会绑定。
  • 特点

    • 执行者:M 是真正干活的。它从 P 的本地运行队列中获取 G,并执行 G 中的代码。
    • 数量有限:M 的数量受到操作系统和 Go 运行时的限制。Go 运行时会控制 M 的数量,通常有一个活跃上限,但为了处理阻塞的系统调用,可能会临时创建更多的 M。
    • 重量级:M 的创建和上下文切换都涉及到内核,开销较大。

代表逻辑处理器,维护运行 G 所需的资源(如内存缓存、运行队列), Processor 代表一个虚拟的处理器或执行上下文。它是 G 和 M 之间的“中间人”,负责调度 G 到 M 上执行。

  • 代码包含

    • runq: 本地运行队列(Local Run Queue),是一个无锁的环形数组([256]guintptr)。
    • runnext: 一个特殊的槽位,用于存放下一个优先执行的 G(实现 LIFO,提高局部性)。
    • m: 反向指向当前拥有该 P 的 M。
    • mcache: 内存分配缓存(TCMalloc 机制的核心)。
  • 特点

    • 调度核心:P 最重要的部分是它拥有一个本地可运行 Goroutine 队列 (Local Run Queue, LRQ)。这使得 G 的调度可以大部分在 P 的本地完成,避免了全局锁的竞争。
    • 数量可控:P 的数量由环境变量 GOMAXPROCS 或运行时函数 runtime.GOMAXPROCS() 设置,默认情况下等于 CPU 的核心数。这个值决定了 Go 程序可以真正并行执行的最大 Goroutine 数量。
    • 解耦 G 和 M:P 的存在,使得 M 和 G 的关系不再是固定的。一个 M 可以在不同的 P 上执行任务,一个 G 也可以在不同的 P 和 M 上被调度。

全局调度器结构 schedt

  • runq: 全局运行队列(Global Run Queue),存放没有 P 承载的 G。
  • midle / pidle: 空闲的 M 和 P 链表。
  • lock: 全局锁,保护全局队列等资源。

  1. 一个 M 必须先与一个 P 绑定才能执行任务。
  2. M 从其绑定的 P 的本地运行队列 (LRQ) 中弹出一个可运行的 G。
  3. M 执行这个 G 的代码。
  4. 当 G 执行完毕、因 channel 阻塞或主动让出 CPU 时,M 会继续从 P 的 LRQ 中获取下一个 G。

调度器的核心是一个无限循环,由 schedule() 函数驱动。

这是调度循环的主逻辑,运行在 g0 栈上:

  1. 检查异常:检查是否持有锁、是否在 cgo 中等。
  2. findRunnable(): 阻塞式地寻找下一个可运行的 G。
  3. execute(gp, inheritTime):
    • 将 G 的状态改为 _Grunning
    • 将 M 的 curg 指向该 G。
    • 调用 gogo(&gp.sched)(汇编),从 g0 栈切换到用户 G 栈,并跳转到 G 的 PC 开始执行。

这是调度器最复杂的逻辑,决定了 G 的获取优先级(为了公平和效率):

  1. runnext (LIFO):
    • 最优先检查 P 的 runnext 字段。这是为了优化刚刚被唤醒的 G(如通信后的 G),让它尽快运行。
  2. Local Run Queue (FIFO):
    • 从 P 的本地队列 runq 获取 G。
  3. Global Run Queue (Fairness):
    • 为了防止全局队列饥饿,每 61 次调度(schedtick % 61 == 0),会优先从全局队列 sched.runq 获取 G。
  4. Netpoll (Non-blocking):
    • 检查网络轮询器(Netpoller),看是否有 G 从网络 I/O 中就绪。
  5. Work Stealing (Steal):
    • 如果本地没任务,会尝试从其他 P 的本地队列窃取一半(runqsteal)的任务。
    • 这是 Go 高效利用多核的关键。
  6. Global Run Queue (Blocking):
    • 如果还没找到,再次检查全局队列(这次会加锁并可能阻塞)。
  7. Netpoll (Blocking):
    • 如果以上都失败,线程可能会进入休眠,等待 Netpoll 唤醒或被其他线程唤醒。

当 G 执行结束(goexit)或主动让出(Gosched)或阻塞(gopark):

  • goexit0: 清理 G 的状态,将其放回 P 的空闲列表 gFree,然后再次调用 schedule(),开始新一轮循环。

Go 使用协作式抢占(基于函数调用)和异步抢占(基于信号,Go 1.14+)相结合的方式。

  • 触发者: 后台监控线程 sysmon 会定期运行 retake 函数。
  • 检测逻辑: retake 遍历所有 P。如果一个 P 处于 _Prunning 状态且当前 G 运行时间超过 10ms (forcePreemptNS),则触发抢占。
  • 抢占实现 (preemptone):
    1. 设置标志: 将 G 的 preempt 字段设为 true
    2. 修改栈守卫: 将 g.stackguard0 设置为 stackPreempt (一个非常大的值)。
    3. 协作式: 当 G 进行下一次函数调用时,编译器插入的栈检查指令会发现 SP > stackguard0,从而跳转到 morestack,进而调用 newstacknewstack 检查到是抢占请求,会将 G 挂起并放回运行队列。
    4. 异步抢占 (Async Preemption): 如果支持(如 Linux),preemptone 还会向 M 发送 SIGURG 信号。信号处理函数会中断当前执行流,保存上下文,并强制调度。这解决了没有函数调用的死循环无法被抢占的问题。

当用户代码使用 go 关键字创建一个新的 G 时,这个 G 会优先被放入当前 Goroutine 所在的 P 的 LRQ。如果 LRQ 已满,则会将该 P 的一半 G 和新创建的 G 一起放入全局运行队列 (GRQ)

G 的创建通常由 go 关键字触发,编译器将其转换为对 runtime.newproc 的调用。

  • 入口函数: newproc (位于 proc.go)
  • 核心逻辑:
    1. 获取上下文: 获取当前 G 和调用方的 PC。
    2. 切换到系统栈: 使用 systemstack 切换到 g0 栈执行 newproc1,这是为了防止栈溢出和保证调度安全。
    3. 调用 newproc1: 这是真正创建 G 的函数。
      • 复用或新建: 首先尝试从当前 P 的本地空闲列表 (pp.gFree) 获取 G (gfget)。如果获取不到,则调用 malg 分配一个新的 G 结构体和栈空间。
      • 初始化: 设置 G 的状态为 _Gdead,初始化栈指针 (SP)、指令指针 (PC)。PC 被设置为 goexit 的地址(以便 G 执行完后能正常退出),然后通过 gostartcallfn 伪造栈帧,让 G 看起来像是刚调用了目标函数。
      • 设置状态: 将 G 的状态通过 CAS 操作设置为 _Grunnable
      • 分配 ID: 为 G 分配唯一的 goid
    4. 放入队列: 调用 runqput 将新创建的 G 放入当前 P 的本地运行队列。如果本地队列满了,会放入全局运行队列。
    5. 唤醒 P: 如果有空闲的 P,调用 wakep 尝试唤醒一个 P 来分担工作。
  1. 场景:当一个 P 的 LRQ 为空时,其绑定的 M 并没有任务可做。

  2. 机制:M 不会立即休眠。它会先尝试从 GRQ 中获取 G。如果 GRQ 也为空,它会变成一个“窃取者”,随机选择另一个 P,并尝试从那个 P 的 LRQ “窃取”一半的 G 到自己的 LRQ 中来执行。

  3. 目的:这是 GMP 实现自动负载均衡的关键。它能确保任务被均匀地分配到所有 P 上,避免了某些核心空闲而另一些核心过载的情况。

这是 GMP 模型的一个亮点,它解决了 M:N 调度模型中一个经典的难题。

  1. 场景:一个在 M 上运行的 G 执行了一个阻塞型系统调用(例如,同步的文件读写)。

  2. 机制

    • M 即将进入内核态并被阻塞。

    • 在阻塞前,Go 运行时会将这个 M 与其绑定的 P 分离

    • 运行时会寻找一个空闲的 M(或创建一个新的 M)来接管这个 P。

    • 这个新的 M 会继续执行 P 的 LRQ 中的其他 G。

  3. 结果:即使一个 M 因为系统调用而阻塞,它所关联的 P 上的计算资源也不会被浪费,程序的并行度得以保持。

  4. 返回后:当系统调用完成后,原来的 M 会被唤醒。它会尝试获取一个空闲的 P 来继续执行它的 G,如果获取不到,这个 G 会被放入 GRQ,M 可能会进入休眠。

  5. Netpoller: 对于网络 I/O,Go 运行时通过集成的网络轮询器 (netpoller) 将大部分网络操作转换为非阻塞的,从而从根本上避免了 M 因网络 I/O 而陷入上述的阻塞流程。

Go 对系统调用进行了封装,以优化调度。主要涉及 entersyscallexitsyscall

  • 进入系统调用 (entersyscall):

    1. 保存现场: 保存当前的 SP 和 PC 到 g.sched
    2. 修改状态: 将 G 的状态从 _Grunning 修改为 _Gsyscall
    3. 解绑 P: 将 M 与 P “解绑”。此时 P 进入 _Psyscall 状态。P 并没有完全释放,而是处于一种“半释放”状态。如果系统调用很快返回,M 可以快速重新获取这个 P。
    4. Sysmon 监控: 后台监控线程 sysmon 会扫描处于 _Psyscall 的 P。如果发现阻塞时间过长(默认 10ms),sysmon 会调用 retake 将 P 剥离(抢占),交给其他 M 使用。
  • 退出系统调用 (exitsyscall):

    1. 快速路径 (exitsyscallfast): M 尝试重新获取之前的 P (oldp)。如果成功,G 状态切回 _Grunning,继续执行。
    2. 尝试获取空闲 P: 如果旧 P 被抢走了,尝试从全局空闲 P 列表 (sched.pidle) 获取一个 P。
    3. 慢速路径: 如果获取不到 P,M 将 G 状态设为 _Grunnable,将 G 放入全局运行队列 (sched.runq)。
    4. M 休眠: M 调用 stopm 将自己放入空闲 M 列表并休眠,等待被唤醒。

如果一个 M 在尝试了所有方法(检查 LRQ、GRQ、工作窃取)后,仍然找不到可执行的 G,它会放弃其 P(将 P 放入空闲 P 列表),然后 M 自身进入休眠状态。当有新的工作需要处理时(例如,一个新 G 被创建或一个网络连接就绪),调度器会唤醒一个休眠的 M(或创建新的 M)。

M (Machine) 对应操作系统线程。Go 运行时尽量复用 M,避免频繁创建销毁线程。

  • M 的休眠 (stopm):

    • 当 M 没有工作可做(没有 P,且无法从全局队列或网络轮询器获取 G)时,会调用 stopm
    • 放入空闲列表: 将 M 放入全局空闲 M 列表 (sched.midle)。
    • 底层休眠: 调用 mPark,底层使用操作系统原语(如 Linux 的 futex 或 semaphore)挂起线程,不再占用 CPU。
  • M 的唤醒 (startm):

    • 当有新的 G 创建或者 P 处于空闲状态需要 M 来运行时,会调用 startm
    • 获取空闲 P: 如果没有指定 P,尝试从空闲 P 列表获取。
    • 获取空闲 M: 尝试从 sched.midle 获取一个休眠的 M。
    • 唤醒: 如果获取到 M,通过 notewakeup (底层信号机制) 唤醒该线程。该 M 醒来后会绑定 P 并开始调度循环。
    • 新建 M: 如果没有空闲 M,则调用 newm 创建一个新的系统线程。
  1. M 必须拥有 P 才能执行 G
  2. Mg0 栈上运行 schedule() 寻找 G
  3. M 找到 G 后,通过 execute() 切换到 G 的栈执行用户代码。
  4. G 执行完毕或阻塞,M 切换回 g0,重新运行 schedule()
  5. 如果 M 阻塞(如系统调用),P 会被剥离(Handoff),寻找新的 M 接管,保证 P 上的其他 G 能继续运行。

Go 程序的启动入口在汇编层面,最终会调用 runtime.schedinit

这是调度器的初始化函数:

  1. sched.maxmcount = 10000: 设置最大 M 数量(防止线程耗尽)。
  2. mcommoninit(gp.m, -1): 初始化当前的 M,这通常是 m0(主线程)。
  3. procresize(procs):
    • 根据 GOMAXPROCS 创建或调整 P 的列表 (allp)。
    • allp[0] 绑定到 m0 上,使主线程拥有执行能力。
    • 多余的 P 会被放入空闲列表 pidle

关键点

  • m0: 进程启动时的第一个线程,负责初始化和启动第一个 G。
  • g0: 每个 M 都有一个 g0,仅用于执行调度代码,不执行用户逻辑。

runqgrab 函数中可以看到:

n := t - h      // 计算队列中的 goroutine 数量
n = n - n/2     // 只偷取一半

偷取的是队列中 goroutine 总数的一半,而不是固定数量。

从代码可以看出偷取的是队列的前半部分(从 head 开始):

for i := uint32(0); i < n; i++ {
    g := pp.runq[(h+i)%uint32(len(pp.runq))]  // 从 head(h) 开始偷取
    batch[(batchHead+i)%uint32(len(batch))] = g
}
if atomic.CasRel(&pp.runqhead, h, h+n) { // 更新 head 指针
    return n
}
  1. 计算偷取数量n = (tail - head) - (tail - head)/2,即偷取队列中一半的 goroutine

  2. 偷取位置:从被偷取 P 的 runqhead 开始,连续偷取 n 个 goroutine

  3. 原子操作:使用 CAS 操作更新被偷取 P 的 runqhead 指针

  4. 备选策略:如果 runq 为空,还会尝试偷取 runnext 中的 goroutine

偷取前半部分(head 端)而不是后半部分的原因:

  1. FIFO 特性:Go 的本地队列基本按照 FIFO 顺序执行,head 端的 goroutine 通常等待时间更长
  2. 缓存局部性:较老的 goroutine 可能已经不在 CPU 缓存中,偷取到其他 P 上执行影响较小
  3. 减少竞争:生产者(当前 P)主要在 tail 端操作,偷取者从 head 端操作,减少竞争

假设一个 P 的本地队列有 8 个 goroutine:

[G1, G2, G3, G4, G5, G6, G7, G8]
 ↑head                    ↑tail

Work Stealing 会偷取前 4 个:

  • 偷取的:[G1, G2, G3, G4]
  • 剩余的:[G5, G6, G7, G8]

这种设计既保证了负载均衡,又最大化了系统的并发性能。Work Stealing 会偷取前 4 个:

  • 偷取的:[G1, G2, G3, G4]
  • 剩余的:[G5, G6, G7, G8]

  • P 数量:固定为 GOMAXPROCS,只在显式调用 runtime.GOMAXPROCS() 时改变, 需要注意的是1.25之前容器中的golang无法感知Cgroup分配的核数,会使用宿主机的执行核心数量
  • M 数量:动态变化,按需创建,主要在以下情况新增:
    1. 有可运行的 goroutine 但没有可用的 M
    2. M 因系统调用或其他原因被阻塞
    3. 需要更多并行度来处理工作负载

P 的数量 = GOMAXPROCS

先环境变量后读执行核心

  1. 初始化时确定

    • schedinit() 函数中,通过以下优先级确定:
      if n, ok := strconv.Atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
          procs = n  // 环境变量 GOMAXPROCS
      } else {
          procs = defaultGOMAXPROCS(numCPUStartup)  // 默认为 CPU 核心数
      }
  2. 运行时调整

    • 通过 runtime.GOMAXPROCS(n) 函数动态调整
    • 会调用 procresize(nprocs) 重新调整 P 的数量

M 的数量是动态的,没有固定上限(除了 sched.maxmcount = 10000 的安全上限)

  1. 初始时:只有一个 M0(主线程)
  2. 按需创建:根据工作负载动态创建新的 M
  1. 没有空闲的 M 可用时

    // 在 startm() 函数中
    nmp := mget()  // 尝试获取空闲的 M
    if nmp == nil {
        // 没有可用的 M,需要创建新的
        newm(fn, pp, id)
    }
  2. 主要触发场景

    a) 有新的 Goroutine 需要执行但没有足够的 M

    • wakep() 被调用时(新建 goroutine 或 goroutine 变为可运行状态)
    • 如果没有 spinning 的 M,会创建新的 M

    b) 系统调用阻塞

    • 当 M 执行系统调用时,会释放 P
    • 如果有其他可运行的 goroutine 但没有可用的 M,会创建新的 M

    c) 网络 I/O 或其他阻塞操作

    • M 被阻塞时,调度器需要新的 M 来执行其他 goroutine
// 在 startm() 中的关键逻辑
func startm(pp *p, spinning, lockheld bool) {
    nmp := mget()  // 尝试从空闲池获取 M
    if nmp == nil {
        // 没有空闲 M,创建新的
        id := mReserveID()
        newm(fn, pp, id)  // 创建新 M
        return
    }
    // 唤醒已有的空闲 M
    notewakeup(&nmp.park)
}

P 的数量变化只发生在 GOMAXPROCS 改变时

  1. 启动时schedinit() 中调用 procresize(procs)
  2. 运行时runtime.GOMAXPROCS(n) 调用时
func procresize(nprocs int32) *p {
    old := gomaxprocs
    
    // 如果需要更多 P
    if nprocs > int32(len(allp)) {
        // 扩展 allp 切片
        nallp := make([]*p, nprocs)
        copy(nallp, allp[:cap(allp)])
        allp = nallp
    }
    
    // 初始化新的 P
    for i := old; i < nprocs; i++ {
        pp := allp[i]
        if pp == nil {
            pp = new(p)  // 创建新的 P
        }
        pp.init(i)
        allp[i] = pp
    }
    
    // 如果减少 P,销毁多余的 P
    for i := nprocs; i < old; i++ {
        pp := allp[i]
        pp.destroy()
    }
}

  • g0:每个 M 的系统栈 goroutine,用于执行 runtime 底层操作
  • m0:程序的主线程 M,是程序启动的入口点

g0 是每个 M 的调度栈 goroutine

  1. 定义

    type m struct {
        g0      *g     // goroutine with scheduling stack
        // ... 其他字段
    }
  2. 作用

    • 系统栈(System Stack):g0 提供了一个固定大小的栈,用于执行 runtime 代码
    • 调度栈(Scheduling Stack):所有调度相关的操作都在 g0 栈上执行
    • Runtime 操作栈:垃圾回收、内存分配、系统调用等底层操作都在 g0 栈上进行
  3. 特征

    • 固定大小:通常为 8KB(在 64 位系统上)
    • 不会增长:与普通 goroutine 的栈不同,g0 栈大小固定,不会动态增长
    • 系统级:用于执行 runtime 系统级代码,不运行用户代码
  4. 使用场景

    • 调度器切换 goroutine 时
    • 执行 systemstack() 调用时
    • 垃圾回收操作时
    • 内存分配和释放时
    • 系统调用的准备和清理工作
特性 g0 普通 goroutine
栈大小 固定(通常 8KB) 动态增长(初始 2KB)
用途 执行 runtime 代码 执行用户代码
调度 不被调度 被调度器管理
栈增长 不会增长 会动态增长
数量 每个 M 一个 可以有数百万个
  1. 调度流程

    • 用户 goroutine 执行用户代码
    • 需要调度时切换到 g0 栈
    • 在 g0 栈上执行调度逻辑
    • 选择新的 goroutine 后切换回用户栈
  2. 内存管理

    • 垃圾回收标记和清扫在 g0 栈上进行
    • 内存分配的底层操作在 g0 栈上执行

m0 是程序的主线程对应的 M,它是特殊的:

  1. 定义

    var (
        m0           m        // 主线程 M
        g0           g        // 主线程的 g0
        // ...
    )
  2. 特殊性

    • 主线程:对应操作系统的主线程
    • 启动入口:Go 程序从 m0 开始执行
    • 固定存在:m0 在程序整个生命周期中都存在
    • 特殊处理:在某些操作中需要特殊处理,如信号处理
  3. 初始化过程

    func main() {
        mp := getg().m
        if mp != &m0 {
            throw("runtime.main not on m0")  // 确保在 m0 上运行
        }
        // ... 初始化代码
    }
  4. 职责

    • 执行 runtime.main 函数
    • 启动系统监控goroutine(sysmon)
    • 处理程序的初始化工作