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会增加或者被唤醒:

  1. 当G被系统调用阻塞时,P会与M解绑,会尝试唤醒或创建一个新的M;
  2. 当拥有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,并放到全局队列中。

  1. sysmon 监控 goroutine 运行时间
  2. 如果某个 goroutine 运行太久(比如 ~10 ms)发出抢占信号
  3. 当前运行的 M/调度器捕获到抢占请求
  4. 正在执行的 goroutine 被标记为 可抢占
  5. 调度器将这个 goroutine 暂停并放到 全局 run queue
  6. 当前 P 再从本地队列或其它来源取下一个 goroutine 执行

这个过程和linux的信号机制有关,这里虽然我也查了一下怎么回事,但是不在这里多说了。

阻塞处理策略

golang将阻塞分为三部分:用户态阻塞、网络IO和系统调用。

需要强调的是,由于P的本地队列,还有之前说的全局队列都是可运行的G的队列,所以这些阻塞的G阻塞后都不在原来队列里了。

用户态阻塞

这种阻塞说的是chan阻塞、block或者sleep等,他并没有调用真正的系统阻塞调用

这种情况下,runtime会将该P设置为waiting状态,并从P中踢出去。

网络IO

这一部分我还没看懂💦,之后回来补

——2026.02.05

系统调用

系统调用时线程也被阻塞,这时:

  1. runtime将P与M解绑,释放P

  2. 尝试让另一个M与P结合,并运行P本地队列里的G

  3. 当系统调用执行完成后,该G会被重新runnable并加入队列(原来的队列,如果满了会加入全局队列)