当前位置: 首页 > news >正文

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。

    // 示例:当 G1 执行阻塞操作时
    go func() { // G1resp, _ := 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 的队列。

    // 底层逻辑简化示意
    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)线程数
110,00052-3
435,00044-6
1638,000616-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)

相关文章:

  • Vue-监听属性
  • 理想AI Talk第二季-重点信息总结
  • 【ROS2】RViz2源码分析(九):RosClientAbstraction和RosNodeAbstraction的关系
  • ngx_http_realip_module 模块概述
  • 【DeepSeek论文精读】11. 洞察 DeepSeek-V3:扩展挑战和对 AI 架构硬件的思考
  • c++多线程debug
  • 符合Python风格的对象(再谈向量类)
  • Spring Web MVC————入门(3)
  • Go语言--语法基础5--基本数据类型--类型转换
  • Vue 3 中使用 md-editor-v3 的完整实例markdown文本
  • 网络编程套接字(二)
  • 高并发内存池|二、Common
  • 【JavaWeb】JDBC
  • 如何利用内网穿透实现Cursor对私有化部署大模型的跨网络访问实践
  • java中sleep()和wait()暂停线程的区别
  • [Java实战]Spring Boot整合Elasticsearch(二十六)
  • 大模型微调步骤整理
  • 第9章 组件及事件处理
  • Mac 在恢复模式下出现 旋转地球图标 但进度非常缓慢
  • Oracle 内存优化
  • LPR名副其实吗?如果有所偏离又该如何调整?
  • 中国田径巡回赛西安站完赛:男子跳远石雨豪夺冠
  • 从良渚到三星堆:一江水串起了5000年的文明对话
  • 美国考虑让移民上真人秀竞逐公民权,制片人称非现实版《饥饿游戏》
  • 国际金价下跌,中概股多数上涨,穆迪下调美国主权信用评级
  • 多个“首次”!上市公司重大资产重组新规落地