GolangLearning | 协程调度器与GMP模型设计

by CUNOE, September 1, 2023

记录Golang协程调度与GMP模型设计

调度器

早期单进程操作系统

在一个一维时间轴上顺序执行进程。

  1. 单一执行流程、计算机只能按顺序一个一个任务执行
  2. 进程阻塞带来CPU性能浪费

多线程/进程操作系统

存在一个CPU调度器,进行进程切换。

通过时间片来强制要求进程执行时间不能超过时间片,达到时间片长度后强制切换进程。

宏观上并发,本质上还是一次执行一次任务。

解决了阻塞问题,防止因阻塞导致后续进程无法执行。

存在切换进程的时间与计算成本,进程/线程数量越多,切换成本越大。

随着同步竞争(锁、竞争资源冲突等)导致开发设计变得复杂。

调度模型

单一线程切分成协程、线程。「1:1关系」

  • 跟多线程/多进程模型无异
  • 切换成本高

单一线程切分成协程(Co-Routine)、协程调度器、线程(Thread)。「N:1关系」

  • 无法利用多个CPU
  • 出现阻塞瓶颈

多个线程切分成协程、协程调度器、线程。「M:N关系」

  • 能够利用多核
  • 过于依赖调度器算法与优化

Goroutine

协程 -> Goroutine协程 内存:几KB 灵活调度:常切换

Golang早期调度器

「M:N」关系

存在一个全局Go协程队列,存在N个协程。

线程轮询从全局队列获取锁,尝试执行协程,执行后放回队列尾。

缺陷

  1. 创建、销毁、调度协程都需要线程获取锁,造成锁竞争
  2. 线程转移协程会造成延迟和额外的系统负载
  3. 系统调用(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环境变量;在程序中通过runtime包进行设置
  • 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中偷取时,会从全局偷取。「加锁/解锁」

go func()经历了哪些过程

  1. go func()执行时会创建一个G
  2. 存在两个存储G的队列,局部调度器P的本地队列、全局G队列。新创建的G会优先包存在P的本地队列中,若P的本地队列已经满了则会包存在全局队列中。
  3. G只能运行在M中,一个M必须持有一个P,M会从P的本地队列中弹出一个可执行的G来执行,如果P的本地队列为空,就会从其他的P的本地队列中偷取一个G来执行。
  4. 一个M调度G执行的过程是一个循环机制。
  5. 当M执行某一个G时发生了阻塞操作,M阻塞,如果当前有一些G在等待执行,运行时会将线程M从P中摘除(Detach),然后创建或拉取一个新的线程来服务该P
  6. 当M阻塞结束时,该G会尝试获取一个空闲P执行,并进入该P的本地队列。如果无法获取P,则M转为休眠状态,加入空闲线程中,G回到全局队列等待执行。

调度器的生命周期

  • M0: 启动程序后编号为0的主线程;被保存在全局变量runtime.m0中,不需要在heap上分配;负责执行初始化操作和启动第一个G;启动第一个G后就和其他M一样
  • G0:每次启动一个M,都会创建一个G,为G0;G0只用于调度G;不指向任何可执行函数;在调度和系统调用时会使用G0来调度;M0、G0都会放在全局空间
package main

import "fmt"

func main() {
  fmt.Println("Hello World")
}

进程启动则创建一个线程M0,M创建一个G0,同时初始化P的本地队列,全局队列,创建G(main),解绑G0,找到一个空闲队列P加入本地队列,后续按正常队列调度执行直至panic或者正常结束。