Go并发编程之GPM并发模型(2)

Admin 2022-05-25 16:28:27 GoLang

接着上一篇,这一篇主要介绍对GPM模型的理解,这也是被众多开发者喜爱的原因,Go的横空出世也基于此,不同于Python基于进程的并发模型,Go采用轻量级的Goroutine来实现并发,可以大大减少CPU的切换。

Goroutine轻量级,主要体现两个方面:

上下文切换代价小:Goroutine 的上下文切换只涉及到三个寄存器(PC/SP/DX)的值修改,而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)以及16 个寄存器、PC、SP等寄存器的刷新。

内存占用少:线程栈空间通常是2M而Goroutine栈空间最小2K(在Go程序中可以轻松支持10w级别的Goroutine运行,而线程数量达到1k时,内存占用就达到2G)。

一、调度器机制

Go调度器模型我们通常叫做GPM模型,他包括4个部分,分别是G、P、M、Sched:

  1. G(Goroutine):每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、任务对象、线程上下文切换、现场保护和现场恢复需要的寄存器(SP、IP)等信息。G并非执行体,每个G需要绑定到P才能被调度执行。

  2. P(Processor):表示逻辑处理器(抽象的概念,并不是真正的物理CPU),对G来说P相当于CPU核,G只有绑定到P才能被调度。对M来说P提供了相关的执行环境(Context),如内存分配状态(mcache)、任务队列(G)等。P的数量决定了系统内最大可并行的G的数量(前提是:物理CPU核数 >= P 的数量)。P的数量由用户设置的runtime.GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大P的数量最大为256。(PS:在Go1.5之后GOMAXPROCS被默认设置可用的核数,而之前则默认为1)

  3. M(Machine):OS内核线程的抽象,是真正执行计算的资源在绑定有效的P后,进入schedule循环,而schedule循环的机制大致是从Global队列、P的Local队列以及wait 队列中获取。M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。M并不保留G状态,这是G可以跨M调度的基础。

  4. Sched(Go调度器):它维护有存储M和G的队列以及调度器的一些状态信息等。

调度器循环的机制是从各种队列、P的本地队列中获取G,切换到G的执行栈上并执行G的函数,调用Go Exit做清理工作并回到M,如此循环。

Go1.1之前只有G-M模型没有P,Dmitry Vyukov在Scalable Go Scheduler Design Doc提出该模型在并发伸缩性方面的问题,并通过加入P(Processors)来改进该问题。

GPM模型图:

g2.png

二、调度器调度过程

首先创建一个G对象,G对象保存到P本地队列或者是全局队列。P此时去唤醒一个M。P继续执行它的执行序。M寻找是否有空闲的P,如果有则将该G对象移动到它本身。接下来M执行一个调度循环(调用G对象->执行->清理线程->继续找新的Goroutine执行)。

M执行过程中,随时会发生上下文切换。当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。Go调度器M的栈保存在G对象上,只需要将M所需要的寄存器(SP、PC等)保存到G对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G任务还没有执行完,M可以将任务重新丢到P的任务队列,等待下一次被调度执行。当再次被调度执行时,M通过访问G的SP、PC寄存器进行现场恢复(从上次中断位置继续执行)。

1、P队列

P有两种队列:本地队列和全局队列。

  1. 本地队列: 当前P的队列,本地队列是Lock-Free,没有数据竞争问题,无需加锁处理,可以提升处理速度。

  2. 全局队列:全局队列为了保证多个P之间任务的平衡。所有M共享P全局队列,为保证数据竞争问题,需要加锁处理。相比本地队列处理速度要低于全局队列。

2、上线文切换

包含当时程序状态以及变量状态。如线程切换的时候在内核会发生上下文切换,这里的上下文就包括了当时寄存器的值,把寄存器的值保存起来,等下次该线程又得到CPU时间的时候再恢复寄存器的值,这样线程才能正确运行。

对于代码中某个值说,上下文是指这个值所在的局部(全局)作用域对象。相对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等。

3、线程清理

Goroutine被调度执行必须保证P/M进行绑定,所以线程清理只需要将P释放就可以实现线程的清理。

P被释放主要有两种情况:

  • 主动释放:当执行G任务时有系统调用,当发生系统调用时M会处于Block状态,调度器会设置一个超时时间,当超时时会将P释放。

  • 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的P/M组合,当超过系统程序设置的超时时间,会自动将P资源抢走,去执行队列的其它G任务。

三、调度策略

任务窃取(work-stealing):当每个P之间的G任务不均衡时,调度器允许从GRQ或者其他P的LRQ中获取G执行。

减少阻塞:当出现大量网络请求和IO操作导致Goroutine阻塞,Go提供了网络轮询器(netpoller)来处理网络请求和 IO 操作的问题,通过epoll实现IO多路复用。通过使用 NetPoller进行网络系统调用,调度器可以防止Goroutine在进行这些系统调用时阻塞M。这可以让M执行P的LRQ中其他的GGoroutine,而不需要创建新的 M。有助于减少操作系统上的调度负载。

g1.png

G1要进行网络系统调用,因此它被移动到网络轮询器并且处理异步网络系统调用。然后M可以从LRQ执行另外的Goroutine。此时G2就被上下文切换到M上了。

异步网络系统调用由网络轮询器完成,G1被移回到P的LRQ中。一旦G1可以在M上进行上下文切换,它负责的Go相关代码就可以再次执行。这里的最大优势是,执行网络系统调用不需要额外的M。网络轮询器使用系统线程,它时刻处理一个有效的事件循环。

四、总结

相比大多数并行设计模型Go比较有优势的设计是P上下文这个概念,如果只有G和M的对应关系,那么当G阻塞在IO上的时候,M是没有实际在工作的这样造成了资源的浪费,没有了P那么所有G的列表都放在全局,这样导致临界区太大对多核调度造成极大影响。

而Goroutine既可以用来做密集的多核计算,又可以做高并发的IO应用,做IO应用时对程序员来说和写同步阻塞一样,而实际上由于runtime的调度,底层是以同步非阻塞的方式在运行(即IO多路复用)。所以保护现场的抢占式调度和G被阻塞后传递给其他M调用的核心思想,使得Goroutine的产生。

相关文章
最新推荐