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
协程的调度流程
来源 : Golang 修养之路
- 通过go创建一个goroutine
- 有两个存储G的队列,一个是局部调度器P的本地队列(256大小),一个是全局G队列。如果P的本地队列已经满了就会保存在全局队列中;
- G只能运行在M中,一个M必须持有一个P,M和P时1:1关系。M会从P的本地队列弹出一个可执行状态的G来执行,P的本地队列为空,就会想从其他的P中偷取部分可执行的G
- 一个M调度G执行的过程是一个循环的过程
- 当M执行一个G发生了系统调用或者其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会将这线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
- 当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;