博客主页:🏆看看是李XX还是李歘歘 🏆
🌺每天分享一些包括但不限于计算机基础、算法等相关的知识点🌺
💗点关注不迷路,总有一些📖知识点📖是你想要的💗
⛽️今天的内容是 万字长文: Go语言的GPM调度器原理及其源码 ⛽️💻💻💻
常见的并发模型有七种:
-
线程与锁
-
函数式编程
-
Clojure之道
-
actor
-
通讯顺序进程(CSP)
-
数据级并行
-
Lambda架构
Go的并发模型-CSP
Go 语言的并发同步模型来自一个叫作通信顺序进程(Communicating Sequential Processes,CSP) 的范型(paradigm),核心观念是将两个并发执行的实体通过通道channel连接起来,所有的消息都通过channel传输。CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。用于在goroutine之间同步和传递数据的关键数据类型叫作通道(channel)。
Go的调度模型-GPM
那么CSP与Go语言有什么关系呢?接下来我们来看Go语言对CSP并发模型的实现——GPM调度模型。
GPM调度模型
GPM代表了三个角色,分别是Goroutine、Processor、Machine。
-
Goroutine:就是咱们常用的用go关键字创建的执行体,它对应一个结构体g,结构体里保存了goroutine的堆栈信息
-
Machine:表示操作系统的线程
-
Processor:表示处理器,管理G和M的联系
Goroutine
Goroutine就是代码中使用go关键词创建的执行单元,也是大家熟知的有“轻量级线程”之称的协程,协程是不为操作系统所知的,它由编程语言层面实现,上下文切换不需要经过内核态,再加上协程占用的内存空间极小,所以有着非常大的发展潜力。
go func() {}()
goroutine建立在操作系统线程基础之上,它与操作系统线程之间实现了一个多对多(M:N)的两级线程模型。
这里的 M:N 是指M个goroutine运行在N个操作系统线程之上,内核负责对这N个操作系统线程进行调度,而这N个系统线程又负责对这M个goroutine进行调度和运行。
所谓的对goroutine的调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程,这些负责对goroutine进行调度的程序代码我们称之为goroutine调度器。
把内核对系统线程的调度简单的归纳为:在执行操作系统代码时,内核调度器按照一定的算法挑选出一个线程并把该线程保存在内存寄存器的值放入CPU对应的寄存器从而恢复该线程的运行。
系统线程对goroutine的调度与内核对系统线程的调度原理是一样的,实质都是通过保存和修改CPU寄存器的值来达到切换线程/goroutine的目的。
为了实现对goroutine的调度,需要引入一个数据结构来保存CPU寄存器的值以及goroutine的其它一些状态信息,在Go语言调度器源代码中,这个数据结构是一个名叫g的结构体,它保存了goroutine的所有信息,该结构体的每一个实例对象都代表了一个goroutine,调度器代码可以通过g对象来对goroutine进行调度,当goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在g对象的成员变量之中,当goroutine被调度起来运行时,调度器代码又负责把g对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。
stack结构体
stack结构体主要用来记录goroutine所使用的栈的信息,包括栈顶和栈底位置:
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
//用于记录goroutine使用的栈的起始和结束位置
type stack struct {
lo uintptr // 栈顶,指向内存低地址
hi uintptr // 栈底,指向内存高地址
}
gobuf结构体
gobuf结构体用于保存goroutine的调度信息,主要包括CPU的几个寄存器的值:
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr // 保存CPU的rsp寄存器的值
pc uintptr // 保存CPU的rip寄存器的值
g guintptr // 记录当前这个gobuf对象属于哪个goroutine
ctxt unsafe.Pointer
// 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
// 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。
ret sys.Uintreg
lr uintptr
// 保存CPU的rip寄存器的值
bp uintptr // for GOEXPERIMENT=framepointer
}
g结构体
g结构体用于代表一个goroutine,该结构体保存了goroutine的所有信息,包括栈,gobuf结构体和其它的一些状态信息:
// 前文所说的g结构体,它代表了一个goroutine
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
// 记录该goroutine使用的栈
stack stack // offset known to runtime/cgo
// 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
......
// 此goroutine正在被哪个工作线程执行
m *m // current m; offset known to arm liblink
// 保存调度信息,主要是几个寄存器的值
sched gobuf
stktopsp uintptr // 期望 sp 位于栈顶,用于回溯检查
param unsafe.Pointer // wakeup 唤醒时候传递的参数
......
// schedlink字段指向全局运行队列中的下一个g,
//所有位于全局运行队列中的g形成一个链表
schedlink guintptr
......
// 抢占调度标志,如果需要抢占调度,设置preempt为true
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
......
}
要实现对goroutine的调度,仅仅有g结构体对象是不够的,至少还需要一个存放所有(可运行)goroutine的容器,便于工作线程寻找需要被调度起来运行的goroutine,于是Go调度器又引入了schedt结构体,一方面用来保存调度器自身的状态信息,另一方面它还拥有一个用来保存goroutine的运行队列。因为每个Go程序只有一个调度器,所以在每个Go程序中schedt结构体只有一个实例对象,该实例对象在源代码中被定义成了一个共享的全局变量,这样每个工作线程都可以访问它以及它所拥有的goroutine运行队列,我们称这个运行队列为全局运行队列。
schedt结构体
schedt结构体用来保存调度器的状态信息和goroutine的全局运行队列:
type schedt struct {
// accessed atomically. keep at top to ensure alignment on 32-bit systems.
goidgen uint64
lastpoll uint64
lock mutex
// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
// sure to call checkdead().
// 由空闲的工作线程组成链表
midle muintptr // idle m's waiting for work
// 空闲的工作线程的数量
nmidle int32 // number of idle m's waiting for work
nmidlelocked int32 // number of locked m's waiting for work
mnext int64 // number of m's that have been created and next M ID
// 最多只能创建maxmcount个工作线程
maxmcount int32 // maximum number of m's allowed (or die)
nmsys int32 // number of system m's not counted for deadlock
nmfreed int64 // cumulative number of freed m's
ngsys uint32 // number of system goroutines; updated atomically
// 由空闲的p结构体对象组成的链表
pidle puintptr // idle p's
// 空闲的p结构体对象的数量
npidle uint32
nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.
// Global runnable queue.
// goroutine全局运行队列
runq gQueue
runqsize int32
......
// Global cache of dead G's.
// gFree是所有已经退出的goroutine对应的g结构体对象组成的链表
// 用于缓存g结构体对象,避免每次创建goroutine时都重新分配内存
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
......
}
既然说到全局运行队列,读者可能猜想到应该还有一个局部运行队列。确实如此,因为全局运行队列是每个工作线程都可以读写的,因此访问它需要加锁,然而在一个繁忙的系统中,加锁会导致严重的性能问题。于是,调度器又为每个工作线程引入了一个私有的局部goroutine运行队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了工作线程的并发性。在Go调度器源代码中,局部运行队列被包含在p结构体的实例对象之中,参考下面Proccessor中的p结构体
Machine
M就是对应操作系统的线程,最多会有GOMAXPROCS个活跃线程能够正常运行,默认情况下GOMAXPROCS被设置为内核数,假如有四个内核,那么默认就创建四个线程,每一个线程对应一个runtime.m结构体。线程数等于CPU个数的原因是,每个线程分配到一个CPU上就不至于出现线程的上下文切换,可以保证系统开销降到最低。
m结构体用来代表工作线程,它保存了m自身使用的栈信息,当前正在运行的goroutine以及与m绑定的p等信息,详见下面定义中的注释:
type m struct {
// g0主要用来记录工作线程使用的栈信息,在执行调度代码时需要使用这个栈
// 执行用户goroutine代码时,使用用户goroutine自己的栈,调度时会发生栈的切换
g0 *g // goroutine with scheduling stack
// 通过TLS实现m结构体对象与工作线程之间的绑定
tls [6]uintptr // thread-local storage (for x86 extern register)
mstartfn func()
// 指向工作线程正在运行的goroutine的g结构体对象
curg *g // current running goroutine
// 记录与当前工作线程绑定的p结构体对象
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
// spinning状态:表示当前工作线程正在试图从其它工作线程的本地运行队列偷取goroutine
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
// 没有goroutine需要运行时,工作线程睡眠在这个park成员上,
// 其它线程通过这个park唤醒该工作线程
park note
// 记录所有工作线程的一个链表
alllink *m // on allm
schedlink muintptr
// Linux平台thread的值就是操作系统线程ID
thread uintptr // thread handle
freelink *m // on sched.freem
......
}
M里面存了两个比较重要的东西,一个是g0,一个是curg。
-
g0:会深度参与运行时的调度过程,比如goroutine的创建、内存分配等
-
curg:代表当前正在线程上执行的goroutine。
刚才说P是负责M与G的关联,所以M里面还要存储与P相关的数据。
-
p:正在运行代码的处理器
-
nextp:暂存的处理器
-
old:系统调用之前的线程的处理器
Processor
Proccessor负责Machine与Goroutine的连接,它能提供线程需要的上下文环境,也能分配G到它应该去的线程上执行,有了它,每个G都能得到合理的调用,每个线程都不再浑水摸鱼。局部运行队列被包含在p结构体的实例对象之中,每一个运行着go代码的工作线程都会与一个p结构体的实例对象关联在一起。
p结构体
p结构体用于保存工作线程执行go代码时所必需的资源,比如goroutine的运行队列,内存分配用到的缓存等等。
type p struct {
lock mutex
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
......
// Queue of runnable goroutines. Accessed without lock.
//本地goroutine运行队列
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
runq [256]guintptr //使用数组实现的循环队列
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
runnext guintptr
// Available G's (status == Gdead)
gFree struct {
gList
n int32
}
......
}
同样的,处理器的数量也是默认按照GOMAXPROCS来设置的,与线程的数量一一对应。
结构体P中存储了性能追踪、垃圾回收、计时器等相关的字段外,还存储了处理器的待运行队列,队列中存储的是待执行的Goroutine列表。
重要的全局变量
allgs []*g // 保存所有的g allm *m // 所有的m构成的一个链表,包括下面的m0 allp []*p // 保存所有的p,len(allp) == gomaxprocs ncpu int32 // 系统中cpu核的数量,程序启动时由runtime代码初始化 gomaxprocs int32 // p的最大值,默认等于ncpu,但可以通过GOMAXPROCS修改 sched schedt // 调度器结构体对象,记录了调度器的工作状态 m0 m // 代表进程的主线程 g0 g // m0的g0,也就是m0.g0 = &g0
在程序初始化时,这些全变量都会被初始化为0值,指针会被初始化为nil指针,切片初始化为nil切片,int被初始化为数字0,结构体的所有成员变量按其本类型初始化为其类型的0值。所以程序刚启动时allgs,allm和allp都不包含任何g,m和p。
三者的关系
首先,默认启动四个线程四个处理器,然后互相绑定。
这个时候,一个Goroutine结构体被创建,在进行函数体地址、参数起始地址、参数长度等信息以及调度相关属性更新之后,它就要进到一个处理器的队列等待发车。
啥,又创建了一个G?那就轮流往其他P里面放呗,相信你排队取号的时候看到其他窗口没人排队也会过去的。
假如有很多G,都塞满了怎么办呢?那就不把G塞到处理器的私有队列里了,而是把它塞到全局队列里(候车大厅)。
除了往里塞之外,M这边还要疯狂往外取,首先去处理器的私有队列里取G执行,如果取完的话就去全局队列取,如果全局队列里也没有的话,就去其他处理器队列里偷,哇,这么饥渴,简直是恶魔啊!
如果哪里都没找到要执行的G呢?那M就会因为太失望和P断开关系,然后去睡觉(idle)了。
那如果两个Goroutine正在通过channel做一些恩恩爱爱的事阻塞住了怎么办,难道M要等他们完事了再继续执行?显然不会,M并不稀罕这对Go男女,而会转身去找别的G执行。
系统调用
如果G进行了系统调用syscall,M也会跟着进入系统调用状态,那么这个P留在这里就浪费了,怎么办呢?这点精妙之处在于,P不会傻傻的等待G和M系统调用完成,而会去找其他比较闲的M执行其他的G。
当G完成了系统调用,因为要继续往下执行,所以必须要再找一个空闲的处理器发车。
如果没有空闲的处理器了,那就只能把G放回全局队列当中等待分配。
sysmon
sysmon是我们的保洁阿姨,它是一个M,又叫监控线程,不需要P就可以独立运行,每20us~10ms会被唤醒一次出来打扫卫生,主要工作就是回收垃圾、回收长时间系统调度阻塞的P、向长时间运行的G发出抢占调度等等。
在图6-2中,可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一个goroutine并准备运行,这个goroutine就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的goroutine分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的goroutine会一直等待,直到自己被分配的逻辑处理器执行。
有时,正在运行的goroutine需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和goroutine会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个goroutine来运行。一旦被阻塞的系统调用执行完成并返回,对应的goroutine会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。
如果一个goroutine需要做一个网络I/O调用,流程上会有些不一样。在这种情况下,goroutine会和逻辑处理器分离,并移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或者写操作已经就绪,对应的goroutine就会重新分配到逻辑处理器上来完成操作。调度器对可以创建的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建10000个线程。这个限制值可以通过调用runtime/debug包的SetMaxThreads方法来更改。如果程序试图使用更多的线程,就会崩溃。
并发(concurrency)不是并行(parallelism)。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导Go语言设计的哲学。
如果希望让goroutine并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将goroutine平等分配到每个逻辑处理器上。这会让goroutine在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕Go语言运行时使用多个线程,goroutine依然会在同一个物理处理器上并发运行,达不到并行的效果。
图6-3展示了在一个逻辑处理器上并发运行goroutine和在两个逻辑处理器上并行运行两个并发的goroutine之间的区别。调度器包含一些聪明的算法,这些算法会随着Go语言的发布被更新和改进,所以不推荐盲目修改语言运行时对逻辑处理器的默认设置。如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数进行细微调整。后面会介绍如何做这种修改。
runtime包
runtime.Gosched()
让出CPU时间片,重新等待安排任务
runtime.Goexit()
退出当前协程
runtime.GOMAXPROCS(n int)
Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU内核数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU内核数。
Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU内核数数。
runtime.NumGoroutine()
查看当前程序启动的协程数量
有没有什么限制
在前面的内容中,我们针对 Go 的调度模型和 Goroutine 做了一个基本介绍和分享。
接下来我们回到主题,思考 “goroutine 太多了,会不会有什么影响”。
在了解 GMP 的基础知识后,我们要知道在协程的运行过程中,真正干活的 GPM 又分别被什么约束?
煎鱼带大家分别从 GMP 来逐步分析。
M 的限制
第一,要知道在协程的执行中,真正干活的是 GPM 中的哪一个?
那势必是 M(系统线程) 了,因为 G 是用户态上的东西,最终执行都是得映射,对应到 M 这一个系统线程上去运行。
那么 M 有没有限制呢?
答案是:有的。在 Go 语言中,M 的默认数量限制是 10000,如果超出则会报错:
GO: runtime: program exceeds 10000-thread limit
通常只有在 Goroutine 出现阻塞操作的情况下,才会遇到这种情况。这可能也预示着你的程序有问题。
若确切是需要那么多,还可以通过 debug.SetMaxThreads
方法进行设置。
G 的限制
第二,那 G 呢,Goroutine 的创建数量是否有限制?
答案是:没有。但理论上会受内存的影响,假设一个 Goroutine 创建需要 4k(via @GoWKH):
-
4k * 80,000 = 320,000k ≈ 0.3G内存
-
4k * 1,000,000 = 4,000,000k ≈ 4G内存
以此就可以相对计算出来一台单机在通俗情况下,所能够创建 Goroutine 的大概数量级别。
注:Goroutine 创建所需申请的 2-4k 是需要连续的内存块。
P 的限制
第三,那 P 呢,P 的数量是否有限制,受什么影响?
答案是:有限制。P 的数量受环境变量 GOMAXPROCS
的直接影响。
环境变量 GOMAXPROCS
又是什么?在 Go 语言中,通过设置 GOMAXPROCS
,用户可以调整调度中 P(Processor)的数量。
另一个重点在于,与 P 相关联的的 M(系统线程),是需要绑定 P 才能进行具体的任务执行的,因此 P 的多少会影响到 Go 程序的运行表现。
P 的数量基本是受本机的核数影响,没必要太过度纠结他。
那 P 的数量是否会影响 Goroutine 的数量创建呢?
答案是:不影响。且 Goroutine 多了少了,P 也该干嘛干嘛,不会带来灾难性问题。
何为之合理
在介绍完 GMP 各自的限制后,我们回到一个重点,就是 “Goroutine 数量怎么预算,才叫合理?”。
“合理” 这个词,是需要看具体场景来定义的,可结合上述对 GPM 的学习和了解。得出:
-
M:有限制,默认数量限制是 10000,可调整。
-
G:没限制,但受内存影响。
-
P:受本机的核数影响,可大可小,不影响 G 的数量创建。
Goroutine 数量在 MG 的可控限额以下,多个把个、几十个,少几个其实没有什么影响,就可以称其为 “合理”。
真实情况
在真实的应用场景中,没法如此简单的定义。如果你 Goroutine:
-
在频繁请求 HTTP,MySQL,打开文件等,那假设短时间内有几十万个协程在跑,那肯定就不大合理了(可能会导致 too many files open)。
-
常见的 Goroutine 泄露所导致的 CPU、Memory 上涨等,还是得看你的 Goroutine 里具体在跑什么东西。
还是得看 Goroutine 里面跑的是什么东西。
参考:
《GO语言实战》