一. 了解协程底层的前置知识
先了解了线程模型,与MPG模型,了解了go底层在针对MPG模型实现上对应的M,P,G的底层结构 mai方法启动go程序后,初始化了一些东西,底层在启动时会执行一个rt0_go,这就是Go 程序启动时执行的第一个函数,在rt0_go函数中先后执行了:
runtime.args()保存二进制文件的绝对路径到os.executablePath runtime.osinit(SB)针对系统环境初始化,例如初始化cpu核数内存也大小等getproccount(), getPageSize() runtime.schedinit(SB) 调度相关初始化, runtime.mainPC(SB) 启动监控任务用于标记抢占执行过长时间的 G,以及检测 epoll 里面是否有可执行的 G runtime.newproc()获取实际的工作协程,该函数中会调用newproc1(),主动开启协程时底层调用的也是该函数 runtime.mstart(SB)启动调度循环,由此处引出协程调度,也是一个重点,后续有专门讲解
在runtime.schedinit(SB), 该函数内部进行了一些初始化动作
调用getg()获取g0 设置m的最大数量是10000 调用mcommoninit初始化m0,分配 M 和 g0 的内存空间 调用procresize调整p的数量,也可以通过环境变量GOMAXPROCS来控制P的数量。 _MaxGomaxprocs 控制了最大的 P 数量只能是 1024 绑定m0和p 到现在已经有了m0 g0 gsignal和p相互绑定,并且有ncpu个p
接下来我们详细看一下通过go关键字开启协程后,协程调度执行的源码与底层逻辑,可以从三个角度了解协程底层
前面main方法启动已经了解到了程序的初始化 协程的创建,底层也就是newproc() go程序启动后的调度执行,也就是runtime.mstart(SB)
线程本地存储
先了解一下线程本地存储(Thread Local Storage,简称TLS),其实就是线程私有全局变量。普通的全局变量,一个线程对其进行了修改,所有线程都可以看到这个修改;线程私有全局变量不同,每个线程都有自己的一份副本,某个线程对其所做的修改不会影响到其它线程的副本。 Golang是多线程程序,当前线程正在执行的协程,显然每个线程都是不同的,这就维护在线程本地存储。所以在Golang协程切换逻辑中,随处可见『get_tls(CX)』,用于获取当前线程本地存储首地址
协程的几种状态
enum{
Gidle,
Grunnable,
Grunning,
Gsyscall,
Gwaiting,
Gmoribund,
Gdead,
} ;
调度过程简介
首先创建一个G对象,G对象保存到P本地队列或者是全局队列。P此时去唤醒一个M。P继续执行它的执行序。M寻找是否有空闲的P,如果有则将该G对象移动到它本身。接下来M执行一个调度循环(调用G对象->执行->清理线程→继续找新的Goroutine执行)。 M执行过程中,随时会发生上下文切换。当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。Go调度器M的栈保存在G对象上,只需要将M所需要的寄存器(SP、PC等)保存到G对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G任务还没有执行完,M可以将任务重新丢到P的任务队列,等待下一次被调度执行。当再次被调度执行时,M通过访问G的vdsoSP、vdsoPC寄存器进行现场恢复(从上次中断位置继续执行)
什么是 M0, G0
在main方法启动时,底层rt0_go中执行的runtime.schedinit(SB), 该函数内部进行了一些初始化动作,有初始化创建M0,G0的逻辑 M0 是什么? 当程序启动时,runtime 系统会创建一个名为 m0(也称为 scheduler thread)的特殊 M,其主要职责是协调、分派和管理所有其他 M 和 goroutine,并提供调度服务,与普通的 M 相比,m0 具有以下几个特点
m0 是 runtime 系统中唯一的 scheduler thread,它是与操作系统线程一一对应的,在运行时仅有一个实例存在。 m0 负责初始化和启动整个 runtime 系统,包括全局变量的初始化、内存管理器的初始化、垃圾回收器的初始化等。 m0 负责处理和维护跨 M 的全局资源。例如,m0 在需要进行跨 M 调度时,会检查目标 M 上的 runqueue,如果队列为空,则从其他 M 中偷取一些 goroutine 过来。 在默认情况下,m0 不参与普通的 goroutine 执行,因此不会占用 P。只有在全局没有其他空闲的 M 可以调度 goroutine 时,m0 才会使用 P 来运行一些 goroutine。
什么是G0:G可以分为三种类型,执行用户任务的普通G, 启动runtime.main用到的 G,第三种执行 runtime 下调度工作的叫 G0,每个 M 都绑定一个 G0,多个M内部的G0实际是同一个
go程序启动时创建的第一个goroutine, main函数所在的goroutine,也是整个程序的主goroutine g0 不会被放入全局的 runqueue 队列里,它也没有自己的私有栈,并且它也不会被阻塞。 g0 会跑在操作系统线程上,而不是其他的 M 上。 g0 的调度、内存管理、垃圾回收等都是由操作系统线程负责。g0 开始的时候会执行 runtime.main() 函数,从而启动Go程序的 main() 函数。 落到底层代码上go语言底层MPG模型中提供了对应的M,P,G结构体, 在M中有一个g0属性,指的就是这个g0,多个M中的g0指向的是同一个
二. 协程的创建
通过go关键字可以很方便的创建协程,Golang编译器会将go关键字替换为runtime.newproc函数调用,并且在启动go程序时rt0_go内部也会执行这个函数,函数newproc实现了协程的创建逻辑, 查看runtime下的newproc函数源码,在newproc内部调用了newproc1()函数
func newproc ( fn * funcval) {
gp := getg ( )
pc := getcallerpc ( )
systemstack ( func ( ) {
newg := newproc1 ( fn, gp, pc)
_p_ := getg ( ) . m. p. ptr ( )
runqput ( _p_, newg, true )
if mainStarted {
wakep ( )
}
} )
}
先思考一个问题: func1调用func2,func2函数栈帧入栈,func2执行完毕,func2函数栈帧出栈,重新回到func1的函数栈帧。那如果func1以及func2代表着两个协程呢?这两个函数会并行执行,还能像函数调用过程一样吗?显然是不行的,因为func1以及func2函数栈帧需要随意切换,golang自己维护了协程的用户栈, 在堆上申请一块内存,将寄存器%rsp以及寄存器%rbp指过去,从而将这块内存伪装成用户栈,寄存器%rsp以及寄存器%rbp指向了用户栈,CPU在这个用户栈上完成基于寄存器%rsp入栈以及%rbp出栈操作 了解协程创建可以分两个部分
newproc1()执行获取协程G runqput()执行,把G放到_p_的runnext运行队列或全局队列
1. newproc1() 获取G
协程创建主要逻辑由函数runtime·newproc1实现,在newproc1()中重点完成了以下工作:
执行getg()获取g0,也就是当前工作线程主线程 执行acquirem()获取m,并且设置绑定到当前线程的M实例不可抢占 执行_p_ := g .m.p.ptr()通过当前m找到p, 执行gfget(p ) 先从p的本地队列获取空闲的g,如果当前P和sched都没有空闲的g,就创建新 执行malg(_StackMin)创建g,并且新建一个2k的栈,包括分配结构体G以及协程栈,系统中的每个g都是由该函数创建而来的 执行allgadd(newg)将新建的g添加到全局变量allgs中 判断是否有参数,如果有执行memmove()将参数拷贝到获取的g goroutine栈 执行memclrNoHeapPointers()初始化设置sched,后续调度器需要依靠这些字段才能把goroutine调度到CPU上运行,重点将goexit+1设置到sched的pc属性上(为什么要+1参考下面的gostartcallfn()函数,因为当协程执行完函数后,会通过这个+1后的地址继续向下执行) 执行gostartcallfn(),执行该函数中的gostartcall() 执行casgstatus()将newg的状态变更为Grunnable 执行[p .goidcache,p .goidcacheend) 获取goid。 不够用就从sched.goidgen里面批量获取16个 releasem (m.lock–),释放M实例的不可抢占状态,返回新的G实例
func newproc1 ( fn * funcval, argp unsafe. Pointer, narg int32 , callergp * g, callerpc uintptr ) * g {
_g_ := getg ( )
if fn == nil {
_g_. m. throwing = - 1
throw ( "go of nil func value" )
}
acquirem ( )
siz := narg
siz = ( siz + 7 ) &^ 7
if siz >= _StackMin- 4 * sys. RegSize- sys. RegSize {
throw ( "newproc: function arguments too large for new goroutine" )
}
_p_ := _g_. m. p. ptr ( )
newg := gfget ( _p_)
if newg == nil {
newg = malg ( _StackMin)
casgstatus ( newg, _Gidle, _Gdead)
allgadd ( newg)
}
if newg. stack. hi == 0 {
throw ( "newproc1: newg missing stack" )
}
if readgstatus ( newg) != _Gdead {
throw ( "newproc1: new g is not Gdead" )
}
totalSize := 4 * sys. RegSize + uintptr ( siz) + sys. MinFrameSize
totalSize += - totalSize & ( sys. SpAlign - 1 )
sp := newg. stack. hi - totalSize
spArg := sp
if usesLR {
* ( * uintptr ) ( unsafe. Pointer ( sp) ) = 0
prepGoExitFrame ( sp)
spArg += sys. MinFrameSize
}
if narg > 0 {
memmove ( unsafe. Pointer ( spArg) , argp, uintptr ( narg) )
if writeBarrier. needed && ! _g_. m. curg. gcscandone {
f := findfunc ( fn. fn)
stkmap := ( * stackmap) ( funcdata ( f, _FUNCDATA_ArgsPointerMaps) )
if stkmap. nbit > 0 {
bv := stackmapdata ( stkmap, 0 )
bulkBarrierBitmap ( spArg, spArg, uintptr ( bv. n) * sys. PtrSize, 0 , bv. bytedata)
}
}
}
memclrNoHeapPointers ( unsafe. Pointer ( & newg. sched) , unsafe. Sizeof ( newg. sched) )
newg. sched. sp = sp
newg. stktopsp = sp
newg. sched. pc = funcPC ( goexit) + sys. PCQuantum
newg. sched. g = guintptr ( unsafe. Pointer ( newg) )
gostartcallfn ( & newg. sched, fn)
newg. gopc = callerpc
newg. ancestors = saveAncestors ( callergp)
newg. startpc = fn. fn
if _g_. m. curg != nil {
newg. labels = _g_. m. curg. labels
}
if isSystemGoroutine ( newg, false ) {
atomic. Xadd ( & sched. ngsys, + 1 )
}
casgstatus ( newg, _Gdead, _Grunnable)
if _p_. goidcache == _p_. goidcacheend {
_p_. goidcache = atomic. Xadd64 ( & sched. goidgen, _GoidCacheBatch)
_p_. goidcache -= _GoidCacheBatch - 1
_p_. goidcacheend = _p_. goidcache + _GoidCacheBatch
}
newg. goid = int64 ( _p_. goidcache)
_p_. goidcache++
if raceenabled {
newg. racectx = racegostart ( callerpc)
}
if trace. enabled {
traceGoCreate ( newg, newg. startpc)
}
releasem ( _g_. m)
return newg
}
gostartcallfn下的 gostartcall()
在gostartcallfn()中调用了gostartcall()
func gostartcallfn ( gobuf * gobuf, fv * funcval) {
var fn unsafe. Pointer
if fv != nil {
fn = unsafe. Pointer ( fv. fn)
} else {
fn = unsafe. Pointer ( abi. FuncPCABIInternal ( nilfunc) )
}
gostartcall ( gobuf, fn, unsafe. Pointer ( fv) )
}
gostartcall函数的主要作用有两个:
调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作; 重新设置newg.buf.pc 为需要执行的函数的地址,即fn,这里才真正让newg的ip寄存器指向fn函数,等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,从而使newg得以在cpu上真正的运行起来
func gostartcall ( buf* gobuf, fn, ctxtunsafe. Pointer) {
sp := buf. sp
if sys. RegSize > sys. PtrSize {
sp -= sys. PtrSize
* ( * uintptr ) ( unsafe. Pointer ( sp) ) = 0
}
sp -= sys. PtrSize
* ( * uintptr ) ( unsafe. Pointer ( sp) ) = buf. pc
buf. sp = sp
buf. pc = uintptr ( fn)
buf. ctxt = ctxt
}
2. runqput()
在前面看P的源码时内部存在几个重要的字段其中就有runnext, 查看runqput()函数,当获取到g后
首先通过cas判断是否有其它线程在操作runnext ,如果有重试 取出原runnext上保存的等待运行的g,放入runq队列尾部,将 newg 加入到 P 的 runnext 字段,具有最高优先级,以上防止并发安全问题都是基于cas执行的 然后执行入列操作,如果 P 的runq本地队列没有满,入队 如果本地运行队列满了调用runqputslow把g放到"全局队列",注意runqputslow会把本地运行队列中一半的g放到全局队列
待确认问题点: 在入队时,入的并不是当前新创建的g,好像是runnext上原来等待执行的g,先通过 head,tail,len(p .runq) 来判断队列是否已满,如果没满,则直接写到队列尾部,同时修改队列尾部的指针。
func runqput ( _p_ * p, gp * g, next bool ) {
if randomizeScheduler && next && fastrandn ( 2 ) == 0 {
next = false
}
if next {
retryNext:
oldnext := _p_. runnext
if ! _p_. runnext. cas ( oldnext, guintptr ( unsafe. Pointer ( gp) ) ) {
goto retryNext
}
if oldnext == 0 {
return
}
gp = oldnext. ptr ( )
}
retry:
h := atomic. LoadAcq ( & _p_. runqhead)
t := _p_. runqtail
if t- h < uint32 ( len ( _p_. runq) ) {
_p_. runq[ t% uint32 ( len ( _p_. runq) ) ] . set ( gp)
atomic. StoreRel ( & _p_. runqtail, t+ 1 )
return
}
if runqputslow ( _p_, gp, h, t) {
return
}
goto retry
}
runqputslow()函数内部
先将 P 本地队列里所有的 goroutine 加入到一个数组中,数组长度为 len(p .runq)/2 + 1,也就是 runq 的一半加上 newg。 接着,将从 runq 的头部开始的前一半 goroutine 存入 bacth 数组。然后,使用原子操作尝试修改 P 的队列头,因为出队了一半 goroutine,所以 head 要向后移动 1/2 的长度。如果修改失败,说明 runq 的本地队列被其他线程修改了,因此后面的操作就不进行了,直接返回 false,表示 newg 没被添加进来。 最后,将链表添加到全局队列中。由于操作的是全局队列,因此需要获取锁,因为存在竞争,所以代价较高。这也是本地可运行队列存在的原因
func runqputslow ( _p_ * p, gp * g, h, t uint32 ) bool {
var batch [ len ( _p_. runq) / 2 + 1 ] * g
n := t - h
n = n / 2
if n != uint32 ( len ( _p_. runq) / 2 ) {
throw ( "runqputslow: queue is not full" )
}
for i := uint32 ( 0 ) ; i < n; i++ {
batch[ i] = _p_. runq[ ( h+ i) % uint32 ( len ( _p_. runq) ) ] . ptr ( )
}
if ! atomic. Cas ( & _p_. runqhead, h, h+ n) {
return false
}
batch[ n] = gp
for i := uint32 ( 0 ) ; i < n; i++ {
batch[ i] . schedlink. set ( batch[ i+ 1 ] )
}
lock ( & sched. lock)
globrunqputbatch ( batch[ 0 ] , batch[ n] , int32 ( n+ 1 ) )
unlock ( & sched. lock)
return true
}
globrunqputbatch(): 如果全局的队列尾 sched.runqtail 不为空,则直接将其和前面生成的链表头相接,否则说明全局的可运行列队为空,那就直接将前面生成的链表头设置到 sched.runqhead。最后,再设置好队列尾,增加 runqsize
func globrunqputbatch ( ghead * g, gtail * g, n int32 ) {
gtail. schedlink = 0
if sched. runqtail != 0 {
sched. runqtail. ptr ( ) . schedlink. set ( ghead)
} else {
sched. runqhead. set ( ghead)
}
sched. runqtail. set ( gtail)
sched. runqsize += n
}
参考博客