Golang GMP调度模型

199 阅读8分钟

GMP模型

设计思想

针对早期版本GM,并发1:1线程模型导致的问题

  • 调度器调度G时,全局锁竞争问题
  • M转移G会造成 延迟和额外的系统负载 。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了 很差的局部性 ,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
  • 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。CPU切换线程,上下文开销,因为系统调用导致的线程频繁切换M;

GMP 的基本组成部份

G: goroutine 协程

golang并发的执行单元

  • 轻量性:每个goroutine都有自己的栈空间,定时器,初始化的栈空间2k(1.21),空间随着需求动态增长和压缩
  • 并发执行:通过关键字 go 可以方便的创建执行并发的goroutine;以非阻塞的方式执行任务,提高程序的吞吐量和性能
  • 调度:work-steal, hand-off 等机制保证性能释放
  • 同步:通过channel,sync等途径进行Goroutine之间的信息传递
  • 对应的线程模型: M:N

Goroutines 被多路复用到多个操作系统线程上,因此如果其中一个线程发生阻塞(例如在等待 I/O 时),其他线程会继续运行。它们的设计隐藏了线程创建和管理的许多复杂性;

M: machine 操作系统线程(应用层)

实际的执行单元,M本身是应用层的概念,每个M绑定一个对应的内核线程。

P: processor 管理调度的处理器

包含了运行Goroutine的资源,是和M关联的上下文,维护了一组Goroutine的队列,以及与之相关的调度器和执行堆栈。P 是用来管理调度 M 和执行 Goroutines 的实体。

  • 调度G:P 负责调度 Goroutines。它会从全局队列中获取 Goroutines 并将它们分配给 M 执行。
  • 本地队列: P 维护了一个本地队列,用于存放等待执行的 Goroutines。这种本地队列的设计可以提高调度的效率,避免过多的全局锁竞争。更好的运用局部性原理。
  • 执行堆栈:P 包含了执行 Goroutines 所需的堆栈信息,它负责在执行过程中维护 Goroutine 的上下文信息。
  • GOMAXPROCS:通过 GOMAXPROCS 环境变量或者 runtime.GOMAXPROCS() 函数可以控制 P 的数量。防止因为不合适的最大处理个数,导致的CPU利用率下降;

进程 线程 协程

进程 process

运行时程序的封装,操作系统角度来看是操作系统对于进程的描述,linux pcb task_struct结构体;

  • 进程是操作系统资源分配和调度的基本单位,实现了操作系统内部的并发;
  • 每个进程都在独立的内存空间中(虚拟内存技术),互相之间不能访问对方的内存;
  • 进程之间通常使用IPC(进程间通信)来交换数据

具体进程内包含的东西,操作系统详述

线程 thread

Linux下的线程是以进程pcb模拟实现的,并且这些pcb共用同一个虚拟地址空间,共享进程中的大部分资源,因此Linux下的线程实际上是一个 轻量级进程,相较传统进程更加 轻量化

  • 线程是在进程内执行的 独立执行单元,多个线程可以共享同一进程的资源,包括内存空间和文件句柄等
  • 线程是CPU调度的基本单位,CPU通过调度PCB实现程序调度
  • 同一进程内的多个线程可以并发执行,共享同一地址空间,可以相互访问同一进程中的数据
  • 由于共享资源,需要进行同步操作来避免多个线程之间的数据竞争

协程 coroutine

  • 协程是一种用户态的轻量级线程,它由程序员自行控制调度,而不是由操作系统控制。
  • 协程可以在单线程中实现并发,可以根据需要在不同的时间点暂停、恢复、和切换执行。
  • 由于协程是由用户控制调度的,因此切换开销比线程要小,可以有效提高程序的并发能力。

GMP调度模型

调度器设计策略

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

  • work steal 机制:当本线程无可运行的G时,全局队列中也不存在可用的G,尝试从其他M绑定的P中偷取G,而不是销毁线程
  • hand off 机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,将P转移给其他空闲的线程

利用并行GOMAXPROCS 设置P的数量,最多有 GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度;

抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在go中,一个goroutine最多占用一个线程10ms,防止其他线程被饿死,这就是goroutine和coroutine不同的地方

全局G队列:新的调度器中仍然存在全局G队列,但是功能被弱化,当M无法执行work stealing从其他P偷不到G时,从全局G队列中获取G

协程的调度流程

goroutine.png

来源 : Golang 修养之路

  1. 通过go创建一个goroutine
  2. 有两个存储G的队列,一个是局部调度器P的本地队列(256大小),一个是全局G队列。如果P的本地队列已经满了就会保存在全局队列中;
  3. G只能运行在M中,一个M必须持有一个P,M和P时1:1关系。M会从P的本地队列弹出一个可执行状态的G来执行,P的本地队列为空,就会想从其他的P中偷取部分可执行的G
  4. 一个M调度G执行的过程是一个循环的过程
  5. 当M执行一个G发生了系统调用或者其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会将这线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
  6. 当M的系统调用结束时,这个G会尝试获取一个空闲的P执行,并放入这个P的本地队列。如果获取不到P,那么这个线程M就会变成休眠状态,加入到空闲线程队列中,这个G会被放入全局队列中;

Note:

  • 应用内go出去的func(),如果P本地队列没有满的话,优先放在队列头部,优先执行(局部性原则);

  • 本地队列满创建新的goroutine,需要执行负载均衡(将P中前一半的队列打散,还有新创建的G转移到全局队列中);

  • 在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行 。提高cpu的利用率;唤醒的M没有可用的G时,进入自旋模式,这意味着它会轮询(自旋)查看全局队列中是否有可以执行的 Goroutine,而不是进入睡眠状态等待调度器唤醒。

    • 自旋模式的目的是避免线程阻塞和唤醒的开销,因为线程的阻塞和唤醒操作会涉及到操作系统的系统调用,其开销相对较大。通过自旋模式,M 可以在不进入睡眠状态的情况下等待新的任务。然而,自旋模式也会占用 CPU 资源,因此需要在平衡性能和资源利用的考量下使用。golang会根据系统负载,动态调整M的自旋行为;
  • 从全局Goroutine队列中获取可执行的Goroutine(findrunnable()),获取的G个数;每次不会获取太多

    n =  min(len(GQ) / GOMAXPROCS +  1,  cap(LQ) / 2 )
    
  • 阻塞的系统调用:M1会和P1解绑,M1和对应的G进入系统调用,如果P1本地队列有G、全局队列有G或有空闲的M,P1都会立马唤醒1个M和它绑定,否则P1则会加入到空闲P列表,等待M来获取可用的p

  • 非阻塞的系统调用:M1会和P1解绑,但是M1会标记P1,M1和对应的G进入系统调用状态,当M1退出系统调用时,会尝试获取P1,如果无法获取,则获取空闲的P,如果依然没有,G会被记为可运行状态,并加入到全局队列,M1因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)

调度器的生命周期

graph TD;
	A(开始) --> B(创建第一个线程M0);
	B --> C(创建第一个Go协程G0);
	C --> D(关联M0和G0);
	D --> E(调度初始化);
	E --> F("创建main()中的goroutine");
	F --> G(启动M0);
	G --> H(M绑定P);
	H --> I{"M通过P获取到G?"};
	I --> |N|M(M休眠);
	M --> |"M被唤醒启动"|H;
	I --> |Y|J(M设置G环境);
	J --> K(M执行G);
	K --> L(G退出);
	L --> I;

特殊的M0和G0

  • M0 启动后编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要再heap上分配,m0负责执行初始化操作和启动第一个G,之后M0就和其他的M相同;
  • G0 每启动一个M都会第一个创建的goroutine,G0仅用于负责调度的G,G0不执行任何可执行的函数,每个M都会有一个自己的G0。在调度或系统调用是会使用G0的栈空间,全局变量的G0是M0的G0;