golang——GMP调度模型详解

目录

一.Golang调度器由来

 存在问题:

3种协程和线程的关系

二.Golang对协程的处理

协程和goroutine关系

Go的GMP调度模型

P 和 M 何时会被创建

P和M的个数

调度器的设计策略

(一)复用线程

(二)利用并行

(三)抢占

(四)全局G队列

go func()调度流程

调度器的生命周期

三.Go 调度器调度场景过程全解析

场景1.G1创建G2

场景2.G1执行完毕

场景3.G2开辟过多的G

场景4.G2本地满再创建G7

场景5.G2本地未满创建G8

场景6.唤醒正在休眠的M

场景7.被唤醒的M2从全局队列取批量G

场景8.M2从M1偷取G

场景9.自旋线程的最大限制

场景10.G发生系统调用/阻塞

场景11.G发生系统调用/非阻塞



一.Golang调度器由来

多线程/多进程操作系统

并发执行进程/线程时

  • 进程/线程数量越多,切换成本就越大
  • 多线程伴随着同步竞争(锁、资源冲突等)

 存在问题:

1.高内存占用

2.调度的高消耗CPU(上下文切换)

后来人们发现一个线程可被分为用户态和内核态线程

用户态线程对CPU透明

一个用户态线程绑定内核态线程(Linux 的 PCB 进程控制块),这样用户态任务可以切换,而CPU执行的线程不变,减少了切换线程带来的开销

约定:

        内核线程 = 线程

        用户线程 = 协程

线程由CPU调度,是抢占式

协程由用户态调度,是协作式的(一个协程让出CPU后,才执行下一个协程)

goroutine由runtime(go协程调度器)进行管理,而不是操作系统

 一个/多个协程可绑定在一个/多个线程上

3种协程和线程的关系

1. N:1关系——N个协程绑定1个线程

优点:

        协程在用户态线程即完成切换,不会陷入到内核态,切换轻量快速

缺点:

        一个进程的所有协程都绑定在一个线程上,一旦某个协程阻塞,协程调度器没有切换协程的能力时会造成线程阻塞,本进程的其他协程都无法执行了,失去并发能力


2. 1:1关系——1个协程绑定1个线程

 优点:

        实现简单,协程的调度由CPU完成,不存在N:1的缺点

缺点:

        协程的创建、删除和切换的代价都由CPU完成,略显昂贵


3. M:N关系——M个协程绑定1个线程

优点:

        克服了前两种的缺点

缺点:

        实现起来最复杂

二.Golang对协程的处理

协程和goroutine关系

        Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。

goroutine

        来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度

Go的GMP调度模型

GMP是Go运行时调度层面的实现,负责在适当实际将合适的协程分配到合适的位置,保证公平和效率

包含4个重要结构,分别是G、M、P、Sched

全局队列

        存放等待运行的G

P的本地队列

        存放等待运行的G

        数量限制,不能超过256G

        优先将新创建的G放在P的本地队列中

G 协程

        是Goroutine的缩写

        相当于操作系统的进程控制块(process control block)。

        它包含:函数执行的指令和参数,任务对象,线程上下文切换,字段保护,和字段的寄存器。

M 线程

        每个M都有一个线程的栈。如果没有给线程的栈分配内存,操作系统会给线程的栈分配默认的内存。当线程的栈制定,M.stack->G.stack, M的PC寄存器会执行G提供的函数。

P (处理器,Processor)

        包含运行goroutine的资源

        如果线程想运行goroutine,必须先获取P。P中还包含了可运行的G队列。

        优先将新创建的G放在P的本地队列中,如果满了会放在全局队列中

P 和 M 何时会被创建


P 何时创建:

        在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

M 何时创建:

        没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

P和M的个数

P的个数——在同一时刻的P个数,而不是宏观的并发

        程序启动时创建

        最多有GOMAXPROCS个

配置方法

1.环境变量$GOMAXPROCS个(可配置)

2.在程序中通过runtime.GOMAXPROCS()来设置


M的个数——动态数量

        动态的,一旦有一个M阻塞,就会创建一个新的M

        如果有M空闲,就会回收/睡眠

         Go语言本身,限定了M的最大量是10000(忽略)

        通过runtime/debug包中的SetMaxThreads函数来设置

调度器的设计策略

(一)复用线程

避免频繁的创建、销毁线程,而是对线程的复用

1.work stealing机制

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

2.hand off机制

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

(二)利用并行

eg.GOMAXPROCS限定P的个数,最多有GOMAXPROCS个线程分布在多个CPU上同时运行

(三)抢占

之前:coroutine,要等待另一个协程主动释放CPU才执行下一个协程

现在:一个goroutine最多占用CPU10ms时间片轮询),防止其他goroutine被饿死(抢占)

(四)全局G队列

基于work stealing机制的补充,当M从其他P偷不到G时,他可以从全局G队列中获取G

go func()调度流程

go func()

1.创建一个G

2.1G优先放到当前线程持有的P的本地队列中;

2.2如果已经满了,则放入全局队列中

3.1M通过P获取G;(一个M必须持有一个P——1:1)

3.2如果M的本地队列为空,从全局队列获取G;

3.3(work stealing机制)如果也为空,则从其他的MP组合偷取G

4.调度

5.执行func()函数

5.1 超出时间片后返回P的本地队列

5.2 若G.func()发生systemCall/阻塞

5.2.1 runtime(即调度器)会把这个M从P中摘除,(hand off机制)创建一个M或从休眠队列中取一个空闲的M,接管正在被阻塞中的P

5.2.2 M系统调用(阻塞)结束时,G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中

6.销毁G

7.返回

调度器的生命周期

M0和G0

M0(进程唯一)

启动程序后的编号为0的主线程

在全局变量runtime.m0中,不需要在heap上分配

负责执行初始化操作和启动第一个G

启动第一个G后,M0就和其他的一样了

G0(线程唯一)

每启动一个M,都会第一个创建G0,每个M都会有一个自己的G0

G0不指向任何可执行的函数

G0仅用于负责调度G,在调度/系统调用时,M会切换到G0,调度其他G

M0的G0会放在全局空间

package main

import "fmt"

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

1. runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
3. 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
4. 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
5. G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
6. M 运行 G
7.G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。

1

1

1

(三)Go 调度器调度场景过程全解析

场景1.G1创建G2

P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2

局部性:G2 优先加入到 P1 的本地队列

场景2.G1执行完毕

G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。

场景3.G2开辟过多的G

场景4.G2本地满再创建G7

本地队列的前一半转移到全局队列(防止饥饿)

这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

场景5.G2本地未满创建G8

G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。

场景6.唤醒正在休眠的M

在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。

假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。

场景7.被唤醒的M2从全局队列取批量G

        自旋线程M2,优先从全局队列获取一批G放到P2的本地队列(函数:findrunnable()).M2从全局队列取的G数量符合下面的公式

n=min(len(GQ)/GOMAXPROCS+1,len(GQ/2))

场景8.M2从M1偷取G

取后半部分

场景9.自旋线程的最大限制

自旋线程+执行线程 ≤ GOMAXPROCS

超过的线程将加入休眠线程队列

场景10.G发生系统调用/阻塞

        假定当前除了 M3 和 M4 为自旋线程,还有 M5 和 M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。

场景11.G发生系统调用/非阻塞

        G8由阻塞变为非阻塞

         M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。

参考 [Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛 (learnku.com)

  • 12
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值