前言 : 协程是go的一大特色 ,也是go为什么那么快的原因之一 . 哪GO的调度器是如何演变的? 为什么让Go协程如此的高效呢 ?
1. Golang 调度器的由来
1.1. 传统的单进程
我早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是"单进程时代"
一切的程序只能串行发生。

面临2个问题:
- 单一的执行流程,计算机只能一个任务一个任务处理。
- 进程阻塞所带来的CPU时间浪费。
后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。
1.2. 传统的多进程/线程

在多进程/多线程的操作系统中,解决了阻塞的问题,因为一个进程阻塞cpu可以立刻切换到其他进程中去执行,而且调度cpu的算法可以保证在运行的进程都可以被分配到cpu的运行时间片。这 Z 样从宏观来看,似乎多个进程是在同时被运行。
但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间 ,CPU虽然利用起来了,但如果进程过多,要考虑很多同步竞争等问题,如锁、竞争冲突 等。 CPU 利用率不高

1.3. 协程来提高CPU利用率
多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存(进程虚拟内存会占用4GB[32位操作系统], 而线程也要大约4MB)。
工程师们就发现,其实一个线程分为"内核态"线程和"用户态"线程。
一个"用户态线程"必须要绑定一个"内核态线程",但是CPU并不知道有"用户态线程"的存在,它只知道它运行的是一个"内核态线程"(Linux的PCB进程控制块)。

这样,我们再去细化去分类一下,内核线程依然叫"线程(thread)",用户线程叫"协程(co-routine)".

通过协程调度器解决 , 协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。
1.4. 早期的 goroutine调度器
下面的 G 为协程 , M 为线程

M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
老调度器有几个缺点:
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
- M转移G会造成延迟和额外的系统负载 。比如当G中包含创建新协程的时候,M创建了G',为了继续执行G,需要把G'交给M'执行,也造成了很差的局部性,因为G'和G是相关的,最好放在M上执行,而不是其他M'。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
1.5. GMP 模型下的性能
- 上下文切换耗时:Goroutine切换约200ns(仅用户态),线程切换约1-2μs(涉及内核态)
- 内存占用对比:单线程维护百万级Goroutine仅需2GB内存,同量级线程需要TB级内存
- 创建效率:Goroutine创建耗时约300ns,线程创建需要微秒级
2. GMP 模型
2.1. 模型的理解

2.1.1. G(Goroutine):
- 协程,通过 go 关键字创建,是 Go 语言中的轻量级执行单位
- 每个 goroutine 初始分配约 2KB 栈空间,可按需扩容/缩容
- 包含执行上下文(PC/SP等寄存器值)、栈、状态(运行/就绪/阻塞等)
2.1.2. M(Machine):
- 对应操作系统线程,由操作系统调度
- 每个 m 包含:
一个特殊的调度协程 g0(负责调度逻辑,64位系统默认分配 8MB 栈)
处理信号的协程 gsignal
- 在没有用户 goroutine 可执行时,m 会运行 g0 进行调度
- 同一时间只能运行一个 goroutine
2.1.3. P(Processor):
- 逻辑处理器,数量默认等于 GOMAXPROCS(默认为 CPU 核数)
- 核心调度组件,管理本地运行队列(runqueue,通常容量 256)
- 优化机制:
当本地队列满时,会将半数 goroutine 转移到全局队列(避免全局队列锁竞争)
执行时会优先从本地队列获取,其次全局队列,最后通过 work-stealing 从其他 P 窃取
2.1.4. M0
M0
是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0
中,不需要在 heap
上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0
就和其他的 M 一样了。
2.1.5. G0
G0
是每次启动一个 M 都会第一个创建的 gourtine
,G0
仅用于负责调度的G ,G0
不指向任何可执行的函数, 每个M都会有一个自己的 G0
。在调度或系统调用时会使用G0的栈空间, 全局变量的 G0
是 M0
的 G0。
2.1.6. 队列
- 全局队列(Global Queue):存放等待运行的G。
- P的本地队列 :同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
2.2. 调度器的设计策略
- 复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
- work stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
- hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
- 自适应的P数量 :
GOMAXPROCS
设置P的数量,默认P数量等于CPU核心数,但可通过GOMAXPROCS
动态调整。在IO密集型场景可适当增加 P 数量
- 抢占 :在
coroutine(普通协程)
中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。
- 本地队列无锁化 :每个 P 维护的本地队列采用无锁环形队列结构 ,配合原子操作实现高性能入队/出队,避免传统线程池的全局队列锁竞争
2.3. goroutine 调度流程

-
- 我们通过 go func()来创建一个goroutine;
- 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
- G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;
- 一个M调度G执行的过程是一个循环机制;
- 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
- 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。
参考视频 : B站刘丹冰老师