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站刘丹冰老师

相关推荐
sufu106511 分钟前
SpringAI更新:废弃tools方法、正式支持DeepSeek!
人工智能·后端
嘵奇27 分钟前
Spring Boot拦截器详解:原理、实现与应用场景
java·spring boot·后端
八股文领域大手子28 分钟前
Java死锁排查:线上救火实战指南
java·开发语言·面试
XQ丶YTY1 小时前
大二java第一面小厂(挂)
java·开发语言·笔记·学习·面试
秋野酱2 小时前
基于javaweb的SpringBoot自习室预约系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
weloveut3 小时前
西门子WinCC Unified PC的GraphQL使用手册
后端·python·graphql
面试官E先生3 小时前
【极兔快递Java社招】一面复盘|数据库+线程池+AQS+中间件面面俱到
java·面试
蒂法就是我4 小时前
详细说说Spring的IOC机制
java·后端·spring
秋野酱5 小时前
基于javaweb的SpringBoot高校图书馆座位预约系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
HWL56795 小时前
Express项目解决跨域问题
前端·后端·中间件·node.js·express