go语言协程调度器 GPM 模型

go语言协程调度器 GPM 模型

下面的文章将以几个问题展开,其中可能会有扩展处:

  1. 什么是调度器?为什么需要调度器?

  2. 多进程/多线程时cpu怎么工作?

  3. 进程/线程的数量多多少?太多行不行?为什么不行?那怎么解决?

  4. 什么是协程?协程和线程/进程的区别?协程加入的作用?为什么会有这样的作用?

  5. GPM模型的结构?怎么设计的?各部分的作用?各部分间怎么协作?

调度器由来

单进程时代,所有程序几乎都是阻塞的。只能一个任务一个任务执行。那么在计算机处理流程中会有多个硬件的支持和处理,cpu、cache、主内存、磁盘、网络等。比如:但是当任务执行到磁盘时,需要加载磁盘数据,此时流程阻塞,导致cpu处于等待状态,那么这对于cpu来说就是资源浪费。理应让cpu在这时去处理其他任务。又因为单进程下多任务也会阻塞,由此出现了多进程/多线程。为了极大发挥cpu等资源。我们需要有一个监听通知机制或者说算法,监听cpu状态并告知cpu执行哪一个任务。

多进程/多线程时cpu工作方式

为了实现在宏观角度上多个进程/线程一起执行的目标,需要一个调度器,通过分时的机制,在不同的时间轴上执行不同的进程/线程。

多进程/线程的烦恼

假设在linux系统下,linux对待进程和线程是一样的。

假设,一个程序提供了一个服务。当并发量很小时,我们创建了较多的进程和线程,我们发现:整体的处理响应速度提高、应对并发量的阈值提高。当并发量很大时,我们创建了大量的进程和线程,此时我们发现:这个整体的响应反而比之前的慢,那按道理应该是相同的处理响应时间。

这是为何呢?

这是因为进程和线程的大量创建,cpu的资源大量用到了进程/线程的创建、进程/线程间切换、进程/线程销毁等与业务无关的操作。使得真正用到业务的cpu资源减少。还有内存的高占用,导致整体性能下降。

那么怎么解决呢?

这时出现了协程。

协程

协程其实是一种"用户态"的线程。(之后我们把"线程"都看作"内核级线程"),协程必须绑定线程才可以正常运行。那怎么绑定?方式上?数量上?

绑定方式和数量

N : 1 关系

N个协程由一个协程调度器调度,和一个线程绑定

缺点:

一个协程阻塞,整个线程也就阻塞了

1 : 1 关系

协程和线程 1 : 1 绑定,协程的调度也由cpu完成

缺点:

cpu又负责协程的创建、切换、销毁,增加了cpu的负担

M : N 关系

克服以上的问题。用户态调度器负责协程的创建,协程阻塞会主动让出线程,使得有新的协程可以和线程绑定,执行其他任务。

综上,那么在 M : N 的关系中,怎么实现一个协程调度器是至关重要的,因为他会基于协作式的调度策略负责与线程的解绑定,影响执行效率

在介绍完整个的调度器、协程后,我们来认识 go 语言中的协程和调度器

Go协程

go协程基于协程的思想,是一种用户级线程。由 runtime 调度,初始占用极小,但是可以动态的扩容。

扩展:

runtime 是 Go 语言的核心运行时环境,负责管理内存分配、垃圾回收(GC)、协程调度、系统调用等底层操作。其中,协程调度器 是 runtime 的关键组件之一,负责 Goroutine 的创建、销毁和调度。

GPM 模型

首先,需要明白的是:gpm模型是go语言实现的一种用户空间的协程调度器,是 runtime包 的核心组件之一

GPM 模型的成员

G:goroutine协程,用户空间。协程实体,保存执行上下文(栈、PC 指针等),初始栈 2KB,动态扩缩容

P:processor处理器,用户空间。是 Go 运行时在用户空间抽象出的调度上下文,负责承载 Goroutine 队列和执行环境,数量由 GOMAXPROCS 控制

M:(machine)thread线程,内核空间。实际执行代码的内核线程,必须绑定 P 才能运行 G(实际上,这里就是将之前的 "协程→线程" 直接绑定关系,抽象为 "协程→P→线程" 的间接绑定)

扩展:相比于之前直接绑定关系,这样的间接绑定的好处是什么?

一、抽象层级的对比
模型 绑定关系 调度灵活性 资源利用率
传统 N:1 协程 → 单线程 极低 单核利用
传统 1:1 协程 → 专用线程 高(但开销大)
Go GPM(M:N) 协程 → P → 动态绑定线程 极高 高且开销低
二、引入 P 的核心优势
  1. 解耦协程与线程的强绑定
  • 传统模式问题

    在 1:1 模式下,协程阻塞会导致对应线程阻塞,即使系统中存在其他可运行的协程。

  • GPM 解决方案

    P 作为 "执行上下文",可在不同 M 间动态迁移。当 G 阻塞时,P 与当前 M 解绑,转移到其他空闲 M 继续执行队列中的 G。

    go 复制代码
    // 示例:当 G1 执行阻塞操作时
    go func() { // G1
        resp, _ := http.Get("https://example.com") // 阻塞调用
        // ...
    }()
    
    go func() { // G2
        // 即使 G1 阻塞,P 可调度 G2 在其他 M 上执行
    }()
  1. 减少锁竞争,提升并发性能
  • 全局队列瓶颈

    早期 Go 版本(<=1.0)仅使用全局运行队列,所有 M 竞争同一个队列,锁冲突严重。

  • P 的本地队列

    每个 P 维护自己的本地队列(LRQ),M 优先从本地队列获取 G,大幅减少锁争用。

    • 工作窃取:当本地队列空时,M 从其他 P 的队列 "偷取" G,负载均衡更高效。
  1. 优化系统调用处理
  • 非阻塞系统调用

    通过 netpoller(基于 epoll/kqueue)实现 IO 多路复用,M 无需阻塞等待,可继续执行其他 G。

  • 阻塞系统调用

    当 G 执行阻塞调用时,M 释放 P,允许其他 M 接管 P 继续工作。调用完成后,G 重新加入某个 P 的队列。

    go 复制代码
    // 底层逻辑简化示意
    func syscallRead(fd int) {
        g := getg()
        g.m.p.ptr().syscallentering(g) // P 准备进入系统调用
        // 执行内核调用...
        g.m.p.ptr().syscallexiting(g)  // P 退出系统调用,重新分配
    }
  1. 控制并行度,避免过度并发
复制代码
  GOMAXPROCS

限制活跃 P 的数量,从而控制实际并行执行的协程数。

  • 对于 CPU 密集型任务,设置 GOMAXPROCS=CPU核数 可充分利用硬件资源。
  • 对于 IO 密集型任务,可设置更大的 GOMAXPROCS,但需权衡线程切换开销。
三、对比实验:P 的性能影响

以下是不同 GOMAXPROCS 设置下的性能测试(数据为示意):

GOMAXPROCS 吞吐量(req/s) 平均延迟(ms) 线程数
1 10,000 5 2-3
4 35,000 4 4-6
16 38,000 6 16-20
  • 结论
    • 增加 P 数量(≤CPU 核数)可提升并行度,但超过核数后收益递减,甚至因线程切换开销导致性能下降。
四、总结:P 的设计哲学

P 的引入本质是在用户空间实现了一个轻量级的虚拟 CPU 管理系统

  • 将调度决策(如 G 的选择、负载均衡)从内核转移到用户空间,减少内核干预;
  • 通过本地队列和工作窃取算法,最小化锁竞争(对全局队列来说);
  • 动态绑定机制使资源利用更高效,尤其适合高并发 IO 场景。

这种设计让 Go 既能支持百万级协程,又能高效利用多核 CPU,成为构建云原生应用的理想语言。

GPM 模型结构介绍

  1. 全局队列:负责存放等待运行的协程。协程来源:各自处理器下协程创建满了就拿一半放到全局队列。协程去处:处理器本地队列的协程不够了就从全局队列拿取。特点:全局资源,任何读写操作都是要互斥的(上锁)。
  2. P:处理器。对上负责调度协程,向下负责绑定线程。维护一个本地的协程队列,有利于细锁化。特点:协程偷取机制、动态绑定线程。
  3. M:负责从P中获取协程执行任务。触发P的偷取机制、从全局队列取协程动作

GMP 模型的调度策略的介绍

  1. 线程复用:比如在GPM模型中,当线程出现空闲或阻塞状态时分别会触发偷取机制移交机制。使得充分利用线程,避免大量创建和销毁线程。
  2. 并行:P 的数量决定了并行量。cpu核数决定了 P 的数量。推荐最大的 P = 核数/2
  3. 混合协程工作策略:抢占式 + 协作式。协作式:当goroutine出现阻塞,协程主动让出,P 解绑定,然后和其他空闲线程绑定。抢占式:一个go程最大运行时长为10ms(go1.14后新增),调度器通过 SIGURG 信号强制中断其执行,go程主动释放 P
  4. 全局G队列:本地队列为空,优先从全局队列取,如果没有则"偷取"。本地队列过多,向全局队列输送协程。

GPM 模型的调度器生命周期

在 Go 语言调度器的 GPM 模型中还有两个比较特殊的角色,它们分别是 M0 和 G0。

  1. M0
    • 启动程序后的编号为 0 的主线程。
    • 在全局命令 runtime.m0 中,不需要在 heap 堆上分配。
    • 负责执行初始化操作和启动第 1 个 G。
    • 启动第 1 个 G 后,M0 就和其他的 M 一样了。
  2. G0
    • 每次启动一个 M,创建的第 1 个 Goroutine 就是 G0。
    • G0 仅用于负责调度 G。
    • G0 不指向任何可执行的函数。
    • 每个 M 都会有一个自己的 G0。
    • 在调度或系统调度时,会使用 M 切换到 G0,再通过 G0 调度
    • M0 的 G0 会放在全局空间。
初始化阶段
  1. 创建最初的 M0 和 G0,并将二者关联。
  2. 初始化 M0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表
作用阶段
  1. runtime.main函数开始,(创建 Main Goroutine)调用 main.main 函数, 将 Main Goroutine 放到 P 的本地队列。
  2. 启动 M0 ,M0 从 P 中获取 Main Goroutine 。(由于 G 拥有栈,M 根据 G 的栈信息和调度信息设置运行环境)然后运行,最后(如果还有待执行的go程)继续从 P 队列获取go程执行,直到 Main Goroutine 结束。最后 runtime.main 执行 Defer 和 Panic 或者 runtime.exit 结束。

GPM 模型调度的场景举例

  1. go程1 创建 go程2,优先加入本地队列
  2. G0 和 G1的切换,当 M 上的 G1 执行完,自动切换回 G0
  3. 开辟过多 G ,拿出前一半的go程和新创建的go程放到全局队列
  4. 新创建的 go程 可以唤醒空闲的 mp 组合执行任务
  5. 当一个 mp 组合没有g执行时,p 就会调度 G0线程。此时 M、P、G0组合被称为 自旋线程
  6. 自旋线程寻找可执行的 G 优先从全局队列获取
  7. 自旋线程寻找可执行的 G 最后从其他队列偷取
  8. 阻塞线程和 P 解绑,然后 P 和其他 可运行的M绑定。
  9. 之前与 P 绑定的 M 非阻塞后,P 会尝试与 M 重新绑定。如果 P 正在和其他 M 绑定 或者 全局空闲 P 队列为空,那么 M 进入空闲线程队列,进入休眠(最后可能被 gc)
相关推荐
duapple16 分钟前
Golang基于反射的ioctl实现
开发语言·后端·golang
Dxy123931021640 分钟前
Python 条件语句详解
开发语言·python
prinrf('千寻)3 小时前
MyBatis-Plus 的 updateById 方法不更新 null 值属性的问题
java·开发语言·mybatis
m0_555762903 小时前
Qt缓动曲线详解
开发语言·qt
my_styles3 小时前
docker-compose部署项目(springboot服务)以及基础环境(mysql、redis等)ruoyi-ry
spring boot·redis·后端·mysql·spring cloud·docker·容器
飞川撸码4 小时前
【LeetCode 热题100】739:每日温度(详细解析)(Go语言版)
算法·leetcode·golang
揽你·入怀4 小时前
数据结构:ArrayList简单实现与常见操作实例详解
java·开发语言
AA-代码批发V哥4 小时前
Math工具类全面指南
java·开发语言·数学建模
Nobkins5 小时前
2021ICPC四川省赛个人补题ABDHKLM
开发语言·数据结构·c++·算法·图论
十八年的好汉5 小时前
buck变换器的simulink/matlab仿真和python参数设计
开发语言·matlab