go 协程调度
最开始加一个goroutine调度的演示,意思一下:
https://toolkit.whysonil.dev/tools/simulators/goroutine-scheduler
线程、协程和goroutine
线程
线程是内核(os)调度的基本单元,是内核可见的。
线程拥有自己的堆栈和寄存器。
线程切换需要陷入内核态,因此较其他两种都更重。
协程
协程可以看作用户级线程,是程序负责调度。内核不可见。
往往是协作式的,即协程自己手动让出资源。
切换不需要陷入内核态,轻量一些。
goroutine
是go的协程实现。相比于传统协程,实现的是抢占式调度,由调度器控制运行。
GMP及其调度
go的协程调度系统,或者说GMP模型,其中有三个组成部分,即G、M、P。
G(Goroutine)
G就是goroutine,是go的协程实现。是用户级的调度单元。
在go runtime执行main函数的时候会创建一个g,每当执行到go语句时会创建新的g,直到函数执行完或者panic,该g会消失。
M(machine)
代表操作系统线程,是执行goroutine的载体。
初始化时会被创建第一个M,之后M的数量时动态变化的。
两种情况下M会增加或者被唤醒:
- 当G被系统调用阻塞时,P会与M解绑,会尝试唤醒或创建一个新的M;
- 当拥有runnable 的G的P大于M时,会尝试唤醒或创建一个新的M;
当与M绑定的P
P(processor)
处理器,存在G的本地队列,用于调度G到M上进行执行。
P的数量由runtime.MAXPROCS()决定,默认等于CPU的数量。
在初始化时,会创建特定数量的P,P的数量不会动态变化。
调度抽象
实际上之前的定义已经解释的比较清晰了,接下来讲一下P的调度逻辑:
P实际上一直在不断重复获取一个runnable的G,然后寻找M进行绑定并执行的循环。
本地队列和全局队列
P存在本地队列,当G生成时,他会进入生成他的P的本地队列;如果本地队列已满,则会将一半的G移动到全局队列。当P的本地队列为空时,优先去全局队列取一部分并开始运行。
working steal
当p的本地队列为空并且全局队列也没有的时候,P会随机选取其他的P并偷取一半的G到自己本地队列来执行。
抢占策略
在协程中,协程需要主动让出cpu,才能让下一个协程进行执行。golang中,runtime存在一个监控线程sysmon来跟踪goroutine的运行时间,如果goroutine运行超过一个阈值,就执行抢占动作,将该G的状态变为runnable,并放到全局队列中。
- sysmon 监控 goroutine 运行时间
- 如果某个 goroutine 运行太久(比如 ~10 ms)发出抢占信号
- 当前运行的 M/调度器捕获到抢占请求
- 正在执行的 goroutine 被标记为 可抢占
- 调度器将这个 goroutine 暂停并放到 全局 run queue
- 当前 P 再从本地队列或其它来源取下一个 goroutine 执行
这个过程和linux的信号机制有关,这里虽然我也查了一下怎么回事,但是不在这里多说了。
阻塞处理策略
golang将阻塞分为三部分:用户态阻塞、网络IO和系统调用。
需要强调的是,由于P的本地队列,还有之前说的全局队列都是可运行的G的队列,所以这些阻塞的G阻塞后都不在原来队列里了。
用户态阻塞
这种阻塞说的是chan阻塞、block或者sleep等,他并没有调用真正的系统阻塞调用
这种情况下,runtime会将该P设置为waiting状态,并从P中踢出去。
网络IO
这一部分我还没看懂💦,之后回来补
——2026.02.05
系统调用
系统调用时线程也被阻塞,这时:
-
runtime将P与M解绑,释放P
-
尝试让另一个M与P结合,并运行P本地队列里的G
-
当系统调用执行完成后,该G会被重新runnable并加入队列(原来的队列,如果满了会加入全局队列)