goroutine调度机制(GMP模型)

进程、线程和协程

  • 进程是操作系统分配资源的最小单元,是一个具有一定独立功能的程序关于某个数据集合上的一次运行活动
  • 线程是操作系统调度的最小单元,是进程的一个执行单元
  • 协程是用户态线程,协程的调度完全由用户控制。
进程 线程 协程
切换者 操作系统 操作系统 用户(编程者/应用程序)
切换时机 根据操作系统自己的切换策略,用户不感知 根据操作系统自己的切换策略,用户不感知 用户自己(的程序)决定
切换内容 页全局目录、内核栈、硬件上下文 内核栈、硬件上下文 硬件上下文
切换内容的保存 保存于内核栈中 保存于内核栈中 保存于用户自己的变量(用户栈或者堆)
切换过程 用户态-内核态-用户态 用户态-内核态-用户态 用户态(没有陷入内核态)
切换效率

多进程/线程解决了阻塞问题。如果是单进程/线程,就是顺序执行的,程序A跑完,开始跑程序B。如果程序A阻塞了,就需要等待A阻塞完毕,再等待A执行完毕,才能执行B。也就是说,A程序去执行IO操作了,CPU不执行相应的计算操作,但是还要等待A阻塞完才能继续执行。而与此同时B程序看着CPU没活干,也得等着。多进程/线程则是给每个程序分配一定的时间片,只能在这个时间段内占用CPU的计算资源。也就是说,当程序阻塞了,顶多阻塞一个时间片,过完这个时间片就会交出CPU,让CPU去进行其他程序的计算。

对于 Linux 操作系统来讲,CPU对进程的态度和线程的态度是一样的,都有较高的切换成本。以及还有创建、销毁成本。

7-cpu切换浪费成本.png

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用 4GB [32 位操作系统], 而线程也要大约 4MB)。

大量的进程 / 线程出现了新的问题:

  • 高内存占用
  • 调度的高消耗 CPU

一个 “用户态线程”(协程) 必须要绑定一个 “内核态线程”(线程),但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态线程”(Linux 的 PCB 进程控制块)。也就是说在提出协程概念之前,一个协程对应 一个线程,这样,协程的切换开销等于线程的切换开销。如果一个线程绑定多个协程,那么线程内的协程切换就不涉及线程切换开销了。

协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

9-协程和线程.png

3 种协程和线程的映射关系:

  • N 个协程绑定 1 个线程

优点:协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上

缺点:某个程序用不了硬件的多核加速能力;一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

  • 1 个协程绑定 1 个线程

优点:最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点

缺点:协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。

  • M个协程绑定N个线程

是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

12-m-n.png

GMP模型

G:goroutine协程

M:thread线程

P:processor处理器,它包含了运行 goroutine 的资源(栈、堆、数据等),如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。P的个数由环境变量$GOMAXPROCS决定,P的个数决定了任意时刻最多有多少个goroutine在运行(并行)。

在 Go 中,线程(M)是运行 goroutine (G)的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上

16-GMP-调度.png

  • 全局队列(Global Queue):存放等待运行的 G。(被抛弃的,候补的G)
  • P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。;
  • P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  • M:线程想运行任务就得获取 P,从 P 的本地队列获取 G。P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

Goroutine 调度器OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核(多核)上执行。

大致过程:CPU(多核)-->M(多线程)-->P(多协程)-->G(协程)

调度器策略

  • 1.复用线程

    • work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毀线程。
    • hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
  • 2.利用并行

    • GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。
  • 3.抢占

    • 在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死。
  • 4.全局G队列

    • 当N执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

go func()调度流程

18-go-func调度周期.jpeg

  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 执行的过程是一个循环机制(有时间片,一个goroutine最多占用CPU 10ms);

  5. 当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;(hand off机制)也就是说,M和P是配对的,如果P中的任务G使得配对的M阻塞,则解除P与M的配对,并创建新的M与P配对,这样P中后续任务可以得到执行。此时M直接与G绑定,M执行G的系统调用。

  6. 当 M 系统调用(第5步的发生systemCall/阻塞)结束时候,图中M1已经没有绑定P,M1在直接调度执行G:

    • 这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列
    • 如果获取不到 P,那么这个线程 M 变成休眠状态,加入到空闲线程中,然后这个 G 会被放入全局队列中。
    • 一个 M 必须持有一个 P,此时是M直接执行G,并且执行完毕,因此M需要找一个P绑定,G找找一个P队列放入。(我的理解)
    • 前两句话我还是不太理解,好像意思是G要么进入P本地队列,要么进入全局队列;M要么接管未被接管的P,要么休眠或销毁。

其他解释:
当某个G由于系统调用陷入内核态时,该P就会脱离当前的M,此时P会更新自己的状态为Psyscall,M与G互相绑定,进行系统调用。结束以后若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。

参考

[Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛

协程的原理以及与线程的区别 - rhyme - 博客园

Golang深入理解GPM模型_哔哩哔哩_bilibili

Golang并发模型GMP - 知乎

posted @ 2023-03-13 15:18  roadwide  阅读(211)  评论(0编辑  收藏  举报