Go语言底层(二) : GMP 模型

前言 : 协程是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队列是有互斥锁进行保护的。

老调度器有几个缺点:

  1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
  2. M转移G会造成延迟和额外的系统负载 。比如当G中包含创建新协程的时候,M创建了G',为了继续执行G,需要把G'交给M'执行,也造成了很差的局部性,因为G'和G是相关的,最好放在M上执行,而不是其他M'。
  3. 系统调用(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 都会第一个创建的 gourtineG0 仅用于负责调度的GG0 不指向任何可执行的函数, 每个M都会有一个自己的 G0。在调度或系统调用时会使用G0的栈空间, 全局变量的 G0M0 的 G0。

2.1.6. 队列

  1. 全局队列(Global Queue):存放等待运行的G。
  2. P的本地队列 :同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。

2.2. 调度器的设计策略

  1. 复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
  • work stealing机制

当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。

  • hand off机制

当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

  1. 自适应的P数量GOMAXPROCS设置P的数量,默认P数量等于CPU核心数,但可通过 GOMAXPROCS 动态调整。在IO密集型场景可适当增加 P 数量
  1. 抢占 :在 coroutine(普通协程) 中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。
  1. 本地队列无锁化 :每个 P 维护的本地队列采用无锁环形队列结构 ,配合原子操作实现高性能入队/出队,避免传统线程池的全局队列锁竞争

2.3. goroutine 调度流程

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

参考视频 : B站刘丹冰老师

相关推荐
{⌐■_■}8 分钟前
【go】slice的浅拷贝和深拷贝
开发语言·后端·golang
〆、风神1 小时前
Spring Boot 自定义 Redis Starter 开发指南(附动态 TTL 实现)
spring boot·redis·后端
Asthenia04121 小时前
HashMap 扩容机制与 Rehash 细节分析
后端
DataFunTalk1 小时前
不是劝退,但“BI”基础不佳就先“别搞”ChatBI了!
前端·后端
星星电灯猴1 小时前
flutter项目 发布Google Play
后端
疏桐1 小时前
并发
面试
用户9704438781161 小时前
按图搜索1688商品(拍立淘)API 返回值说明
javascript·后端·算法
Fly_hao.belief1 小时前
Spring Boot 框架注解:@ConfigurationProperties
java·spring boot·后端
代码吐槽菌1 小时前
基于SpringBoot的水产养殖系统【附源码】
java·数据库·spring boot·后端·毕业设计
尽一份心出一份力1 小时前
等不是办法,干才有希望,快速跑通graphRag
后端·机器学习·开源