看这篇文章之前可以先看:
Go中用了两种方式实现并发:
-
多线程共享内存(加锁)
-
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存在两个影响性能的问题:
- M从全局队列中拿一定量的G来运行的时候,需要加锁,因为要保证与其他M之间的线程安全。当并发量大的时候,这个锁对性能的影响很大。
- M中的G在进行系统调用的时候,只能阻塞着等系统调用完成,这个M不能在此期间把G切出去执行另一个G。例如M从全局队列中取了任务G1、G2来执行,G1中执行系统调用阻塞了,在G1执行完之前,不能在M1上执行G2,只能将G2交给其他的M执行,这涉及到内核线程的切换,比较消耗性能。
GPM模型解决了这两个问题:
-
多了P之后,每个M只需要从对应的P那里拿到要执行的任务,不再需要加锁和其他M"争抢"任务,提高了性能。
-
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队列中。
参考地址
- Golang合辑(视频):www.bilibili.com/video/BV1hv...
- 《深入Go语言------原理、关键技术与实战》by 历冰、朱荣鑫、黄迪璇
- Share Memory By Communicating:go.dev/blog/codela...