Go中的GPM调度模型

看这篇文章之前可以先看:

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语言运行时开启的线程,与内核线程一一对应。

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

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

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

其中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队列中了):

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

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

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

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函数中执行任务

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

go 复制代码
package main

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

例2: 在goroutine中执行任务

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

go 复制代码
package main

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

func main() {
  go hello()
}

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

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

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

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

go 复制代码
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中等待一定的时间。

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

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

然后G1被调度执行:

例4: 使用channel确保goroutine执行

使用channel替换time.Sleep()

go 复制代码
package main

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

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

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

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

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

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

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

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

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...
相关推荐
bearpping37 分钟前
SpringBoot最佳实践之 - 使用AOP记录操作日志
java·spring boot·后端
一叶飘零_sweeeet39 分钟前
线上故障零扩散:全链路监控、智能告警与应急响应 SOP 完整落地指南
java·后端·spring
开心就好20252 小时前
不同阶段的 iOS 应用混淆工具怎么组合使用,源码混淆、IPA混淆
后端·ios
架构师沉默2 小时前
程序员如何避免猝死?
java·后端·架构
椰奶燕麦2 小时前
Windows PackageManager (winget) 核心故障排错与通用修复指南
后端
zjjsctcdl3 小时前
springBoot发布https服务及调用
spring boot·后端·https
zdl6863 小时前
Spring Boot文件上传
java·spring boot·后端
世界哪有真情3 小时前
哇!绝了!原来这么简单!我的 Java 项目代码终于被 “拯救” 了!
java·后端
RMB Player3 小时前
Spring Boot 集成飞书推送超详细教程:文本消息、签名校验、封装工具类一篇搞定
java·网络·spring boot·后端·spring·飞书
重庆小透明3 小时前
【搞定面试之mysql】第三篇 mysql的锁
java·后端·mysql·面试·职场和发展