目录
一、 Go的并发机制:线程模型
二、 Go的并发机制:goroutine和channel
背景
由于个人兴趣和学习的目的,在十月份自己开始着手开发一个公链项目,当然这属于一个练手的项目,其功能也有限,只能用于学习交流,并不能作为其他用途,在后面我也打算写这项目开发过程的一系列文章,项目的框架及介绍请看GitHub地址,如果你也有兴趣一起参与开发,欢迎fork:https://github.com/ReedWe/reed。
其实公链相关的技术,没有什么新的,但需要的知识面会比较广:共识机制、密码学、账号钱包、UTXO模型、P2P、虚拟机等等,由于目前是自己在开发,所以每一个知识点都需要较为深入的研究下。目前大多区块链项目开发语言大体都在C++、Go、Rust三种,而我自己选用的开发语言是Go。
虽然是从零开始使用Go,好在这类C家族的语言之间转换还是比较容易的,只是有一些它独有的特性需要翻阅资料。项目目前开发到节点相关部分,而这部分节点负责的工作是有点多的,自然要使用到并发实现,在之前也有听过Go的并发机制,貌似和其他语言很不一样,所以我决定花几天时间去深入了解里面的原理,反正也是看书籍和资料了,索性记录下来,当做是知识沉淀吧。
并发和并行
在讨论并发(Concurrent)的时候,必定会引出另一个概念,并行(parallel),先来看一张图解:
并发,两队人交替的使用一台咖啡机,而并行有两台咖啡机。就像我们平时写了多个任务,使用了多个线程去执行,这时候可以说你的程序是并发的,但是不一定是并行的,这取决于CPU是但和还是多核。
要了解Go并发机制,先要了解它的线程模型,在开始之前还是要熟悉一下有关线程的知识。
对于一个CPU来说,每时每刻都在为调度线程(或者说切换上下文)而忙碌,CPU有两个存放线程的队列,一个是存放等待运行的线程队列,另一个是正在运行的队列。调度器会被时间划分为很小的时间片段分配给线程,这样每个线程都可以在CPU上面运行,但是同一时间只能运行一个,如果线程因为一些原因而导致他进入睡眠状态,调度器会把它在队列移除,等待它被唤醒,就会重新加入到队列,继续执行。
一般的用户程序是无法直接来操作线程的,能操作线程的一般叫做系统调用,系统调用属于内核的一部分,出于系统的安全考虑,这和普通的程序调用是不一样的,可以说操作系统划分了用户空间和内核空间,当运行用户程序是,CPU处于用户空间,这时候它的身份是用户,只能访问用户空间的资源。
线程模型
正因为划分了两个割裂的空间,所以就分别有了内核线程和用户线程。用户线程在内核线程之上,这两种线程并不都是一对一的,在实现中,需要将用户线程根据不同的策略映射到内核线程,根据他们的映射关系不同可以分为下面三种线程模:
-
用户级线程模型
在此模型下,调度器可调度的最小单元就是进程,所以内核线程不知道用户程序线程(后面统一叫用户线程)的存在,所以这些用户线程的管理由用户程序来完成,因此它们的切换速度也极快,由于进程下所有用户线程都只能对应同一内核线程,所以这种此模型有一个用户线程被阻塞(例如执行I/O操作),这个进程就会完全停下来,因为内核无法调度里面的用户线程,即使存在多个CPU,用户线程也无法分配给其他CPU执行,完全无法发挥出多核的优势,这种模型在现代的操作系统都已不再使用,由于看起来像是多个用户线程对应一个内核线程,所以这种模型也叫做(M:1)线程实现。 -
内核级线程模型
在此模型下的用户线程与内核线程一一对应,也就是说完全接管了用户线程,它也属于内核的一部分,统一由调度器来创建、终止和切换。这样就能完全发挥出多核的优势,多个线程可以跑在不同的CPU上,实现真正的并行。但也正由于一切都由内核来调度,这样大大增加了工作量,线程的切换是非常耗时的,而且创建也很用到更多的资源,所以也大大减少能创建线程的数量。由于是一对一的关系所以也叫(1:1)线程实现。 -
两层线程模型
或者叫混合线程模型(M:N),这种模型结合了以上两种的优点。同一个进程可以关联多个内核线程,这和内核级线程模型类似,不同的是用户线程与内核线程并不是一一对应关系,实现了此模型的线程库会将多个用户线程关联到一个内核线程上,当一个内核线程阻塞,可以通过线程库将部分用户线程再关联到其他的内核线程,由于允许多对多或一对一操作,所以也叫两层模型。这种设计增加了复杂性,因为管理线程的工作需要内核和用户线程库协同完成,但是也因为这后只能怪灵活的设计,使内核资源的消耗可以大大减少,同时也使线程调度效率提高(类似用户级线程模型)。这种模型的实现是要体现在语言层面的,例如Java,是否支持此模型取决于JVM的实现,当然其他两种模型也是可以支持。
Go的线程模型
Go在内核线程之上, 搭建了一个特有的两层线程模型,这个特有的两层线程模型架构主要由G(goroutine)、M(machine)、P(processer)再加一个调度器(schedt)组成。简单的介绍它们的关系:M和P的存在,都是为了执行G,而我们写的支持并发的代码块,通过G的包裹,挂到一个P上,获得了执行所需的上下文环境。等待调度器将P于M关联,而M是和核心线程对应的,这样G就能被调度到内核线程上执行。
上图实现表示关系是固定的,虚线表示不固定。M与核心线程是固定的,在它的生命周期内不会变更,而M与P,P与G关系都是可变。下面深入了解它们每一个独立个体,这部分内容时,建议和我一样,同步阅读Go的源代码https://github.com/golang/go.git这样更方便理解。
说到Go的运行时源代码,不得不提Go是实现了自举的语言, 它的运行时和编译器源代码也是用Go语言写的,我觉得自举的过程还是比较有趣的一起分享下:Go最早使用C语言编写它的编译器,然后使用Go编写了另一个程序,这个程序功能和前面的编译器一样,接着使用C语言写的编译器去编译这个程序,这样就得到了一个Go语言版本的编译器。当然实际没有说的这么简单,当中还要进行大量的测试和优化,这是个不错的想法,以后可以考虑写个demo试一试。
M(machine)
可以理解为一个M就对应一个内核线程。打开位于源码runtime/runtime2.go
看下M的结构
type m struct {
g0 *g // goroutine with scheduling stack
//mstartfn func()
curg *g // current running goroutine
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
...
spinning bool // m is out of work and is actively looking for work
}
M结构里有几十个字段,这里挑选相关的几个,字段g0
:每个M都有一个特殊的gorountine,用于执行一些运行时任务,g0在源代码中也大量出现,它并不是由Go程序创建的,而是由Go的运行时系统初始化M的时候分配的。字段mstartfn
: M的起始函数,起始就是编写go语句携带的函数。字段curg
:保存当前M正在执行的那个G,字段p
:保存正在与当前M关联的P,字段nextp
:保存M与潜在关联的P,在某些情况下,运行时重新启动了M,这时候M会把这个的P关联在一起。字段spinning
:布尔类型,如果为真,表示当前M正在不停的到处去寻找可运行的G,直到找到为止,所以字段的名字才叫自旋(这部分内容下面会详细介绍)。M在创建的时候会被加入到一个运行时的全局列表(runtime.allm
),这样做是为了方便管理。M的数量是有限制的,限制数量的初始值是10000个,正因为它与内核线程一一对应,新建一个M就等于需要一个内核线程对应,一个线程需要分配的内存以MB为单位,况且用户程序是能使用用户空间的虚拟内存,所以基本上是不会达到这个值。
P(processer)
Go运行时的调度器会根据不同的情况,让P与不同的M进行关联,尽量使那些等待运行的G都有就会在M上运行。看下P的结构:
type p struct {
...
status uint32 // one of pidle/prunning/...
m muintptr // back-link to associated m (nil if idle)
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
}
...
}
P结构的字段也有很多,这里也挑出几个,字段m
:表示与当前P关联的M,如果当前P是空闲的,此值为零值nil。字段runq
:当前P维护一个用于存放可运行的G的队列。字段runnext
:当前准备运行的G。字段gFree
:用于存放空闲的G的队列。可以看到P中有两个队列:可运行G和空闲G队列。当一个G初始化完成变得可运行后便会加入到P的runq
队列,当前一个G运行完成后,就会放到runnext
空闲队列中。在结构中不难看出,同一时间一个P只能关联一个M,这个关联关系是灵活变化的,受控于运行时的调度器。例如一个P已关联了一个M,取出队列的最新G正在运行,这时候G正在进行一些I/O操作阻塞,调度器会考虑将当前P与M分离,与其他空闲的M关联(如果没有空闲M则创建一个)。这种调度发生在Go运行时。和M一样,Go运行时也拥有一个P的列表,里面存放所有的P。
另外,调度器分别存放了空闲的M列表(schedt.midle)、空闲的P列表(schedt.pidle)和空闲的G列表(schedt.gFree),当一个P与M不再关联(可能是G已全部执行完毕,也可能是发生STW),运行时会把它放入空闲列表,等到需要新的P与M关联,再拿出来。至于schedt.gFree,上面介绍了已执行完的G存放在当前P的gFree
空闲列表,当G存放到一定数量,调度器会把它移到调度器里的空闲G列表。
当前用户使用go去启动一个函数,运行时会从P的空闲G列表获取一个,如果没有,再尝试从调度器的空闲列表获取,都没有才会创建一个新的G。可以看出,Go的线程模型中,把一切的资源尽可能的复用来提升效率。
P的最大数量默认是CPU的核数,上限是256个。因为P中已经存放了一个G的队列,我猜是Go的开发者觉得可以满足需要,设置更多的P反而在运行时增加复杂性。
P结构还有一个字段status
没介绍,表示P是有状态的,runtime2.go
文件中:
const (
// P status
_Pidle = iota
_Prunning
_Psyscall
_Pgcstop
_Pdead
)
- _Pidle:当前P处于空闲状态,没有与任何M关联。
- _Prunning:表示当前P与关联了一个M,需要注意的是关联意味着M拥有了当P,只有该M能修改P的状态。
- _Psyscall:当前P中运行的G正在进行系统调用(syscall)。
- _Pgcstop:表示进行STW(stop the world,运行时需要执行一些特殊的任务,需要停止调度器工作),需要停止调度。
- _Pdead:表示当前P已经不再使用,一个处于pdead状态的P,只有面临被释放掉资源的命运。
P的初始化函数源码runtime/proc.go:func (pp *p) init(id int32)
,初始化时status值为_Pgcstop
,初始化完成后,运行时会把它设置为_Pidle
,放入到空闲列表,等待调用,与某个M关联后状态变为_Prunning
,如果进入系统调用会转变为_Psyscall
,等到调用完成状态又变为_Pgcstop
。
G(goroutine)
Go编写一个段并发代码很简单:
go func() {fmt.Printf("go")}()
只要在普通的函数前面加一个go
关键字,运行时就会按我们的要求,对函数进行并行执行(也许)。而运行时后面的底层逻辑从G、P、M、内核线程传递。除了G,其他的上面已经介绍。
goroutine与现有的线程、协程等概念并不一样,在这里我们可以理解为一个G,它是执行上面func函数的基础。
运行时遇到go
,来到源代码runtime/proc.go
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)
if mainStarted {
wakep()
}
})
}
第二个参数是go后面的函数,接着调用了getg()
函数,获取当前的G。继续往下主要逻辑在newproc1(...)
实现。此函数先检查入参合法性,尝试从P的空闲列表获取一个G,如果没有再尝试从调度器的空闲列表获取,都没有就新创建一个G。对G进行初始化,并加入到运行时的全局G列表runtime.allgs
。go
后面的函数会被放在字段g.startpc
。G还有一个字段m
用于记录当前关联的M的指针。
G也一样是有状态的,挑选其中涉及到的一部分介绍:
const (
// G status
_Gidle = iota // 0
_Grunnable // 1
_Grunning // 2
_Gsyscall // 3
_Gwaiting // 4
_Gmoribund_unused // 5
_Gdead // 6
...
)
- _Gidle:刚的到的G(新建或从空闲列表获取),尚未初始化的G。
- _Grunnable:一个已初始化并已加入P的等待运行队列,准备运行。
- _Grunning:表示当前G正在运行。
- _Gsyscall:表示当前G正在进行系统调用。
- _Gwaiting:表示当前G正在阻塞。
-_Gdead:表示G正处于闲置状态,例如G的函数执行完成。
G的状态会根据开发者编写的go函数变化,例如函数有I/O操作、time.sleep会导致进入_Gwaiting
状态。当G进入_Gdead
状态后,其实并不会真正死亡,它可以重新初始化被复用。
普通函数如果想要并发执行,就要穿上G这个个强大的外壳,加入到P的待执行队列,到达M,通过M由最终的内核来执行。
调度器(schedt)
调度器也是Go线程模型组成的重要部分,上面多次出现这个词,本篇如没特别说明,调度器是指Go运行时下的schedt,要和内核的调度器区分开来。不过两者在职责上基本相同。
首先看下调度器的结构体,位于源代码runtime/runtime2.go
:
type schedt struct {
midle muintptr // idle m's waiting for work
pidle puintptr // idle p's
// Global cache of dead G's.
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
上面也有提到过,调度器本身也是拥有G、P、M的空闲列表,这样可以提升调度效率。
调度器启动于runtime.schedule()
,上图是调度器的一轮流程图,左边和右边的判断条件是当前M是否锁定了G,我们先来看左边的流程:M中有个lockedg
字段,G中有个lockedm
的字段,该字段保存一个G的指针。为什么要锁定G?出于某些原因,有些时候G中的代码需要在同一核心线程执行,由于M和内核线程是一对一不变,所以M锁定G即可。当前调度器遇到锁定G的情况,会调用runtime.stoplockedm()
函数来停止M,当需要唤醒时,导调用``runtime.startlockedm()函数来继续执行:
// Stops execution of the current m that is locked to a g until the g is runnable again.
// Returns with acquired P.
func stoplockedm() {
_g_ := getg()
//判断当前M是否与G锁定
if _g_.m.lockedg == 0 || _g_.m.lockedg.ptr().lockedm.ptr() != _g_.m {
throw("stoplockedm: inconsistent locking")
}
//接触当前M与P之间的关联
if _g_.m.p != 0 {
// Schedule another M to run this p.
_p_ := releasep()
handoffp(_p_)
}
incidlelocked(1)
// Wait until another thread schedules lockedg again.
mPark()
status := readgstatus(_g_.m.lockedg.ptr())
if status&^_Gscan != _Grunnable {
print("runtime:stoplockedm: lockedg (atomicstatus=", status, ") is not Grunnable or Gscanrunnable\n")
dumpgstatus(_g_.m.lockedg.ptr())
throw("stoplockedm: not runnable")
}
acquirep(_g_.m.nextp.ptr())
//停止当前M,等待被唤醒
_g_.m.nextp = 0
}
======= 唤醒M的代码`runtime.startlockedm()`=======
// Schedules the locked m to run the locked gp.
// May run during STW, so write barriers are not allowed.
//go:nowritebarrierrec
//参数gp表示被锁定的G,也就是与需要唤醒的M关联的G
func startlockedm(gp *g) {
//当前G
_g_ := getg()
mp := gp.lockedm.ptr()
if mp == _g_.m {
throw("startlockedm: locked to me")
}
if mp.nextp != 0 {
throw("startlockedm: m has p")
}
// directly handoff current P to the locked m
incidlelocked(-1)
//取消关联P,把P转给将要唤醒的M
_p_ := releasep()
mp.nextp.set(_p_)
notewakeup(&mp.park)
//因为唤醒了与gp锁定的M,所以这里停止当前M
stopm()
}
接着继续看右边的M未锁定的流程,调度器会导出寻找可执行的G,实际上这也是调度器的日常工作。调度器首先会在P和全局列表获取,如果都没有,调用runtime.findrunnable()
使M处于自旋的状态直到找到一个可执行的G,或者出现STW的情况导致调度器停止。
同一时刻只会出现一个M在自旋,而当找到一个可执行的G时,就会把这个G挂在自旋的M上。
调度器的工作远不止这些,要复杂的多,由于这里只是想写些初步的了解,就不全部记录了。
总结
在使用Go语言编写项目的时候,意识到后面需要用到并发相关的知识,听说在Go可以使用goroutine结合channel优雅的实现,这些是处于开发阶段如何合理地使用层面,如果想深入去了解它的机制原理,就需要读懂它的线程模型,而它的模型框架实现是类似两层(混合)线程模型,所以也需要读懂内核的线程模型。