GMP模型
Go 调度器用 G/M/P 模型。G 是 goroutineM 是 OS 线程P 是执行 Go 代码所需的调度上下文。M 必须绑定 P 才能运行 G。P 有本地队列减少全局锁竞争找不到 G 时会从全局队列、netpoller 或其他 P 偷取。G 阻塞在 channel/锁时M/P 可以去执行其他 GG 阻塞在 系统调用 时M 和 G 一起进入 系统调用P 会被释放给其他 Mruntime 通过 sysmon 和抢占机制避免 G 长时间占用 P。GOMAXPROCS 决定 P 数量也就是 Go 代码最大并行度。为什么引入协程一句话进程线程太重了需要用户态和内核态来回切换协程是用户态切换更加轻量理解GMP原理要先知道G、M、P是什么东西它们作用是啥G 是任务M 是线程P 是调度执行 Go 代码所需的“令牌/上下文”。M线程G协程代表任务P代表逻辑处理器或者说调度上下文。P 不是 CPU也不是线程。它更像是 Go runtime 里的“执行许可证 调度缓存”。为什么 P 里有 G 队列因为 Go 调度器不想所有 goroutine 都去抢一个全局队列。那样锁竞争会很重。所以每个 P 都有自己的本地 run queue。这样绑定到这个 P 的 M 要找活干时优先从自己的 P 本地队列取 G速度快、锁少。GOMAXPROCS 控制 P 的数量P 的数量决定同一时刻最多有多少个 M 并行执行 Go 代码不是控制 goroutine 数量也不是严格控制线程总数。M 数量可以大于 P比如系统调用阻塞时可能创建更多 M。一个G要执行需要怎么办M 先 绑定 PP中有G的调度上下文然后M再拿到 GM执行G举例子M1 绑定 P1运行 G1M2 绑定 P2运行 G2M3 没有 P只能阻塞/休眠不能执行 Go 代码没有 P 的 M 不能执行普通 Go 代码没有 M 的 P 也不能真正运行G 只有被 M 在某个 P 上调度到才会执行。一个 M 要执行 Go 代码流程优先本地P中获取G1. M 绑定一个 P2. M 从 P 的本地队列取一个 G本地队列 无锁3. 执行这个 G4. G 阻塞/让出/结束后M 再从 P 找下一个 Gwork-stealing机制M获取G如果 P 本地队列没有 G才会去其他地方偷G1.全局队列拿一批 G2. 找网络轮询器里的就绪 G3.从其他 P偷一半 Ghand-off机制场景M绑定了P中的G1执行G1如果G1执行了阻塞动作。此时执行“切换机制”目的因为M绑定的P上还有其他的G要将这个P让给其他的M继续执行M绑定的G1阻塞了就只让M绑定G1只让M等待这个G1流程分情况G 阻塞在 channel/锁时M继续持有P继续执行P中其他的G被阻塞的G从P中移除挂到阻塞对象的等待队列G 阻塞在 系统调用 时M先将绑定的P解绑这个P会过度给其他的M继续执行M绑定阻塞的G1会一直等待G1执行完被唤醒视频链接1.为什么引入协程一句话进程线程太重了需要用户态和内核态来回切换协程是用户态切换更加轻量线程进程模型的弊端为了解决多线程多进程频繁切换导致的CPU浪费多线程随着同步竞争锁、竞争资源冲突导致性能下降占用内存进程4GB、线程4MB协程的优点协程是用户态实现的不需要经过内核态和用户态之间的切换更加轻量一个goroutine几KB灵活调度切换成本低2.早期的Go调度器全局go协程队列存放着M个协程g有N个线程去全局go协程队列获取G执行每次获取都需要加全局锁锁竞争3.GMP模型简介① M每次都先去获取P ② P再去获取G一个线程M想执行协程GM就要先去「空闲P队列」获取P然后P和M绑定之后P再依次去「本地协程队列、全局协程队列」获取G将G交给线程M去执行G协程Mthread线程内核线程有一个M阻塞会先从空闲M队列获取新的M若没有再去创建一个新的M如果有M空闲那么就会回收or放回空闲M队列Pprocessor处理器每个P具有自己的协程本地队列P管理了协程队列中的GP的本地队列存放等待运行的G优先将新创建的G存放在P的本地队列本地队列满了才会放到全局队列除了P的本地队列还有一个全局队列数量M的数量GO语言本身限定M的最大量是1w一般设置为核心数runtime/debug包中的SetMaxThreads函数来设置P的数量问题环境变量$GOMAXPROC一般设置为 内核线程数/2在程序中通过runtime.GOMAXPROCS()来设置G的数量问题4.调度器的设计策略复用线程、利用并行、抢占、全局G队列4.1. work-stealing机制概述场景当本线程⽆可运⾏的G时尝试「从其他线程绑定的P偷取G」获取的流程从本地队列获取任务从全局队列获取任务从其它M的本地队列窃取任务case1从全局队列中steal协程G此时M2内核线程绑定的P没有协程G了但是全局队列中有空闲的GM2去全局队列中steal协程G3存放在自己的P中case2从其他P中steal协程G1. M1和P绑定G1正在运行M2线程是空闲的2. 此时M2想执行那么将会从M1绑定的P的本地队列中steal协程4.2. hand-off机制切换机制概述场景当本线程因为G进⾏系统调⽤阻塞时线程M释放绑定的P把P转移给其他空闲的线程执⾏流程当G阻塞时与该G绑定的M也会陷入阻塞在阻塞之前会先把M绑定的P转移给其他M然后将CPU切换到M去执行1. 假设此时M1绑定的P队列中正在执行的协程G1G1执行了一个阻塞操作比如read。2. hand-off执行过程2.1. 首先创建一个线程or唤醒一个睡眠状态的线程如M32.2. 将M1绑定的P迁移到M3上2.3. 将G1与M1进行绑定此时① M1阻塞等待read事件的返回② 内核线程切换到M3通过P去获取本地队列中的G2继续执行这样就完成了hand-off机制5. Go指令的调度流程1~2步骤执行go func()先创建一个G优先放入P的本地队列如果满了放入全局队列此时P已经存放到P的本地队列3步骤此时M获取G优先从M的本地队列P中获取G如果为空依次去全局队列、其他M的本地队列P去偷取G。当获取P成功后将P与M进行绑定4~6步骤之后M1调度协程G执行G的func()函数备注每个G的运行时间不超过10ms防止其他G被饿死此时G执行执行分为以下情况case1G的执行时间片超时即执行时间大于10msG会重新放到M1绑定的本地队列P中case2func函数执行了systemcall\阻塞如read、write则会获取新的M从休眠M空闲队列or创建一个M若此时M1的P队列还有很多G等待执行因为M在执行G1时调用了systemcall\阻塞操作所以M1的P队列将交给新的M接管hand-off机制执行完后的效果①M1和G1捆绑 ②M3接管了M1的P之后与M1绑定的G1因为处于阻塞状态所以下一步会解除绑定关系此时①M1销毁或者存放回休眠队列M中 ②G1放回全局队列中6. 调度器的生命周期M01. 启动程序后编号为0的主线程2. 在全局变量runtime.m0中不需要在heap上分配3. 负责执行初始化和启动第一个G4. 执行第一个G之后M0就和其他的M一样了G01. 每次启动一个M都会第一个创建的G2. G0仅用于负责调度G3. G0不指向任何可执行的函数4. 每一个M都会有一个自己的G05. 在调度或系统调用时就会使用M会切换到G0来调度6. M0的G0会放在全局空间7. 场景分析7.1.场景1G1创建G3此时存在M1、M2每个M绑定了PP上分别有一个G此时G1创建了G3满足局部性即G1创建的G3应该存放在M1和G1所在的P上如下图所示7.2.场景2G1执行完毕当M1绑定的G1执行goexit()G1执行完毕M1继续获取G优先从本地的P获取G7.3.场景3-4-5场景3G2开辟过多的G场景4G2本地满再创建G71. 将本地队列P拆分成2段2. 将前一段和G7打散再存放在本地队列中场景5G2本地未满创建G8直接将G8放到本地队列中7.4.场景6唤醒正在休眠的MM1与P1绑定P1获取了G此时G2创建了G8G2创建一个协程G8的时候1. 首先尝试去休眠线程队列中唤醒一个休眠的线程2. 唤醒之后将M从休眠线程队列中取出来3. 此时被唤醒的M2将尝试与新的P绑定一旦M2绑定了空闲的P此时会调用G0自旋线程M2的本地队列P2中没有G M2正在运行G0去寻找G7.5.场景7被唤醒的M2从全局队列中获取批量G获取G的个数 N min{ len(GQ)/GOMAXPROCS1, len(GQ/2) } GQ全局队列的总长度7.6.场景8M2从M1中批量偷取G假设此时全局队列中没有GM2就需要从其他M的P中获取G批量个数N后半段7.7.场景9自旋线程的最大限制自旋线程 执行线程 GOMAXPROCS此时假设新创建了M5因为GOMAXPROCS4不能在创建自旋线程了所以M5会被放入休眠线程队列17.8.场景10G发生系统调用/阻塞1. M2的P2执行G8此时G8执行了systemcall 阻塞此时M2绑定了G82. 因为此时M2的P2中存在G9因为M2已经全权为G8负责了为了不能阻塞G9的运行所以P2会重新寻找有没有其他的M能继续为它执行根据休眠线程队列中是否有空闲线程分为两种情况2.1. 有MP2将从空闲线程队列中取出M5将P2挂到M5上M5和P2组成新的MP2.2. 无M将P放入空闲队列7.9.场景11G发生系统阻塞再变为非阻塞M2中的G8此时变为非阻塞执行过程见下1. M2中记录了上一次绑定的PP是P2即优先获取原配2. M2发现P2已经被绑定给了M5因此M2是抢不过M5的3. M2会先尝试从空闲P队列中寻找P4.空闲P队列没有P此时M2放弃绑定P将执行释放逻辑① M2放到空闲线程队列 ②G8放到全局P队列8. Golang系统调用与阻塞处理 8.1. 阻塞8.1.1. Go阻塞的4种场景由于原子、互斥量、通道操作调用导致 Goroutine 阻塞调度器将把当前阻塞的 Goroutine 切换出去重新调度 本地P队列 上的其他 Goroutine由于网络请求、网络IO操作导致 Goroutine 阻塞。Go 程序提供了网络轮询器NetPoller来处理网络请求和 IO 操作的问题其后台通过 kqueueMacOSepollLinux或 iocp 来实现 IO 多路复用。通过使用 NetPoller 进行网络系统调用调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines而不需要创建新的 M。执行网络系统调用不需要额外的 M网络轮询器使用系统线程它时刻处理一个有效的事件循环有助于减少操作系统上的调度负载。用户层眼中看到的 Goroutine 中的“block socket”实现了 goroutine-per-connection 简单的网络编程模式。实际上是通过 Go runtime 中的 netpoller 通过 Non-block socket I/O 多路复用机制“模拟”出来的。当调用一些系统方法的时候如文件 I/O如果系统方法调用的时候发生阻塞这种情况下网络轮询器NetPoller无法使用而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。如果在 Goroutine 去执行一个sleep操作导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon它监控那些长时间运行的 G 任务然后设置可以强占的标识符别的 Goroutine 就可以抢先进来执行。8.2. 系统调用与调度机制8.2.1.异步系统调用异步系统调用网络IO结论当G1执行异步系统调用时会发生阻塞该阻塞动作①不需要创建新的M② G会和MP分离G挂到netpoller阻塞事件会由NetPoller接管刚开始G1在M上运行此时G1想去执行「网络系统调用」G1执行「网络系统调用」后发生阻塞此时将G1挂在到NetPoller上监听G1网络系统调用的返回M会从P队列中找到新的协程运行。注不需要创建新的M当G1的「网络系统调用」返回后G1会被移回到P队列中8.2.2.同步系统调用同步系统调用读写文件结论当G1执行同步系统调用时G2会发生阻塞同时会导致与G1绑定的M1也阻塞之后MG 会和P分离P另寻M当M从系统调用返回时不会继续执行而是将G放到run queue刚开始G1在M上运行此时G1想去执行「同步系统调用」G1会阻塞同步调用当G1阻塞后会导致M1也阻塞具体的执行动作是G1和M1绑定在一起陷入阻塞M1绑定的P会转移给新的M阻塞的系统调用完成后G1可以移回 LRQ 并再次由P执行。如果这种情况需要再次发生M1将被放在旁边以备将来使用