记录Golang协程调度与GMP模型设计
调度器
早期单进程操作系统
在一个一维时间轴上顺序执行进程。
- 单一执行流程、计算机只能按顺序一个一个任务执行
- 进程阻塞带来CPU性能浪费
多线程/进程操作系统
存在一个CPU调度器,进行进程切换。
通过时间片来强制要求进程执行时间不能超过时间片,达到时间片长度后强制切换进程。
宏观上并发,本质上还是一次执行一次任务。
解决了阻塞问题,防止因阻塞导致后续进程无法执行。
存在切换进程的时间与计算成本,进程/线程数量越多,切换成本越大。
随着同步竞争(锁、竞争资源冲突等)导致开发设计变得复杂。
调度模型
单一线程切分成协程、线程。「1:1关系」
- 跟多线程/多进程模型无异
- 切换成本高
单一线程切分成协程(Co-Routine)、协程调度器、线程(Thread)。「N:1关系」
- 无法利用多个CPU
- 出现阻塞瓶颈
多个线程切分成协程、协程调度器、线程。「M:N关系」
- 能够利用多核
- 过于依赖调度器算法与优化
Goroutine
协程 -> Goroutine协程 内存:几KB 灵活调度:常切换
Golang早期调度器
「M:N」关系
存在一个全局Go协程队列,存在N个协程。
线程轮询从全局队列获取锁,尝试执行协程,执行后放回队列尾。
缺陷
- 创建、销毁、调度协程都需要线程获取锁,造成锁竞争
- 线程转移协程会造成延迟和额外的系统负载
- 系统调用(CPU在线程中切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销
Goroutine调度器的GMP模型设计思想
GMP名词
- G:Goroutine协程
- M:Thread线程
- P:Processor处理器
概念
- 全局队列:存放等待运行的G
- P的本地队列:存放等待运行的G,数量不超过256个G,优先将先创建的G放在P的本地队列中,如果满了回放到全局队列中。
- P列表:程序启动时创建,数量根据GOMAXPROCS确认
- M列表:当前操作系统分配到Go程序的内核线程数
P&M数量
- P的数量问题:GOMAXPROCS环境变量;在程序中通过
包进行设置 - M的数量问题:限定M的最大数量为10000;动态数量,当有一个M阻塞,会创建一个新M,如果有M空闲,会回收或者睡眠。
操作系统调度器上获取多个内核线程,每个内核线程都有一个P(LocalP),每个P都存在一个P的本地队列,顶层存在一个全局队列。
调度器设计策略
复用线程
WorkStealing机制
当此时P处理器正在执行,P的本地队列存在协程等待执行,存在另一个P空闲,此时会从在P本地队列中等待的协程偷取到自己的队列中执行。
当线程中无可运行G时,从其他线程偷去绑定的P偷取G而不是销毁线程
HandOff机制
当此时P处理器出现阻塞操作时,创建/唤醒一个新的线程,将P切换到新线程执行,原线程睡眠/销毁,若该阻塞G想要继续执行将重新加入队列中等待执行。
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,将P转移到其他空闲线程执行。
利用并行
GOMAXPROCS=CPU核数/2
最多有GOMAXPROCS个线程分布在多个CPU上同时运行
抢占
每个G最多允许10ms到时间片,超出时间强制切换G,防止其他G被饿死
全局G队列
WorkStealing机制无法从其他P中偷取时,会从全局偷取。「加锁/解锁」
经历了哪些过程
执行时会创建一个G - 存在两个存储G的队列,局部调度器P的本地队列、全局G队列。新创建的G会优先包存在P的本地队列中,若P的本地队列已经满了则会包存在全局队列中。
- G只能运行在M中,一个M必须持有一个P,M会从P的本地队列中弹出一个可执行的G来执行,如果P的本地队列为空,就会从其他的P的本地队列中偷取一个G来执行。
- 一个M调度G执行的过程是一个循环机制。
- 当M执行某一个G时发生了阻塞操作,M阻塞,如果当前有一些G在等待执行,运行时会将线程M从P中摘除(Detach),然后创建或拉取一个新的线程来服务该P
- 当M阻塞结束时,该G会尝试获取一个空闲P执行,并进入该P的本地队列。如果无法获取P,则M转为休眠状态,加入空闲线程中,G回到全局队列等待执行。
调度器的生命周期
- M0: 启动程序后编号为0的主线程;被保存在全局变量
中,不需要在heap上分配;负责执行初始化操作和启动第一个G;启动第一个G后就和其他M一样 - G0:每次启动一个M,都会创建一个G,为G0;G0只用于调度G;不指向任何可执行函数;在调度和系统调用时会使用G0来调度;M0、G0都会放在全局空间
进程启动则创建一个线程M0,M创建一个G0,同时初始化P的本地队列,全局队列,创建G(main),解绑G0,找到一个空闲队列P加入本地队列,后续按正常队列调度执行直至panic或者正常结束。