GMP模型的基本概念
G(Goroutine):表示
goroutine
,即 Go 语言中的协程。P(Processor):P代表逻辑处理器,它是一个抽象的概念,并不是真正的物理CPU。P负责维护一个Goroutine队列,调度Goroutine到M上执行。P的数量通常等于CPU的核心数,可以通过
GOMAXPROCS
参数来设置。每个P
负责调度和执行多个goroutine
。包含运行Goroutine的资源和本地队列。M(Machine):表示系统线程(即操作系统的内核线程),是执行Goroutine的实体。负责执行
P
中分配的goroutine
。M
通过P
把goroutine
绑定到内核线程上执行。当P
空闲时,可以调度其他goroutine
执行。P 的本地队列 :每个逻辑处理器(P)都有一个本地队列,用于存放等待运行的 Goroutine(G)。这个本地队列的大小是有限的,通常不超过 256 个 Goroutine。这种设计旨在减少锁的使用,提高调度效率,因为访问本地队列不需要加锁。
全局队列: 用于存放那些因为本地队列已满而无法加入的 Goroutine。全局队列是一个共享资源,所有逻辑处理器(P)都可以从中获取 Goroutine 来执行。
GMP模型的工作原理
**Goroutine (G)**:比作一个快递包裹。
**线程 (M)**:比作一个分拣员。
**逻辑处理器 (P)**:比作分拣员的工作台。
- G与M的绑定机制:
(包裹和分拣员的关系):
想象一下,快递包裹(Goroutine)送到分拣中心后,并不是直接交给某个分拣员(线程),而是先放在分拣员的工作台(逻辑处理器 P)上的包裹堆里。分拣员(M)会从自己工作台上的包裹堆里拿包裹来分拣。
Goroutine并不直接绑定到操作系统线程上,而是通过P来调度。当一个M需要执行工作时,它会从与之关联的P的本地队列中取出一个Goroutine来执行。如果M完成了G的执行或者G被阻塞,M会再次从P的队列中取出另一个G来执行。
- P的本地运行队列:
(工作台上的包裹堆):
每个分拣员的工作台(P)上都有一个包裹堆,用来存放等待分拣的包裹。如果分拣员的工作台上没有包裹了,他会去中心的包裹池(全局运行队列)里拿,或者从其他分拣员的工作台上“偷”几个包裹来分拣,这就是工作窃取。
每个P都有一个本地运行队列,用于存储准备好执行的Goroutine。当P的本地队列为空时,它会尝试从全局运行队列或者其他P的本地队列中“偷取”Goroutine来执行,这种策略称为工作窃取(Work Stealing)。
- M的休眠与唤醒:
(分拣员的休息与工作):
如果一个分拣员发现自己工作台上没有包裹了,而且中心的包裹池里也没有包裹,他可能会暂时休息,不消耗体力。如果有新的包裹送来,或者有包裹从其他工作台“偷”过来,休息的分拣员会被叫醒来继续工作。
当M在其关联的P的本地队列中找不到可运行的G时,它可能会进入休眠状态。在休眠状态下,M不会消耗CPU资源。当新的Goroutine被创建或者有Goroutine变为可运行状态时,休眠中的M可以被唤醒来处理这些任务。
- G的状态转换:
(包裹的处理过程):
快递包裹在分拣过程中会经历几个状态:
可运行(Runnable):包裹刚送到,等待分拣。
运行中(Running):分拣员正在分拣这个包裹。
休眠(Waiting):包裹需要等待某些条件(比如等待特殊处理或者检查)。
死亡(Dead):包裹已经分拣完毕,可以送出去了。
如果包裹在分拣过程中需要等待(比如等待检查),分拣员会先放下这个包裹,去分拣其他包裹。这就像是 Goroutine 在执行过程中遇到会导致阻塞的操作时,它会从分拣员(M)上解绑并进入休眠状态。
一旦等待的条件满足,分拣员会回来继续分拣这个包裹。这就像是阻塞的操作完成后,Goroutine 会变回可运行状态,并等待被调度器重新分配到分拣员(M)上执行。
Goroutine在其生命周期中会经历几种状态,包括可运行(Runnable)、运行中(Running)、休眠(Waiting)和死亡(Dead)。当G在执行过程中遇到会导致阻塞的操作时,它会从M上解绑并进入休眠状态,等待被唤醒。一旦阻塞的操作完成,G会变回可运行状态,并等待被调度器重新分配到M上执行。
GMP
是系统线程运行的代码片段
设计策略
复用线程的两个策略:
Work Stealing机制: 当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
Hand Off机制:当本线程因G进行系统调用等阻塞时,线程会释放绑定的P,把P转移给其他空闲的M执行。
利用并行:GOMAXPROCS
设置P的数量,最多有GOMAXPROCS
个线程分布在多个CPU上同时运行。GOMAXPROCS
也限制了并发的程度,比如GOMAXPROCS = 核数/2
,则最多利用了一半的CPU核进行并行。
调度器的工作机制:
想象一下,你在一个公共场合,大家都在排队上厕所。这里有两种情况:
- 协作式调度:
每个人都知道自己大概需要多久,如果有人预计自己会占用比较长时间,比如要“大号”,他们会主动告诉后面的人:“我可能需要很久,你们可以先去别的厕所看看。”这样,其他人就有机会先去别的厕所,这就是协作式调度。在 Go 语言中,Goroutine 在执行长时间任务时,会主动让出 CPU,让其他 Goroutine 先执行。
Go 语言使用协作式调度机制,意味着 g 在某些特定点(如系统调用、I/O 操作)会主动让出 CPU,从而使调度器有机会调度其他 g 执行。这种机制减少了不必要的上下文切换,提高了调度效率。
- 抢占式调度:
但是,有时候有些人进去后,可能因为玩手机或者别的原因,占用厕所时间过长。管理员(调度器)看不下去了,就会敲门说:“你已经待很久了,快点出来,外面还有人等着呢。”这就是抢占式调度。在 Go 语言中,如果一个 Goroutine 占用 CPU 太久,调度器会强制中断它,让其他 Goroutine 有机会执行。
为了防止某个 g 长时间占用 CPU 资源,Go 语言在 1.14 版本中引入了抢占式调度。如果一个 g 占用 CPU 时间过长,调度器会强制中断其执行,并将控制权交还给调度器,确保其他 g 能够得到执行机会。
特殊的 G0 和 M0:
G0:每个线程(M)启动时都会创建一个特殊的 Goroutine(G0),它用于调度和系统调用,不指向任何用户代码。G0 在调度器需要执行系统调用或者进行调度操作时使用。
M0:程序启动后的第一个主线程(M0)负责执行初始化操作和启动第一个 Goroutine。此后,M0 的行为与其他线程(M)相同。
1 | package main |
接下来我们来针对上面的代码对调度器里面的结构做一个分析。
也会经历如上图所示的过程:
创建初始线程和Goroutine:
- Go 程序启动时,runtime 包会创建一个初始线程
m0
和一个特殊的 Goroutineg0
。g0
是每个线程的调度器 Goroutine,用于执行系统调用和调度操作。
- Go 程序启动时,runtime 包会创建一个初始线程
调度器初始化:
- 初始化线程
m0
、栈、垃圾回收器,以及创建由GOMAXPROCS
(可以设置的,决定了同时运行的线程数)个P
(Processor,逻辑处理器)构成的P
列表。每个P
都与一个M
(Machine,物理线程)关联。
- 初始化线程
main函数的调用:
示例代码中的
main
函数是用户定义的main.main
,而在 runtime 包中也有一个main
函数,即runtime.main
。编译后的代码会将runtime.main
调用main.main
。程序启动时,会为
runtime.main
创建一个 Goroutine,我们可以称之为main goroutine
。这个 Goroutine 会被加入到P
的本地队列中。
启动 m0:
m0
启动后,会绑定到一个P
上,并从P
的本地队列中获取G
(Goroutine),即获取到main goroutine
。
设置运行环境:
- 每个
G
都有自己的栈。M
会根据G
中的栈信息和调度信息设置运行环境。
- 每个
运行 G:
M
开始运行G
。在这个例子中,main goroutine
会执行main.main
函数,打印 “Hello world”。
G
退出和循环:当
G
执行完毕后,M
会尝试获取下一个可运行的G
。这个过程会一直重复,直到main.main
退出。一旦
main.main
退出,runtime.main
会执行任何延迟函数(Defer)和 Panic 处理,或者调用runtime.exit
来退出程序。
GMP
模型的工作流程/调度策略
**Goroutine (G)**:比作一个快递包裹。
**线程 (M)**:比作一个分拣员。
**逻辑处理器 (P)**:比作分拣员的工作台。
新建 Goroutine(新快递包裹到达):
当新的快递包裹(Goroutine)送到分拣中心时,调度器会优先将这些包裹放到当前分拣员(P)的工作台上(本地队列)。如果工作台上的包裹已经很多了,超出了一定数量,那么一半的包裹会被移动到中心的包裹池(全局队列)中,以便其他分拣员可以处理。
通过
go func()
创建一个协程,调度器会将其视为一个新的任务,并优先放入当前逻辑处理器(P)的本地队列(Local RunQueue)中。如果本地队列已满,会将一半的(G)移动到全局队列中。
优先本地队列调度(分拣员优先处理自己工作台上的包裹):
分拣员(M)在工作时,会优先处理自己工作台上的快递包裹(从绑定的 P 的本地队列中获取 G 进行执行)。这种方式不需要分拣员之间互相沟通,提高了分拣效率,因为每个分拣员都专注于自己的工作台。
线程(M)在需要执行 Goroutine 时,会优先从其绑定的逻辑处理器(P)的本地队列中获取 Goroutine 进行执行。这种方式避免了锁的使用,提高了调度效率。
全局队列调度(分拣员从中心包裹池获取包裹):
如果分拣员发现自己工作台上的包裹都处理完了,他们会去中心的包裹池(全局队列)看看有没有新的包裹可以处理。这个过程需要分拣员之间协调(加锁),虽然比直接处理自己工作台上的包裹慢一些,但可以确保整个分拣中心的包裹都能得到处理,保持负载均衡。
如果逻辑处理器(P)的本地队列为空,线程(M)会尝试从全局队列(Global RunQueue)中获取 Goroutine。访问全局队列需要加锁,这是一个相对较慢的过程,但它确保了系统的负载均衡。
工作窃取(分拣员从其他工作台“偷”包裹):
如果分拣员发现自己工作台上和中心包裹池里都没有包裹了,而其他分拣员的工作台上还有包裹,他们可以走过去,从其他分拣员的工作台上“偷”一些包裹来处理。这样可以避免一些分拣员太忙而其他分拣员太闲的情况,确保所有分拣员都能保持忙碌。
如果线程(M)从逻辑处理器(P)的本地队列和全局队列都无法获取到 Goroutine,它会尝试从其他逻辑处理器的本地队列中“偷取”一部分 Goroutine 来执行,这种策略称为工作窃取(Work Stealing)。
Goroutine 的阻塞与唤醒(快递包裹因特殊处理而暂时搁置):
当快递包裹在分拣过程中需要等待某些特殊处理(比如等待检查或者需要特别的分拣规则),分拣员会暂时将这个包裹放到一边,去处理其他包裹。这就像是 Goroutine 在执行过程中遇到会导致阻塞的操作时,它会从分拣员(M)上解绑并进入休眠状态。
一旦需要等待的特殊处理完成,这个包裹会被重新放回某个分拣员的工作台上(P 的本地队列),等待下一个可用的分拣员(M)来继续处理。
当 Goroutine 在执行过程中遇到会导致阻塞的操作(如 I/O 操作或系统调用),它会从线程(M)上解绑并进入休眠状态(Waiting State)。一旦阻塞的操作完成,Goroutine 会被唤醒,并变回可运行状态(Runnable State),等待被调度器重新分配到线程(M)上执行。
GMP模型的优势
高效的资源利用:通过在用户态进行调度,避免了频繁的上下文切换带来的开销,充分利用CPU资源。
轻量级并发:Goroutine比线程更加轻量级,可以启动大量的Goroutine而不会消耗大量内存。
自动调度:Go运行时自动管理Goroutine的调度,无需程序员手动干预,简化了并发编程的复杂度。
参考链接
操作系统篇一:进程与线程、并发并行与串行、同步与异步、阻塞与非阻塞当你被问到这些问题:你觉得的并发、并行、串行有什么区别 - 掘金
第三篇、Golang编程设计与通用之路 - 3、对于操作系统而言进程、线程以及Goroutine协程的区别 - 《Golang 修养之路》 - 书栈网 · BookStack
翻译来自 Goroutines