Golang最大的特色可以说是协程(goroutine)了, 协程让本来很复杂的异步编程变得简单。
虽然现在引入了协程的语言越来越多, 但go中的协程仍然是实现的是最彻底的.
这篇文章将通过分析golang的源代码来讲解协程的实现原理.
我们先来看一下协程和线程的区别
- 内存消耗方面
一个线程的内存在 MB 级别,而协程只需要 KB 级别。 goroutine:8KB 线程:1-8MB 初始化为8KB,最大可扩张到1GB。 - 线程和 goroutine 切换调度开销方面
线程/goroutine 切换开销方面,goroutine 远比线程小 线程:涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等。 goroutine:只有三个寄存器的值修改 - PC / SP / DX.
核心概念
要理解协程的实现, 首先需要了解go中的三个非常重要的概念, 它们分别是G, M和P,
没有看过golang源代码的可能会对它们感到陌生, 这三项是协程最主要的组成部分, 它们在golang的源代码中无处不在.
G (goroutine)
G是goroutine的头文字, goroutine可以解释为受管理的轻量线程, goroutine使用go关键词创建.
举例来说, func main() { go other() }, 这段代码创建了两个goroutine,
一个是main, 另一个是other, 注意main本身也是一个goroutine.
goroutine的新建, 休眠, 恢复, 停止都受到go运行时的管理.
goroutine执行异步操作时会进入休眠状态, 待操作完成后再恢复, 无需占用系统线程,
goroutine新建或恢复时会添加到运行队列, 等待M取出并运行.
M (machine)
M是machine的头文字, 在当前版本的golang中等同于系统线程.
M可以运行两种代码:
- go代码, 即goroutine, M运行go代码需要一个P
- 原生代码, 例如阻塞的syscall, M运行原生代码不需要P
M会从运行队列中取出G, 然后运行G, 如果G运行完毕或者进入休眠状态, 则从运行队列中取出下一个G运行, 周而复始.
有时候G需要调用一些无法避免阻塞的原生代码, 这时M会释放持有的P并进入阻塞状态, 其他M会取得这个P并继续运行队列中的G.
go需要保证有足够的M可以运行G, 不让CPU闲着, 也需要保证M的数量不能过多.
P (process)
P是process的头文字, 代表M运行G所需要的资源.
一些讲解协程的文章把P理解为cpu核心, 其实这是错误的.
虽然P的数量默认等于cpu核心数, 但可以通过环境变量GOMAXPROC修改, 在实际运行时P跟cpu核心并无任何关联.
runtime.GOMAXPROCS函数设置的只会影响P的数量,但是对M (内核线程)的数量不会影响,所以runtime.GOMAXPROCS 并不是控制线程数,只能说是影响上下文环境P的数目。
P也可以理解为控制go代码的并行度的机制,
如果P的数量等于1, 代表当前最多只能有一个线程(M)执行go代码,
如果P的数量等于2, 代表当前最多只能有两个线程(M)执行go代码.
执行原生代码的线程数量不受P控制.
因为同一时间只有一个线程(M)可以拥有P, P中的数据都是锁自由(lock free)的, 读写这些数据的效率会非常的高.
数据结构
在讲解协程的工作流程之前, 还需要理解一些内部的数据结构和状态.
G的数据结构中重要的角色
- stack: 当前g使用的栈空间, 有lo和hi两个成员
- stackguard0: 检查栈空间是否足够的值, 低于这个值会扩张栈, 0是go代码使用的
- stackguard1: 检查栈空间是否足够的值, 低于这个值会扩张栈, 1是原生代码使用的
- m: 当前g对应的m
- sched: g的调度数据, 当g中断时会保存当前的pc和rsp等值到这里, 恢复运行时会使用这里的值
- atomicstatus: g的当前状态
- schedlink: 下一个g, 当g在链表结构中会使用
- preempt: g是否被抢占中
- lockedm: g是否要求要回到这个M执行, 有的时候g中断了恢复会要求使用原来的M执行 !
G的状态
- 空闲中(_Gidle): 表示G刚刚新建, 仍未初始化
- 待运行(_Grunnable): 表示G在运行队列中, 等待M取出并运行
- 运行中(_Grunning): 表示M正在运行这个G, 这时候M会拥有一个P
- 系统调用中(_Gsyscall): 表示M正在运行这个G发起的系统调用, 这时候M并不拥有P
- 等待中(_Gwaiting): 表示G在等待某些条件完成, 这时候G不在运行也不在运行队列中(可能在channel的等待队列中)
- 已中止(_Gdead): 表示G未被使用, 可能已执行完毕(并在freelist中等待下次复用)
- 栈复制中(_Gcopystack): 表示G正在获取一个新的栈空间并把原来的内容复制过去(用于防止GC扫描)
正在被初始化进行中的G是处于Grunnable状态的。一个G真正被使用是在状态为Grunnable之后。G的生命周期及状态变化如图:
M的结构
// M 结构体 type m struct { /*
1. 所有调用栈的Goroutine,这是一个比较特殊的Goroutine。
2. 普通的Goroutine栈是在Heap分配的可增长的stack,而g0的stack是M对应的线程栈。
3. 所有调度相关代码,会先切换到该Goroutine的栈再执行。
*/ g0 *g // goroutine with scheduling stack morebuf gobuf // gobuf arg to morestack divmod uint32 // div/mod denominator for arm - known to liblink // Fields not known to debuggers. procid uint64 // for debuggers, but offset not hard-coded gsignal *g // signal-handling g goSigStack gsignalStack // Go-allocated signal handling stack sigmask sigset // storage for saved signal mask tls [6]uintptr // thread-local storage (for x86 extern register) mstartfn func() //
curg *g // M 正在运行的结构体G
caughtsig guintptr // goroutine running during fatal signal
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
id int32
mallocing int32
throwing int32
preemptoff string // if != "", keep curg running on this m
locks int32
softfloat int32
dying int32
profilehz int32
helpgc int32
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
inwb bool // m is executing a write barrier
newSigstack bool // minit on C thread called sigaltstack
printlock int8
incgo bool // m is executing a cgo call
fastrand uint32
ncgocall uint64 // number of cgo calls in total
ncgo int32 // number of cgo calls currently in progress
cgoCallersUse uint32 // if non-zero, cgoCallers in use temporarily
cgoCallers *cgoCallers // cgo traceback if crashing in cgo call
park note
alllink *m // on allm
schedlink muintptr
mcache *mcache
lockedg *g // 表示与当前M锁定那个g
createstack [32]uintptr // stack that created this thread.
freglo [16]uint32 // d[i] lsb and f[i]
freghi [16]uint32 // d[i] msb and f[i+16]
fflag uint32 // floating point compare flags
locked uint32 // tracking for lockosthread
nextwaitm uintptr // next m waiting for lock
needextram bool
traceback uint8
waitunlockf unsafe.Pointer // todo go func(*g, unsafe.pointer) bool
waitlock unsafe.Pointer
waittraceev byte
waittraceskip int
startingtrace bool
syscalltick uint32
thread uintptr // thread handle
// these are here because they are too large to be on the stack
// of low-level NOSPLIT functions.
libcall libcall
libcallpc uintptr // for cpu profiler
libcallsp uintptr
libcallg guintptr
syscall libcall // stores syscall parameters on windows
mOS
}
- g0: Go运行时系统在启动之初创建的,用于执行一些运行时任务。
-
mstartfn:表示M的起始函数。其实就是我们 go 语句携带的那个函数啦。
- curg:存放当前正在运行的G的指针。
- p:指向当前与M关联的那个P。
- nextp:用于暂存于当前M有潜在关联的P。 (预联)当M重新启动时,即用预联的这个P做关联啦
- spinning:表示当前M是否正在寻找G。在寻找过程中M处于自旋状态。
- lockedg:表示与当前M锁定的那个G。运行时系统会把 一个M 和一个G锁定
M的状态
M并没有像G和P一样的状态标记, 但可以认为一个M有以下的状态:
- 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P
- 执行go代码中: M正在执行go代码, 这时候M会拥有一个P
- 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P
- 休眠中: M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 这时M并不拥有P
自旋中(spinning)这个状态非常重要, 是否需要唤醒或者创建新的M取决于当前自旋中的M的数量.
P的状态
- 空闲中(_Pidle): 当M发现无待运行的G时会进入休眠, 这时M拥有的P会变为空闲并加到空闲P链表中
- 运行中(_Prunning): 当M拥有了一个P后, 这个P的状态就会变为运行中, M运行G会使用这个P中的资源
- 系统调用中(_Psyscall): 当go调用原生代码, 原生代码又反过来调用go代码时, 使用的P会变为此状态
- GC停止中(_Pgcstop): 当gc停止了整个世界(STW)时, P会变为此状态
- 已中止(_Pdead): 当P的数量在运行时改变, 且数量减少时多余的P会变为此状态
P的初始状态就是为Pgcstop,处于这个状态很短暂,在初始化和填充P中的G队列之后,运行时系统会将其状态置为Pidle并放入调度器的空闲P列表 (runtime.sched.pidle)中。其中的P会由调度器根据实际情况进行取用。下图是P在各个状态建的流转情况:
【注意】:每个P中都有一个可运行G队列及自由G队列。自由G队列包含了很多已经完成的G,随着被运行完成的G的积攒到一定程度后,运行时系统会把其中的部分G转移的调度器的自由G队列 【runtime.sched.gfree】中。
【注意】:当我们每次用 go关键字 启用一个G的时候,运行时系统都会先从P的自由G队列获取一个G来封装我们提供的函数 (go 关键字后面的函数) ,如果发现P中的自由G过少时,会从调度器的自由G队列中移一些G过来,只有连调度器的自由G列表都弹尽粮绝的时候,才会去创建新的G。
下面我们来看一看一些核心的队列:
一句话概括三者关系:
- G需要绑定在M上才能运行;
- M需要绑定P才能运行;
下面我们看一看三者及内核调度实体【KSE】的关系:
由上图可以看出 M 与 KSE 总是 一对一 的。一个M能且仅能代表一个内核线程。 一个M的生命周期内,它会且仅会与一个KSE产生关联。
M与P以及P与G之间的关联是多变的,总是会随着实际调度的过程而改变。其中, M 与 P 总是一对一,P 与 G 总是 一对多, 而 一个 G 最终由 一个 M 来负责运行。
待续。。。。。
下一篇将讲一下协程的内存的动态扩展策略