likes
comments
collection
share

Go中的GPM调度模型

作者站长头像
站长
· 阅读数 3

Go中用了两种方式实现并发:

  1. 多线程共享内存(加锁)

  2. CSP并发模型

    Go通过GPM调度模型实现了CSP(Communicating Sequential Process,通信顺序进程)并发模型,使用了CSP中的process/channel相关理论,对应Go中的goroutine/channel。

Do not communicate by sharing memory; instead, share memory by communicating.

"不要通过共享内存来通信,而要通过通信来实现内存共享",这是Go的并发思想,粗略来说就是鼓励不用锁,而是用channel来同步访问共享资源。已经有锁了还要实现channel,并且鼓励使用channel的原因是:用channel实现的代码会比用加锁实现的代码更加简洁高效。

并发场景下首先要想到使用channel,但也不是说所有情况都必须使用channel,有时候需要使用锁。一般数据是流动的时候使用channel,比如将数据从一个goroutine传递到另一个goroutine;数据是不动的时候使用锁,比如多个goroutine访问同一个全局状态。

GPM调度模型是一种特殊的两级线程模型,模型中的G、P、M分别指:

  • G,Goroutine,应用层开启的任务。
  • P,Processer,逻辑处理器,关联G和M。
  • M,Machine,Go语言运行时开启的线程,与内核线程一一对应。

Go中的GPM调度模型

如图所示,M和内核线程是一对一的关系,M和P是多对多的关系,P和G是一对多的关系。

调度过程大致是这样的:Go的运行时(runtime)先准备好G、P、M,然后M绑定P,M从各个队列中获取G,切换到G的执行栈上,并执行G上的任务函数,调用goexit做清理工作并回到M。

上图表示的是整体的对应关系,在某一时刻,M是和一个P对应的,后续专注Go的调度说明,去掉了内核线程的部分,并将模型简化为某一时刻的状态:

Go中的GPM调度模型

其中G1是M1当前正在执行的任务,G2是M2当前正在执行的任务,此刻M1与P1绑定,M2对应着P2绑定。

如果P的本地G队列满了,等待执行的G会放到全局G队列里。

M会先从关联P持有的本地可运行G队列中获取待执行的G,比如M1从P1中依次获取G3、G4、G5执行。

M1执行完了G3、G4、G5之后,P1的可运行G队列为空了,会从调度器持有的全局队列中领取一些任务,比如领取G8、G9(浅色部分表示已经被拿走的G,其实已经不在全局G队列中而是被拿到本地G队列中了):

Go中的GPM调度模型

如果全局队列的G也执行完了,就会从其他P那里“分担”一些G运行(这称为“Work-stealing”,工作窃取),比如把P2的G7拿过来:

Go中的GPM调度模型

程序初始化的时候,会进行调度器初始化,调度器初始化的过程中会按照GOMAXPROCS这个环境变量,决定创建多少个P。

在GPM模型之前,Go用的是GM模型:

Go中的GPM调度模型

GM存在两个影响性能的问题:

  1. M从全局队列中拿一定量的G来运行的时候,需要加锁,因为要保证与其他M之间的线程安全。当并发量大的时候,这个锁对性能的影响很大。
  2. M中的G在进行系统调用的时候,只能阻塞着等系统调用完成,这个M不能在此期间把G切出去执行另一个G。例如M从全局队列中取了任务G1、G2来执行,G1中执行系统调用阻塞了,在G1执行完之前,不能在M1上执行G2,只能将G2交给其他的M执行,这涉及到内核线程的切换,比较消耗性能。

GPM模型解决了这两个问题:

  1. 多了P之后,每个M只需要从对应的P那里拿到要执行的任务,不再需要加锁和其他M“争抢”任务,提高了性能。

  2. M中的G执行系统调用阻塞的时候,对应的P可以绑定到其他M上,G队列中剩余的任务可以在其他M上运行,解决了阻塞的问题。

以下面几个例子来简单了解下GPM。

例1:在main函数中执行任务

先看看下面这段代码的执行顺序:

package main

func main() {
  println("Hello World!")
}

Go中的GPM调度模型

例2: 在goroutine中执行任务

使用go关键字创建一个goroutine,在goroutine中执行任务:

package main

func hello() {
  println("Hello World!")
}

func main() {
  go hello()
}

假设GOMAXPROCS为1,也就是只能创建1个P的情况下,执行顺序如下(能创建多个P的时候,新创建的goroutine不一定是被添加到当前P上,可能被添加到别的P上):

Go中的GPM调度模型

G2创建完之后,也就是go hello()执行完后,main()函数中的代码就执行完毕了,exit()被调用,进程结束。所以G2不会被执行。

例3: 使用time.Sleep确保goroutine中代码执行

使用time.Sleep()main()函数稍微等一会,就能正常打印"Hello World":

package main

import "time"

func hello() {
	println("Hello World!")
}

func main() {
	go hello()
	time.Sleep(time.Second)
}

执行到go hello()的步骤之前已经知道了,执行到time.Sleep(time.Second)的时候会把当前goroutine挂起。time.Sleep(time.Second)会把当前协程G1的状态从_Grunning改为_Gwaiting,并放到timer中等待一定的时间。

Go中的GPM调度模型

在G1等待被执行的期间(设置的1秒),调度器调度G2执行,打印"Hello World"。

Go中的GPM调度模型

到了设置好的睡眠时间后(1秒之后),timer会通过回调函数将G1重新设置为_Grunnable,并放回P的本地队列中:

Go中的GPM调度模型

然后G1被调度执行:

Go中的GPM调度模型

例4: 使用channel确保goroutine执行

使用channel替换time.Sleep()

package main

func hello(ch chan struct{}) {
  println("Hello Goroutine!")
  close(ch)
}

func main() {
  ch := make(chan struct{})
  go hello(ch)
  <-ch
}

Go中的GPM调度模型

channel的类型是runtime.hchan,包含了以下这些主要字段:

type hchan struct {
  // 缓冲区相关字段
  buf unsafe.Pointer
  // 元素类型
  elemtype *_type
  // 是否关闭
  closed uint32
  // 等待的接收队列
  recvq waitq
  // 等待的发送队列
  sendq waitq
  // 锁
  lock mutex
}

这个例子中使用的是无缓冲通道,没有缓冲区,且当前sendq(发送队列)中没有等待的goroutine,所以执行到<-ch的时候G1会阻塞在这里等待数据:

Go中的GPM调度模型

通过调度器调度,执行G2。(在允许创建多个P的时候,G2也可能被添加到其他M的P中被执行,图中只画了被M0执行的场景)

Go中的GPM调度模型

打印完"Hello Goroutine!"之后,执行close(ch)关闭了通道,此时通道的closed属性设置为1表示通道关闭了,等待队列里的G1接收到了元素nil,这个G1的状态从_Gwaiting被修改为_Grunnable,被放到当前P的本地G队列中:

Go中的GPM调度模型

然后G1被调度执行,最后结束进程:

Go中的GPM调度模型

time.Sleep和channel的底层都是调用runtime.gopark来实现协程让出,都是使用runtime.goready把协程恢复到可运行状态,放回到G队列中。

参考地址

  1. Golang合辑(视频):www.bilibili.com/video/BV1hv…
  2. 《深入Go语言——原理、关键技术与实战》by 历冰、朱荣鑫、黄迪璇
  3. Share Memory By Communicating:go.dev/blog/codela…