下面的文章将以几个问题展开,其中可能会有扩展处:
什么是调度器?为什么需要调度器?
多进程/多线程时cpu怎么工作?
进程/线程的数量多多少?太多行不行?为什么不行?那怎么解决?
什么是协程?协程和线程/进程的区别?协程加入的作用?为什么会有这样的作用?
GPM模型的结构?怎么设计的?各部分的作用?各部分间怎么协作?
单进程时代,所有程序几乎都是阻塞的。只能一个任务一个任务执行。那么在计算机处理流程中会有多个硬件的支持和处理,cpu、cache、主内存、磁盘、网络等。比如:但是当任务执行到磁盘时,需要加载磁盘数据,此时流程阻塞,导致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协程基于协程的思想,是一种用户级线程。由 runtime 调度,初始占用极小,但是可以动态的扩容。
扩展:
runtime 是 Go 语言的核心运行时环境,负责管理内存分配、垃圾回收(GC)、协程调度、系统调用等底层操作。其中,协程调度器 是 runtime 的关键组件之一,负责 Goroutine 的创建、销毁和调度。
首先,需要明白的是:gpm模型是go语言实现的一种用户空间的协程调度器,是 runtime包 的核心组件之一
G:goroutine协程,用户空间。协程实体,保存执行上下文(栈、PC 指针等),初始栈 2KB,动态扩缩容
P:processor处理器,用户空间。是 Go 运行时在用户空间抽象出的调度上下文,负责承载 Goroutine 队列和执行环境,数量由 GOMAXPROCS
控制
M:(machine)thread线程,内核空间。实际执行代码的内核线程,必须绑定 P 才能运行 G(实际上,这里就是将之前的 “协程→线程” 直接绑定关系,抽象为 “协程→P→线程” 的间接绑定)
扩展:相比于之前直接绑定关系,这样的间接绑定的好处是什么?
模型 | 绑定关系 | 调度灵活性 | 资源利用率 |
---|---|---|---|
传统 N:1 | 协程 → 单线程 | 极低 | 单核利用 |
传统 1:1 | 协程 → 专用线程 | 高 | 高(但开销大) |
Go GPM(M:N) | 协程 → P → 动态绑定线程 | 极高 | 高且开销低 |
传统模式问题:
在 1:1 模式下,协程阻塞会导致对应线程阻塞,即使系统中存在其他可运行的协程。
GPM 解决方案
P 作为 “执行上下文”,可在不同 M 间动态迁移。当 G 阻塞时,P 与当前 M 解绑,转移到其他空闲 M 继续执行队列中的 G。
// 示例:当 G1 执行阻塞操作时
go func() { // G1
resp, _ := http.Get("https://example.com") // 阻塞调用
// ...
}()
go func() { // G2
// 即使 G1 阻塞,P 可调度 G2 在其他 M 上执行
}()
全局队列瓶颈:
早期 Go 版本(<=1.0)仅使用全局运行队列,所有 M 竞争同一个队列,锁冲突严重。
P 的本地队列
每个 P 维护自己的本地队列(LRQ),M 优先从本地队列获取 G,大幅减少锁争用。
非阻塞系统调用:
通过 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 退出系统调用,重新分配
}
GOMAXPROCS
限制活跃 P 的数量,从而控制实际并行执行的协程数。
GOMAXPROCS=CPU核数
可充分利用硬件资源。GOMAXPROCS
,但需权衡线程切换开销。以下是不同 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 管理系统:
这种设计让 Go 既能支持百万级协程,又能高效利用多核 CPU,成为构建云原生应用的理想语言。
偷取机制
和移交机制
。使得充分利用线程,避免大量创建和销毁线程。SIGURG
信号强制中断其执行,go程主动释放 P在 Go 语言调度器的 GPM 模型中还有两个比较特殊的角色,它们分别是 M0 和 G0。
在全局命令 runtime.m0 中,不需要在 heap 堆上分配。
G0 仅用于负责调度 G。
每个 M 都会有一个自己的 G0。
在调度或系统调度时,会使用 M 切换到 G0,再通过 G0 调度
。