要搞清楚Go并发编程,就需要先清楚操作系统的线程库,因为真正的“跑腿的”是系统线程。先介绍线程实现的相关概念
线程的实现模型主要有三个
优点:
缺点:
特点:
优点:
缺点:
用户线程与内核线程(KSE)为多对多(N:M)的映射关系。
巧妙吸收了用户级线程与内核级线程的优点,一个进程内的多个线程可以分别绑定一个自己的KSE,这点与内核级线程模型相似,但是用户线程并不与KSE唯一绑定,可以多个用户线程映射到同一个KSE。
当某个KSE因为其绑定的线程阻塞被调度出CPU时,其关联的进程中其余的用户线程可以重新与其他KSE绑定。
特点:
在Go的并发编程模型中的用户线程被称之为goroutine。
很多人将goroutine理解为协程,这是不够精确的,我们所理解的传统意义上的协程属于用户级线程模型,而goroutine结合Go调度器的底层实现属于两级线程模型
Go的调度器使用G、M、P三个结构体来实现goroutine的调度,也称之为GMP模型。
G :表示goroutine。
当Goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在G对象的成员变量之中,当Goroutine被调度起来运行时,调度器代码又负责把G对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。
M:内核线程。
M结构体对象除了记录着工作线程的诸如栈的起止位置、当前正在执行的Goroutine以及是否空闲等等状态信息之外,还通过指针维持着与P结构体的实例对象之间的绑定关系。
P:表示逻辑处理器。
P维护一个局部Goroutine可运行G队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这可以大大减少锁冲突,提高工作线程的并发性,并且可以良好的运用程序的局部性原理。
一个G的执行需要P和M的支持。一个M在与一个P关联之后,就形成了一个有效的G运行环境(内核线程+上下文)。每个P都包含一个可运行的G的队列(runq)。该队列中的G会被依次传递给与本地P关联的M,并获得运行时机。
M与KSE之间总是一一对应的关系,一个M仅能代表一个内核线程。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联,而M与P、P与G之间的关联都是可变的,M与P也是一对一的关系,P与G则是一对多的关系。
Go语言的goroutine与内核级线程(KSE)相比,占用了更小的内存空间,降低了上下文开销。
g结构体部分源码(src/runtime/runtime2.go):
type g struct {
stack stack // Goroutine的栈内存范围[stack.lo, stack.hi)
stackguard0 uintptr // 用于调度器抢占式调度
m *m // Goroutine占用的线程
sched gobuf // Goroutine的调度相关数据
atomicstatus uint32 // Goroutine的状态
...
}
type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器
g guintptr // gobuf对应的Goroutine
ret sys.Uintewg // 系统调用的返回值
...
}
gobuf中保存的内容会在调度器保存或恢复上下文时使用,其中栈指针和程序计数器会用来存储或恢复寄存器中的值,改变程序即将执行的代码。
atomicstatus字段存储了当前Goroutine的状态,Goroutine主要可能处于以下9种状态:
Goroutine的状态迁移是一个十分复杂的过程,触发状态迁移的方法也很多。这里主要介绍一下比较常见的五种状态_Grunnable、_Grunning、_Gsyscall、_Gwaiting和_Gpreempted。
可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:
Go语言并发模型中的M是操作系统线程。调度器最多可以创建10000个线程,但是最多只会有GOMAXPROCS(P的数量)个活跃线程能够正常运行。在默认情况下,运行时会将 GOMAXPROCS设置成当前机器的核数,我们也可以在程序中使用runtime.GOMAXPROCS来改变最大的活跃线程数。
例如,对于一个四核的机器,runtime会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的runtime.m结构体。在大多数情况下,我们都会使用Go的默认设置,也就是线程数等于CPU数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由Go语言调度器触发,能够减少很多额外开销。
m结构体源码(部分):
type m struct {
g0 *g // 一个特殊的goroutine,执行一些运行时任务
gsignal *g // 处理signal的G
curg *g // 当前M正在运行的G的指针
p puintptr // 正在与当前M关联的P
nextp puintptr // 与当前M潜在关联的P
oldp puintptr // 执行系统调用之前使用线程的P
spinning bool // 当前M是否正在寻找可运行的G
lockedg *g // 与当前M锁定的G
}
调度器中的处理器P是线程和Goroutine的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器P的调度,每一个内核线程都能够执行多个Goroutine,它能在Goroutine进行一些I/O操作时及时让出计算资源,提高线程的利用率。
P的数量等于GOMAXPROCS,设置GOMAXPROCS的值只能限制P的最大数量,对M和G的数量没有任何约束。当M上运行的G进入系统调用导致M被阻塞时,运行时系统会把该M和与之关联的P分离开来,这时,如果该P的可运行G队列上还有未被运行的G,那么运行时系统就会找一个空闲的M,或者新建一个M与该P关联,满足这些G的运行需要。因此,M的数量很多时候都会比P多。
p结构体源码(部分):
type p struct {
// p 的状态
status uint32
// 对应关联的 M
m muintptr
// 可运行的Goroutine队列,可无锁访问
runqhead uint32
runqtail uint32
runq [256]guintptr
// 缓存可立即执行的G
runnext guintptr
// 可用的G列表,G状态等于Gdead
gFree struct {
gList
n int32
}
...
}
两级线程模型中的一部分调度任务会由操作系统之外的程序承担。在Go语言中,调度器就负责这一部分调度任务。调度的主要对象就是G、M和P的实例。每个M(即每个内核线程)在运行过程中都会执行一些调度任务,他们共同实现了Go调度器的调度功能。
运行时系统中的每个M都会拥有一个特殊的G,一般称为M的g0。M的g0不是由Go程序中的代码间接生成的,而是由Go运行时系统在初始化M时创建并分配给该M的。M的g0一般用于执行调度、垃圾回收、栈管理等方面的任务。M还会拥有一个专用于处理信号的G,称为gsignal。
除了g0和gsignal之外,其他由M运行的G都可以视为用户级别的G,简称用户G,g0和gsignal可称为系统G。Go运行时系统会进行切换,以使每个M都可以交替运行用户G和它的g0。这就是前面所说的“每个M都会运行调度程序”的原因。
除了每个M都拥有属于它自己的g0外,还存在一个runtime.g0。runtime.g0用于执行引导程序,它运行在Go程序拥有的第一个内核线程之中,这个线程也称为runtime.m0,runtime.m0的g0就是runtime.g0。