golang 从http.request中取参数_golang的协程调度硬核原理

本文深入探讨了操作系统线程的弊端与协程的优势,重点介绍了Go语言中的协程(goroutine)及其调度原理。从Go程序启动时调度器的初始化到goroutine的创建、执行、调度和栈管理,详细剖析了Go协程如何实现高并发,并对比了与其他语言协程的区别,揭示了Go在并发编程中的独特设计和性能考虑。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

8b50f0a91910c706d16b8e3adfe2bef3.png

系统线程存在的弊端

现代操作系统的架构中,都是多用户多任务模式,多任务的执行是基于系统线程完成的,首先进程是一个程序运行时的内存抽象,包含了其执行需要的全部资源,为了可以多任务执行程序,使程序在单核cpu上像多个任务同时执行一样,那么就需要多进程进行调度,最常用的就是分时调度模型,一个进程在cpu上执行一段时间后就会被通过发送中断信号 INT21 进行调度,保存各个寄存器的状态形成上下文存入进程,然后从队列中取出下一个等待执行的进程,取出它的上下文装载cpu的各个寄存器状态ESP指令等,恢复进程的执行。但是很多时候我们编写程序的时候并非是顺序执行的,我们有时需要并发执行程序,一个进程中我们需要可以同时执行多段指令代码,但是对于CPU每次只能让一段代码在流水线上执行。如果仅有进程级别的调度,那么我们永远无法实现并发编程,为了解决这个问题,伟大的计算机科学家们设计了线程,一种比进程轻量级的执行单元,从此cpu再也不认识进程了,进程不再是执行单位,而是一个资源隔离单位,一个进程内部可以有多个系统线程,每个系统线程可以作为一个独立的代码执行单元,被绑定到一个CPU的流水线上执行。这样在逻辑上一个程序的多个代码片段就可以像是可以同时执行了一样,而且线程的资源比进程资源占用少,维护的状态也少,调度切换的效率更高。这样不仅可以并发编程,性能也得到了提高。 再后来,随着摩尔定律的失效,人们开始了对多核处理器的探寻之路,程序才得以真正的并行执行,所以所谓的并发仅是一种编程方式,并非真正的并行执行,当同一个进程的线程在多个处理器上运行时,才是真正的并行。 多核架构的出现,使多线程编程成为了主流趋势,并发与并行的应用使得很多无法实现的事情变成了可能,业务规模进一步扩大,终于量变引起了质变,从前牛逼无比的多线程模型,出现了种种问题。 首先我们开始觉得线程维护的状态实在太多了,线程存在的意义是可以在一个程序中同时执行多个指令流,而维护线程的状态与数据只是一种成本。这种成本体现在要维护线程复杂的状态切换,寄存器指令,调用函数栈的存储。一个线程栈初始化默认的线程栈大小是8M,无论线程是否会使用到8M的栈空间。开辟与释放栈空间是很浪费时间的,一个线程的开辟需要消耗非常多的资源(当然比进程小的多的多).在当今动不动就上千个线程的程序里(网络通信中经常需要开辟线程),我们的资源不足!同时与团队管理很像,团队人数多的时候如何分配任务,如何调度所有人都将成为非常耗时的事情,线程也同样如此。从空间与时间上,线程本身已经成为了性能的瓶颈。

协程,大并发时代的高效工具

线程的出现可以说是并发与并行共同驱使导致的,而协程是专门为了提高并发而设计的。明白这样的本质,接下来才不会绕晕。那怎么做到这一点?首先,操作系统是如何执行一个程序的呢? 站在很宏观的角度上看,操作系统执行程序可以分为用户态与系统态,当进程启动时,默认只有一个线程,但是在多线程的程序中,应用进行了系统调用,发送了一个信号到操作系统内核中,系统内核创建了一个系统线程,关联进程,应用通过系统调用来控制系统线程的状态,通过控制他的状态,我们可以控制CPU对线程的调度行为。CPU的调度是一个复杂的过程,最简单的说每个cpu维护一个可运行队列通过时间片,来轮询可运行队列里处于就绪状态的线程,当时间片到期,则cpu会保存线程上下文,修改线程状态调度下一个线程。 协程并没有改变cpu的这个逻辑,对于协程,CPU是不关心的。协程只是一个应用级用户态的概念。 是模拟cpu的调度过程编写的一套实现逻辑与运行的线程解藕的组件。线程将业务逻辑与进程解藕,使得业务不需要随着进程进行调度,协程也是如此,使业务随着协程调度,脱离线程。但是cpu真正认识的还是线程,所以实际上协程还是跑在线程中的,只不过线程执行的是调度协程的逻辑,在恰当时机执行协程的代码逻辑。

协程真的好吗?

协程解决了,以大量并发模式写代码的问题,但软件工程至今没解决的问题就是:以复杂治理复杂. 现在假设,有A,B,C三个线程。A线程中按照上述协程理论我们定义A1,A2,A3,三个协程。同理,B,C上也有三个协程。A,B,C线程上执行的是调度这些协程的逻辑。我们想一下,是不是需要一个全局的数据结构可以让这些线程在没有协程执行的情况下可以去先获取一下待执行的协程,如果没有则休眠。那么什么数据结构是最佳选择?仿照CPU的调度,队列是最好的选择,所以我们要维护一个全局的协程队列,线程从中获取协程来执行。同时,还需要的是什么呢?调度逻辑说到底还是应用级别代码,没法利用时钟中断来终止协程的执行。无法终止执行,也就无法进行调度。所以最简单的办法就是定义一些触发调度的点,比如程序进行系统调用时,网络IO时,等等执行时间不是很确定的时候,为了不阻塞其他协程执行,同时为了防止某些在未定义的调度点处执行时间过长,我们启动一个后台监控的线程专门来监控那些执行时间过长的协程,将其强制调度(如果都使用监控线程切换,性能比较低)。协程的状态如何维护?,状态与函数调用栈的模拟都可以封装在一个结构体中,当获取这个协程的结构题,从中解析协程的状态,调用栈的现场恢复后就可以执行协程的逻辑,调度切换时,可以将其状态写入这个结构体,保存上下文。这样我们就大体实现了协程调度的逻辑,这也是协程主流的实现方法,基本支持协程的语言或框架都是这样的一个思路。但是,这里面有几个问题:首先,我如果有很多线程在执行协程调度,都去全局队列中获取或者写入协程,是不是会有多线程安全问题?说到底,真正在cpu流水线上执行的是线程,协程的调度只是增加了一个中间层。所以协程并没有使计算变快,它只是适用于数据密集型任务,并发场景而不是并行场景。 同时,增加中间层固然使开发变的简单,但也增加了复杂性,一旦出现问题,问题将更难被分析,并发程序的问题本来就很难分析,提升了并发度,问题将更加复杂化(事件驱动模式在这方面似乎更好一些)。 若以在学习go的协程之前,我们必须正确的理解协程本身,否则为自己错误的使用而犯错。

goroutine,先进的协程

终于说到我们的主角了,go的协程与其他的大众协程有着很多不同的地方,这些地方导致goroutine,似乎不像是一个简简单单的协程,跟像是一种新的事物。go中使用M,一个结构体对象来代表真实的系统线程,她的一个字段封装了一个系统线程。其他字段用于控制这个线程,实现协程调度逻辑。同时,为了防止从全局队列中取数据,每个M自身维护了一个局部的协程队列,协程在golang中以G结构体对象表示,封装了协程的执行栈,协程状态等数据。这样,通过局部缓存的方式避免了加锁从全局队列拿G造成的性能下降。每次M执行完一个G就从局部就绪队列拿一个G来执行。你看,目前来看go的协程似乎解决了问题,真正的实现了大并发编程。但是,如果他的的实现真的如此的话,那么问题将会不少。 首先,要明白所有的问题来自于我们没办法像cpu那样调度协程,对何时调度协程这个问题没有完美的方案。比如说,如果一个M欢快的执行着G,在G的逻辑中发生了系统调用,去请求内核完成一向任务,甚至是自己进行复杂耗时的运算,这种场景下假设无法调度G,那么在M中排队的其他线程将无法执行,这就造成了G的饥饿。一个G影响了所有G的执行,这似乎在大并发下可能很常见,一个G处理一年内的新闻热度统计,一个G只是想查看当前的一篇新闻,假设两个G都在同一个M上,统计G一直在执行计算,没有进入调度点,这时该怎么办?监控线程来调度吗?那这种情况非常非常多,要起很多监控线程吗?我们的设计到此似乎陷入来两难的境地。 在左右为难的时候,无论进行何种取舍都无法满足要求的时候,这就要用到设计领域的必杀技,添加中间层。 于是,Golang的设计者们决定解决协程的设计难题--协程的执行者协程的状态维护者即使变成了两个对象还是耦合的。这样就引入一个新的结构体:P,一个G的执行上下文.在计算机科学中,我们通常将那些既不是A也不是B的东西统统的称之为上下文,对于P既不是协程执行者M,也不是协程状态的维护者G,而是一个存储一个局部的协程执行就绪队列等等一系列缓存相关的数据结构,go的调度机制中,大量的应用局部缓存来避免全局竞争造成的性能损失。这样,M就不再维护状态,专心的从P中取出一个G来执行,当一个G执行时间过长的时候,我们把它从P中剔除,将P与M分离,带着P去找一个新的M. 一个很好的比喻,M就是一个地鼠,真正干活的土拨鼠;P是一个推车装载着,小地鼠需要干的活-要搬的砖,G就是砖头,小地鼠要搬的砖。接下来我们将以结合源码与这个比喻去理解go的调度机制。 点击这里,查看官方goroutine设计文档

Go程序启动时,调度器的初始化

go的标准SDk中有一个runtime包,这个包用来管理golang程序的运行时行为。我们的源码之旅从协程调度器的初始化,开始.

  • go的程序从哪里开始? mac 版本默认安装在:/usr/local/Cellar/go/1.11.4/libexec/src/runtime目录下。 有一个文件:asm_amd64.s,go是静态语言,所以底层的汇编实现会根据不同的处理器架构而有所区别,这里主要完成命令行参数的解析与整理,还有就是调度器的初始化。

        // .....省略.....CALL	runtime·args(SB) // 命令行参数初始化CALL	runtime·osinit(SB) // 系统线程初始化CALL	runtime·schedinit(SB) // 调度初始化

程序初始化时,解析完命令行参数后,当然是初始化runtime系统协程了。对于go的runtime,他将业务执行的协程与runtime完成自身功能的协程分离开来,这样做是为了更好的完成管理与隔离二者,避免在监控管理上的麻烦。所以在此时会初始化一个m0,启动一个系统线程与m0相关联,然后在启动一个runtime逻辑工作的函数栈,g0,初始大小8k,所有的runtime的系统逻辑都在此栈上完成。注意这时并没有真正的创建一个G,要知道go main包下的main函数,也会被封装在一个G中执行。但是此时并没有创建,只是创建了一个函数调用栈g0,用来执行runtime的自身逻辑,同时要注意到,上面的汇编代码是asm_amd64.s中的,这是一个引导文件。之后就进入到了,引导文件调用proc.go文件中,m0,g0是全局变量。

var (
m0 m
g0 g
raceprocctx0 uintptr
)

命令行参数与系统协程初始化后进入了初始化阶段的主要过程.首先,初始化的应该是真正的系统线程,这里默认最大允许使用的系统线程是10000个。然后初始化一个系统线程栈,在其基础上创建一个M的对象作为真正执行当下main.main函数的M,创建一个内存分配器,用来管理runtime的内存使用,创建垃圾回收协程,用来进行垃圾回收。最后根据命令行中指定的P的数量来调节P的数量大小,P在初始的时候默认是与操作系统处理器的数量相同,但是可以在命令行或程序中人为的指定。本质P都是放在一个allp的队列中,调整其大小,就是增加一些p的数量,或减少一些p的数量。

func schedinit() {// raceinit must be the first call to race detector.// In particular, it must be done before mallocinit below calls racemapshadow._g_ := getg()if raceenabled {
_g_.racectx, raceprocctx0 = raceinit()
}
sched.maxmcount = 10000 //最大开辟系统线程10000tracebackinit()moduledataverify() // 各个模块的一些检验stackinit() //栈空间的初始化mallocinit() //内存管理器初始化mcommoninit(_g_.m)cpuinit() // 多个cpu架构下的一些初始化操作alginit() // maps must not be used before this callmodulesinit() // modules功能的一些初始化typelinksinit() // uses maps, activeModulesitabsinit() // uses activeModulesmsigsave(_g_.m)
initSigmask = _g_.m.sigmaskgoargs() //命令行参数初始化goenvs() // 环境变量初始化parsedebugvars() //处理 debug参数gcinit() //gc 回收器初始化
sched.lastpoll = uint64(nanotime())// 调整P的数量procs := ncpuif n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}if procresize(procs) != nil {throw("unknown runnable goroutine during bootstrap")
}
.....

在这里真正的创建了一个G来执行main函数

	// create a new goroutine to start programMOVQ	$runtime·mainPC(SB), AX		// entryPUSHQ	AXPUSHQ	$0			// arg sizeCALL	runtime·newproc(SB)POPQ	AXPOPQ	AX

到了这里还没有开始执行业务逻辑的main函数,runtime还是没有完全初始化完毕,runtim本质就是go程序的工作环境,还需要继续创建go程序的执行条件. 这是就进入了 runtime.main()函数内,此时runtime环境大体已经创建完毕,开始逐步的开始构建执行业务逻辑的环境,开始先设置一下函数栈的最大值等一些初始化参数,以检测在什么时候栈溢出.根据处理器的架构,默认64位1G栈大小,32为250M大小。此时需要创建很关键的东西了,我们知道go的调度是在应用层设计的,为了能够做到系统级别的功能(如时钟中断),需要一个奶妈一样的线程来监控管理整个runtime的运行时的大小事务,它叫做MONSYS线程,主要监控垃圾回收与协程调度管理。

// Allow newproc to start new Ms.
mainStarted = trueif GOARCH != "wasm" { // no threads on wasm yet, so no sysmonsystemstack(func() { // systemstack函数主要作用是切换调用栈,语意为在g0系统栈上执行这个函数。newm(sysmon, nil)
})
}

此时还需要初始化一些函数,先初始化runtime包下的init函数

runtime_init() // must be before deferif nanotime() == 0 {throw("nanotime returning zero")
}

当万事具备后将真的开始执行代码了,先会实现main包下的init函数,按照main包下go源文件名称的字典顺序来执行。

fn := main_init // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtimefn()close(main_init_done)

到此将真正的执行用户的逻辑,main包下的main函数:

fn = main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtimefn()

这些函数都是编译器生成的,连接器不知道具体的地址,因此需要使用上面的这种形式进行调用。

main_main代表着真正需要执行的用户逻辑代码,当其全部执行完毕后。 runtime.main函数最后调用exit(0)正确退出go进程。

创建一个go协程源码分析

我们从 go function... 这行代码开始分析,这其中到底发生了什么?

// create a new goroutine to start programMOVQ	$runtime·mainPC(SB), AX		// entryPUSHQ	AXPUSHQ	$0			// arg sizeCALL	runtime·newproc(SB)POPQ	AXPOPQ	AX

此段代码中 runtime·newproc(SB) ,可看出一个go协程的创建是使用newproc函数实现的。

// Create a new g running fn with siz bytes of arguments.// Put it on the queue of g's waiting to run.// The compiler turns a go statement into a call to this.// Cannot split the stack because it assumes that the arguments// are available sequentially after &fn; they would not be// copied if a stack split occurred.//go:nosplitfunc newproc(siz int32, fn *funcval) {argp := add(unsafe.Pointer(&fn), sys.PtrSize)gp := getg()pc := getcallerpc()systemstack(func() {newproc1(fn, (*uint8)(argp), siz, gp, pc)
})
}

先看注释,// Create a new g running fn with siz bytes of arguments. go编译器会将go xxx 语句转换为对此函数的调用。 编译器用脑袋瞎猜,想要调用此函数从声明上看要传入两个参数,很显然应该是编译器根据go语句自动生成的,siz应该是表示函数传递的参数大小。 fn是一个funcval类型的指针,猜测也就是对函数指针,函数名,传递参数的封装,因为这些是变化的东西,只能通过参数传递,程序才能知晓。 从代码中还能看出来,getg()函数获取当前执行go语句的G对象,getcallerpc()函数获取的当前执行函数栈的PC/IP寄存器。然后调用systemstack()函数切到系统栈去完成。真正执行创建G对象的函数是newproc1()

  // Create a new g running fn with narg bytes of arguments starting// at argp. callerpc is the address of the go statement that created// this. The new g is put on the queue of g's waiting to run.func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {_g_ := getg()if fn == nil {
_g_.m.throwing = -1 // do not dump full stacksthrow("go of nil func value")
}
_g_.m.locks++ // disable preemption because it can be holding p in a local varsiz := narg
siz = (siz + 7) &^ 7// We could allocate a larger initial stack if necessary.// Not worth it: this is almost always an error.// 4*sizeof(uintreg): extra space added below// sizeof(uintreg): caller's LR (arm) or return address (x86, in gostartcall).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) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}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 // extra space in case of reads slightly beyond frame
totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlignsp := newg.stack.hi - totalSizespArg := spif usesLR {// caller's LR
*(*uintptr)(unsafe.Pointer(sp)) = 0prepGoExitFrame(sp)
spArg += sys.MinFrameSize
}if narg > 0 {memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))// This is a stack-to-stack copy. If write barriers// are enabled and the source stack is grey (the// destination is always black), then perform a// barrier copy. We do this *after* the memmove// because the destination stack may have garbage on// it.if writeBarrier.needed && !_g_.m.curg.gcscandone {f := findfunc(fn.fn)stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps))// We're in the prologue, so it's always stack map index 0.bv := stackmapdata(stkmap, 0)bulkBarrierBitmap(spArg, spArg, uintptr(narg), 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 // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fnif _g_.m.curg != nil {
newg.labels = _g_.m.curg.labels
}if isSystemGoroutine(newg) {
atomic.Xadd(&sched.ngsys, +1)
}
newg.gcscanvalid = falsecasgstatus(newg, _Gdead, _Grunnable)if _p_.goidcache == _p_.goidcacheend {// Sched.goidgen is the last allocated id,// this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch].// At startup sched.goidgen=0, so main goroutine receives goid=1.
_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)
}runqput(_p_, newg, true)if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {wakep()
}
_g_.m.locks--if _g_.m.locks == 0 && _g_.preempt { // restore the preemption request in case we've cleared it in newstack
_g_.stackguard0 = stackPreempt
}
}

这里有几个关键点,首先就是需要计算一下执行这个函数的需要的栈空间的大小。如果超过一定大小,则抛出异常不能执行。_p_ := _g_.m.p.ptr()从当前的m关联的p中获取当前的p的指针,从newg := gfget(_p_),根据当前p的指针获取p中维护的空闲的G对象,当m在这个p执行完毕一个G后不会立即将G释放,而是维护一个空闲链表来缓存复用利用G对象。如果空闲链表中真的没有,这时runtime会尝试去全局空闲链表中获取G。与G的运行队列一样,为了避免全局抢锁的竞争,则在p中维护了局部的空闲链表,在全局中维护一个大的空闲G链表,为了防止频繁加锁,每次从全局队列中会拿出一批G,最大拿32个避免分布不均匀。如果此时全局队列也没有空闲G,那么gfget(_p_)函数才会返回nil,此时才会调用newg = malg(_StackMin)真正的去创建一个G对象。首先G是一个协程状态的集合,最重要的自然就是函数栈了,此时会初始化一个2k大小的函数栈(系统线程是需要8M)。当然如果,是从空闲链表中获取的G还是需要清理一下函数栈,对其进行初始化,因为G执行完毕后不会提前清理栈空间,而是仅将扩充的栈空间释放掉(后文会说函数栈的扩容),保留2k大小就直接放到空闲链表中,在使用的时候才去清理,这是一种将计算时间分散化的思想,延迟设计。

当一个G被创建好后,对其进行各种初始化操作以后,就要开始把G放入一个正确的地方了。那么将G放在哪才能被正确的调度执行呢,我们知道G必须放在P里,p关联了M才能执行。 那么这个G是放在哪个P哪个M上?,最简单的做法就是放在当前的P和M上,这么做有什么不妥之处吗?很简单,当前的G在执行,你放在当前的P里,也就是说这个G的逻辑不会立刻执行,同时如果当前的G逻辑里要创建很多个G的话那么这个P就会存在很多个G竞争将加剧,同时不利于对P资源的分配,因为如果一个新的G不去放入在一个空闲的P(没有M的P),那么这样的调度将是不均匀的。会存在p的饥饿问题,任务调度的一个设计目标就是,让计算资源更少时间的被空闲,如此我们怎么设计调度策略呢?很简单,创建的G先去P的空闲链表中看看有没有空闲的P,有那就拿出来,然后看看有没有空闲的M有就关联上,没有就创建一个M,如果空闲P列表中没有P那么就只能放在当前的P中。这样就充分的使计算资源空闲时间最小化了。

对G的调度是模仿系统线程的,在go中对于G同样存在状态的标志符GIDLE代码G是空闲状态,放在每个P的gfree链表和全局的空闲链表中维护。通过newproc创建一个G时将进入,Grunnable一个可运行状态,此时G在P的可运行就绪队列中。当G被M真正的执行的时候就进入了Grunning运行状态,通过execute,当G进行系统调用时进入Gsyscall状态,调用park函数G将被阻赛,被放在阻塞队列中(阻赛队列一般由特定的数据结构维护,例如channel中的Lock,定时器等等),ready函数的调用将从这些由特性数据结构维护的阻赛队列中唤醒G。具体转换可以看图。事实上,park,ready,yield,preempt,execute这些函数,本质都是将G放在不同的地方即可实现,将G放到M上即可执行,将G放到可运行队列,将G放到阻塞队列,将G从M上放回就绪队列等等,所以没有啥好分析的。接下来主要分析一下exit,与syscall函数。当然,我们还有P与M需要继续分析。 

接下来,一个G执行完毕后就会调用goexit0函数来退出G的业务逻辑,在这个函数中完成了一系列G状态的变换。

// goexit continuation on g0.func goexit0(gp *g) {_g_ := getg()casgstatus(gp, _Grunning, _Gdead)if isSystemGoroutine(gp) {
atomic.Xadd(&sched.ngsys, -1)
}
gp.m = nillocked := gp.lockedm != 0
gp.lockedm = 0
_g_.m.lockedg = 0
gp.paniconfault = false
gp._defer = nil // should be true already but just in case.
gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf = nil
gp.waitreason = 0
gp.param = nil
gp.labels = nil
gp.timer = nilif gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {// Flush assist credit to the global pool. This gives// better information to pacing if the application is// rapidly creating an exiting goroutines.scanCredit := int64(gcController.assistWorkPerByte * float64(gp.gcAssistBytes))
atomic.Xaddint64(&gcController.bgScanCredit, scanCredit)
gp.gcAssistBytes = 0
}// Note that gp's stack scan is now "valid" because it has no// stack.
gp.gcscanvalid = truedropg()if GOARCH == "wasm" { // no threads yet on wasmgfput(_g_.m.p.ptr(), gp)schedule() // never returns
}if _g_.m.lockedInt != 0 {print("invalid m->lockedInt = ", _g_.m.lockedInt, "\n")throw("internal lockOSThread error")
}
_g_.m.lockedExt = 0gfput(_g_.m.p.ptr(), gp)if locked {// The goroutine may have locked this thread because// it put it in an unusual kernel state. Kill it// rather than returning it to the thread pool.// Return to mstart, which will release the P and exit// the thread.if GOOS != "plan9" { // See golang.org/issue/22227.gogo(&_g_.m.g0.sched)
}
}schedule()
}

取出当前的G对象,修改当前的G对象的状态为Gdead ,调用gfput()函数将G放回当前p的空闲队列中。

// Put on gfree list.// If local list is too long, transfer a batch to the global list.func gfput(_p_ *p, gp *g) {if readgstatus(gp) != _Gdead {throw("gfput: bad status (not Gdead)")
}stksize := gp.stack.hi - gp.stack.loif stksize != _FixedStack {// non-standard stack size - free it.stackfree(gp.stack)
gp.stack.lo = 0
gp.stack.hi = 0
gp.stackguard0 = 0
}
gp.schedlink.set(_p_.gfree)
_p_.gfree = gp
_p_.gfreecnt++if _p_.gfreecnt >= 64 {lock(&sched.gflock)for _p_.gfreecnt >= 32 {
_p_.gfreecnt--
gp = _p_.gfree
_p_.gfree = gp.schedlink.ptr()if gp.stack.lo == 0 {
gp.schedlink.set(sched.gfreeNoStack)
sched.gfreeNoStack = gp
} else {
gp.schedlink.set(sched.gfreeStack)
sched.gfreeStack = gp
}
sched.ngfree++
}unlock(&sched.gflock)
}
}

基本逻辑是这样的,计算一下G中函数栈的大小,看看在执行代码的过程中是否发生了扩容,如果发现扩容过,那么就调整函数栈的大小,释放多余的部分变为初始的大小,然后检查当前的P的gfree中会不会有超过64个G的情况,超过了那么就移动一批到全局的空闲队列中,但至少保留32个。

唤醒一个M

什么时候会唤醒一个M?在需要M的时候,什么时候需要?当G发生系统调用,当前的M需要执行系统调用,为了防止阻塞所有的P,这时P会被sysmon线程解耦,去寻找一个新的M执行,此时调用startm()函数,首先尝试唤醒空闲链表中的P,尝试减少并校验自旋计数,以便防止在没有要执行的p的时候还创建M浪费资源。

func startm(_p_ *p, spinning bool) {lock(&sched.lock)if _p_ == nil {
_p_ = pidleget()if _p_ == nil {unlock(&sched.lock)if spinning {// The caller incremented nmspinning, but there are no idle Ps,// so it's okay to just undo the increment and give up.if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {throw("startm: negative nmspinning")
}
}return
}
}mp := mget()unlock(&sched.lock)if mp == nil {var fn func()if spinning {// The caller incremented nmspinning, so set m.spinning in the new M.
fn = mspinning
}newm(fn, _p_)return
}if mp.spinning {throw("startm: m is spinning")
}if mp.nextp != 0 {throw("startm: m has p")
}if spinning && !runqempty(_p_) {throw("startm: p has runnable gs")
}// The caller incremented nmspinning, so set m.spinning in the new M.
mp.spinning = spinning
mp.nextp.set(_p_)notewakeup(&mp.park)
}

之后,从空闲M列表中获取一个空闲M,获取成功后,进行一系列的校验,是否自旋,是否还有P在执行或将要执行。最后设置自旋,设置传递来的P,调用notewakeup(&mp.park)函数,激活创建的M。如果没有获得M,则会去真正的调用newm()函数去创建一个M。

//go:nowritebarrierrecfunc newm(fn func(), _p_ *p) {mp := allocm(_p_, fn)
mp.nextp.set(_p_)
mp.sigmask = initSigmaskif gp := getg(); gp != nil && gp.m != nil && (gp.m.lockedExt != 0 || gp.m.incgo) && GOOS != "plan9" {// We're on a locked M or a thread that may have been// started by C. The kernel state of this thread may// be strange (the user may have locked it for that// purpose). We don't want to clone that into another// thread. Instead, ask a known-good thread to create// the thread for us.//// This is disabled on Plan 9. See golang.org/issue/22227.//// TODO: This may be unnecessary on Windows, which// doesn't model thread creation off fork.lock(&newmHandoff.lock)if newmHandoff.haveTemplateThread == 0 {throw("on a locked thread with no template thread")
}
mp.schedlink = newmHandoff.newm
newmHandoff.newm.set(mp)if newmHandoff.waiting {
newmHandoff.waiting = falsenotewakeup(&newmHandoff.wake)
}unlock(&newmHandoff.lock)return
}newm1(mp)
}func newm1(mp *m) {if iscgo {var ts cgothreadstartif _cgo_thread_start == nil {throw("_cgo_thread_start missing")
}
ts.g.set(mp.g0)
ts.tls = (*uint64)(unsafe.Pointer(&mp.tls[0]))
ts.fn = unsafe.Pointer(funcPC(mstart))if msanenabled {msanwrite(unsafe.Pointer(&ts), unsafe.Sizeof(ts))
}
execLock.rlock() // Prevent process clone.asmcgocall(_cgo_thread_start, unsafe.Pointer(&ts))
execLock.runlock()return
}
execLock.rlock() // Prevent process clone.newosproc(mp)
execLock.runlock()
}

此时调用allocm(_p_, fn) 函数初始化过程中checkmcount()函数校验M的数量不能超过10000个等各种参数。然后开始真的创建一个M对象,在函数逻辑中,创建了一个M对象,并创建8k栈大小的g0对象 mp.g0 = malg(8192 * sys.StackGuardMultiplier) 用于M关联的真正的系统线程的执行,真正的执行协程调度逻辑的栈空间。接下来对M进行一些外部的初始化,关联传递进来的P,最后真正的去通过系统调用创建一个系统的线程,初始化线程栈并与M对象关联。最后唤醒M的状态,开始执行调度逻辑,等待获取到G的执行。

M得到P执行G的过程

当一个M创建完毕后,开始执行调度逻辑,不断的往复循环拿到G执行,执行后回收G,再获取新的G不断的往复这个循环调度的过程,就是M关联的真正系统线程的唯一工作。 启动一个M工作的函数是 mstart()

func mstart() {_g_ := getg()osStack := _g_.stack.lo == 0if osStack {// Initialize stack bounds from system stack.// Cgo may have left stack size in stack.hi.// minit may update the stack bounds.size := _g_.stack.hiif size == 0 {
size = 8192 * sys.StackGuardMultiplier
}
_g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
_g_.stack.lo = _g_.stack.hi - size + 1024
}// Initialize stack guards so that we can start calling// both Go and C functions with stack growth prologues.
_g_.stackguard0 = _g_.stack.lo + _StackGuard
_g_.stackguard1 = _g_.stackguard0mstart1()// Exit this thread.if GOOS == "windows" || GOOS == "solaris" || GOOS == "plan9" || GOOS == "darwin" {// Window, Solaris, Darwin and Plan 9 always system-allocate// the stack, but put it in _g_.stack before mstart,// so the logic above hasn't set osStack yet.
osStack = true
}mexit(osStack)
}

该函数主要工作就是,去获取当前的G对象,然后确定G的栈边界,定义当前要执行的业务函数地址后进入mstart1()函数,进行一大堆M的延迟初始化后调用了schedule()函数,真正的进入调度循环中。

func schedule() {_g_ := getg()if _g_.m.locks != 0 {throw("schedule: holding locks")
}if _g_.m.lockedg != 0 {stoplockedm()execute(_g_.m.lockedg.ptr(), false) // Never returns.
}// We should not schedule away from a g that is executing a cgo call,// since the cgo call is using the m's g0 stack.if _g_.m.incgo {throw("schedule: in cgo")
}
top:if sched.gcwaiting != 0 {gcstopm()goto top
}if _g_.m.p.ptr().runSafePointFn != 0 {runSafePointFn()
}var gp *gvar inheritTime boolif trace.enabled || trace.shutdown {
gp = traceReader()if gp != nil {casgstatus(gp, _Gwaiting, _Grunnable)traceGoUnpark(gp, 0)
}
}if gp == nil && gcBlackenEnabled != 0 {
gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
}if gp == nil {// Check the global runnable queue once in a while to ensure fairness.// Otherwise two goroutines can completely occupy the local runqueue// by constantly respawning each other.if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)unlock(&sched.lock)
}
}if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())if gp != nil && _g_.m.spinning {throw("schedule: spinning with local work")
}
}if gp == nil {
gp, inheritTime = findrunnable() // blocks until work is available
}// This thread is going to run a goroutine and is not spinning anymore,// so if it was marked as spinning we need to reset it now and potentially// start a new spinning M.if _g_.m.spinning {resetspinning()
}if gp.lockedm != 0 {// Hands off own p to the locked m,// then blocks waiting for a new p.startlockedm(gp)goto top
}execute(gp, inheritTime)
}

首先进行一大堆的GC,STW安全点的检测。保证不会出现在STW的情况下还在调度G,从 findrunnable()函数中获取可运行的G对象。然后就是再次检测STW,自旋等操作。其目的就是协调GC问题。最后调用execute(gp, inheritTime)函数真正的执行G。 可以看出,整个循环调度就做三件事:协调GC,获取可运行的G,执行真正的G。 这里GC以后的专题博客再探讨,先来看看获取可运行的G的逻辑。

func findrunnable() (gp *g, inheritTime bool) {_g_ := getg()// The conditions here and in handoffp must agree: if// findrunnable would return a G to run, handoffp must start// an M.
top:_p_ := _g_.m.p.ptr()if sched.gcwaiting != 0 {gcstopm()goto top
}if _p_.runSafePointFn != 0 {runSafePointFn()
}if fingwait && fingwake {if gp := wakefing(); gp != nil {ready(gp, 0, true)
}
}if *cgo_yield != nil {asmcgocall(*cgo_yield, nil)
}// local runqif gp, inheritTime := runqget(_p_); gp != nil {return gp, inheritTime
}// global runqif sched.runqsize != 0 {lock(&sched.lock)gp := globrunqget(_p_, 0)unlock(&sched.lock)if gp != nil {return gp, false
}
}// Poll network.// This netpoll is only an optimization before we resort to stealing.// We can safely skip it if there are no waiters or a thread is blocked// in netpoll already. If there is any kind of logical race with that// blocked thread (e.g. it has already returned from netpoll, but does// not set lastpoll yet), this thread will do blocking netpoll below// anyway.if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {if gp := netpoll(false); gp != nil { // non-blocking// netpoll returns list of goroutines linked by schedlink.injectglist(gp.schedlink.ptr())casgstatus(gp, _Gwaiting, _Grunnable)if trace.enabled {traceGoUnpark(gp, 0)
}return gp, false
}
}// Steal work from other P's.procs := uint32(gomaxprocs)if atomic.Load(&sched.npidle) == procs-1 {// Either GOMAXPROCS=1 or everybody, except for us, is idle already.// New work can appear from returning syscall/cgocall, network or timers.// Neither of that submits to local run queues, so no point in stealing.goto stop
}// If number of spinning M's >= number of busy P's, block.// This is necessary to prevent excessive CPU consumption// when GOMAXPROCS>>1 but the program parallelism is low.if !_g_.m.spinning && 2*atomic.Load(&sched.nmspinning) >= procs-atomic.Load(&sched.npidle) {goto stop
}if !_g_.m.spinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}for i := 0; i < 4; i++ {for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {if sched.gcwaiting != 0 {goto top
}stealRunNextG := i > 2 // first look for ready queues with more than 1 gif gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {return gp, false
}
}
}
stop:// We have nothing to do. If we're in the GC mark phase, can// safely scan and blacken objects, and have work to do, run// idle-time marking rather than give up the P.if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) {
_p_.gcMarkWorkerMode = gcMarkWorkerIdleModegp := _p_.gcBgMarkWorker.ptr()casgstatus(gp, _Gwaiting, _Grunnable)if trace.enabled {traceGoUnpark(gp, 0)
}return gp, false
}// wasm only:// Check if a goroutine is waiting for a callback from the WebAssembly host.// If yes, pause the execution until a callback was triggered.if pauseSchedulerUntilCallback() {// A callback was triggered and caused at least one goroutine to wake up.goto top
}// Before we drop our P, make a snapshot of the allp slice,// which can change underfoot once we no longer block// safe-points. We don't need to snapshot the contents because// everything up to cap(allp) is immutable.allpSnapshot := allp// return P and blocklock(&sched.lock)if sched.gcwaiting != 0 || _p_.runSafePointFn != 0 {unlock(&sched.lock)goto top
}if sched.runqsize != 0 {gp := globrunqget(_p_, 0)unlock(&sched.lock)return gp, false
}if releasep() != _p_ {throw("findrunnable: wrong p")
}pidleput(_p_)unlock(&sched.lock)// Delicate dance: thread transitions from spinning to non-spinning state,// potentially concurrently with submission of new goroutines. We must// drop nmspinning first and then check all per-P queues again (with// #StoreLoad memory barrier in between). If we do it the other way around,// another thread can submit a goroutine after we've checked all run queues// but before we drop nmspinning; as the result nobody will unpark a thread// to run the goroutine.// If we discover new work below, we need to restore m.spinning as a signal// for resetspinning to unpark a new worker thread (because there can be more// than one starving goroutine). However, if after discovering new work// we also observe no idle Ps, it is OK to just park the current thread:// the system is fully loaded so no spinning threads are required.// Also see "Worker thread parking/unparking" comment at the top of the file.wasSpinning := _g_.m.spinningif _g_.m.spinning {
_g_.m.spinning = falseif int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {throw("findrunnable: negative nmspinning")
}
}// check all runqueues once againfor _, _p_ := range allpSnapshot {if !runqempty(_p_) {lock(&sched.lock)
_p_ = pidleget()unlock(&sched.lock)if _p_ != nil {acquirep(_p_)if wasSpinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}goto top
}break
}
}// Check for idle-priority GC work again.if gcBlackenEnabled != 0 && gcMarkWorkAvailable(nil) {lock(&sched.lock)
_p_ = pidleget()if _p_ != nil && _p_.gcBgMarkWorker == 0 {pidleput(_p_)
_p_ = nil
}unlock(&sched.lock)if _p_ != nil {acquirep(_p_)if wasSpinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}// Go back to idle GC check.goto stop
}
}// poll networkif netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Xchg64(&sched.lastpoll, 0) != 0 {if _g_.m.p != 0 {throw("findrunnable: netpoll with p")
}if _g_.m.spinning {throw("findrunnable: netpoll with spinning")
}gp := netpoll(true) // block until new work is available
atomic.Store64(&sched.lastpoll, uint64(nanotime()))if gp != nil {lock(&sched.lock)
_p_ = pidleget()unlock(&sched.lock)if _p_ != nil {acquirep(_p_)injectglist(gp.schedlink.ptr())casgstatus(gp, _Gwaiting, _Grunnable)if trace.enabled {traceGoUnpark(gp, 0)
}return gp, false
}injectglist(gp)
}
}stopm()goto top
}

获取当前的G,从当前的G得到当前的P对象,然后对P进行GCSTW安全点检测,协调GC。先从当前P队列中获取可运行的G对象,其中会去判断一下是否已经多次在本地队列获取了G,如果是那么对全局队列的G就很不公平,这时才会加锁去全局队列获取。当然,之后逻辑还是会加锁在全局队列中获取G,如果还是获取不到。那么就会进入工作窃取逻辑,从其他运行的M上P的可运行队列偷取一半的G放到自己的队列中来执行。 工作窃取的核心代码如下:

	for i := 0; i < 4; i++ {for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {if sched.gcwaiting != 0 {goto top
}stealRunNextG := i > 2 // first look for ready queues with more than 1 gif gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {return gp, false
}
}
}

其中runqsteal函数真正的完成窃取工作,尝试窃取四次,从全局的P队列中通过一定的随机算法选择一个P,然后移动其一半G到当前P的可运行队列中来。

// Steal half of elements from local runnable queue of p2// and put onto local runnable queue of p.// Returns one of the stolen elements (or nil if failed).func runqsteal(_p_, p2 *p, stealRunNextG bool) *g {t := _p_.runqtailn := runqgrab(p2, &_p_.runq, t, stealRunNextG)if n == 0 {return nil
}
n--gp := _p_.runq[(t+n)%uint32(len(_p_.runq))].ptr()if n == 0 {return gp
}h := atomic.Load(&_p_.runqhead) // load-acquire, synchronize with consumersif t-h+n >= uint32(len(_p_.runq)) {throw("runqsteal: runq overflow")
}
atomic.Store(&_p_.runqtail, t+n) // store-release, makes the item available for consumptionreturn gp
}

若4次窃取还没有窃取到,则进行netpol网络轮询状态,阻塞住。 若当G被获取后调用execute函数真正的执行G。其底层调用gogo汇编代码执行g对象。从g0系统栈切换为g的函数栈,对指针寄存器初始化,将goexit0()压入栈底,执行函数后,最后调用此函数,执行函数栈清理工作,将G放回空闲链表,并重新进入调度循环(切换为g0栈执行).

停止一个M

当一个M执行G时间过长,这会对P中的其他G不公平,MONSYS线程会监控执行时间过长的M,将其停止,同时当M窃取其他P中的G失败后,也会被停止休眠,系统调用时间过长也会停止M的继续执行。其具体逻辑:

func stopm() {_g_ := getg()if _g_.m.locks != 0 {throw("stopm holding locks")
}if _g_.m.p != 0 {throw("stopm holding p")
}if _g_.m.spinning {throw("stopm spinning")
}
retry:lock(&sched.lock)mput(_g_.m)unlock(&sched.lock)notesleep(&_g_.m.park)noteclear(&_g_.m.park)if _g_.m.helpgc != 0 {// helpgc() set _g_.m.p and _g_.m.mcache, so we have a P.gchelper()// Undo the effects of helpgc().
_g_.m.helpgc = 0
_g_.m.mcache = nil
_g_.m.p = 0goto retry
}acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}

获取当前的G,从G中获取当前的M,取消M的自旋状态,放入M的闲置队列.将与M关联的系统线程真正的park,将与之关联的P解绑。

栈空间的扩容

G的栈默认只有8k大小,这对于函数调用栈来说可能很小,系统线程的调用栈初始时有2M大小,因此Golang 必须要设计应对 栈空间的扩容问题,同时为了节省内存大小,还是需要在必要的时候,进行缩容。 当创建一个G时,内存分配器会从栈缓存中获取可分配栈的内存,若内存不足则从堆中分配内存,还是不足则会从系统申请新的内存,将获取的内存用来创建栈对象,本质就是利用了缓存的思想,先将内存缓存起来,提升内存分配的性能。

通过函数newstack真正的进行栈的扩容,其函数先进行一大堆的栈溢出检查,参数校验,关键代码是:

// Allocate a bigger segment and move the stack.oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize * 2if newsize > maxstacksize {print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")throw("stack overflow")
}// The goroutine must be executing in order to call newstack,// so it must be Grunning (or Gscanrunning).casgstatus(gp, _Grunning, _Gcopystack)// The concurrent GC will not scan the stack while we are doing the copy since// the gp is in a Gcopystack status.copystack(gp, newsize, true)if stackDebug >= 1 {print("stack grow done\n")
}casgstatus(gp, _Gcopystack, _Grunning)gogo(&gp.sched)

通过栈的栈顶指针与栈底指针得到老栈的大小,新栈的大小是老栈的2倍。校验新栈是否大于栈分配的最大值 var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real casgstatus(gp, _Grunning, _Gcopystack)校验当前G的状态,只有running状态的G可以进行栈扩容。 copystack(gp, newsize, true)将老栈真正的拷贝到一个指定newsize大小的新栈。此函数会负责分配新的栈空间,并完成数据复制,最后将栈老栈空间释放。同时要考虑,此栈的扩容操作是否可以并发进行。关键代码如下:

............if sync {adjustsudogs(gp, &adjinfo)
} else {// sudogs can point in to the stack. During concurrent// shrinking, these areas may be written to. Find the// highest such pointer so we can handle everything// there and below carefully. (This shouldn't be far// from the bottom of the stack, so there's little// cost in handling everything below it carefully.)
adjinfo.sghi = findsghi(gp, old)// Synchronize with channel ops and copy the part of// the stack they may interact with.
ncopy -= syncadjustsudogs(gp, used, &adjinfo)
}
...........// Adjust pointers in the new stack.gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, adjustframe, noescape(unsafe.Pointer(&adjinfo)), 0)// free old stackif stackPoisonCopy != 0 {fillstack(old, 0xfc)
}stackfree(old)

最后,栈数据被复制后,将再次调用gogo函数来在新栈上执行G的业务逻辑。

栈空间的释放

shrinkstack,函数实现栈空间的收缩,当扫描栈空间,回收G的时候,会将多余过大的栈空间进行收缩,将部分的内存返回内存缓存中。关键代码如下:

.....oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize / 2// Don't shrink the allocation below the minimum-sized stack// allocation.if newsize < _FixedStack {return
}// Compute how much of the stack is currently in use and only// shrink the stack if gp is using less than a quarter of its// current stack. The currently used stack includes everything// down to the SP plus the stack guard space that ensures// there's room for nosplit functions.avail := gp.stack.hi - gp.stack.loif used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {return
}// We can't copy the stack if we're in a syscall.// The syscall might have pointers into the stack.if gp.syscallsp != 0 {return
}if sys.GoosWindows != 0 && gp.m != nil && gp.m.libcallsp != 0 {return
}if stackDebug > 0 {print("shrinking stack ", oldsize, "->", newsize, "\n")
}copystack(gp, newsize, false)

核心逻辑:计算新栈的尺寸,是老栈的一半,然后对协程栈的使用率进行检查,如果使用的空间尺寸大于整个栈的四分之一,则不进行扩容,只有栈的使用空间小于四分之一的时候,才会进行收缩。 收缩的本质,通用是通过对栈数据的复制,在复制函数copystack中,释放老栈的空间。

go的系统调用

尽量避免系统调用,这是多线程优化的核心思想,但是不可能不进行系统调用,所谓系统调用就是给系统内核发送一个消息,将执行状态从用户态切换为系统态,通过将数据写入指定的寄存器,然后通知内核,内核读取寄存器的值,将结果写回寄存器,事件出发通知用户进程,用户进程读取寄存器内的返回结果,完成系统调用。 go 在这个基础上进行了包装,添加了一些逻辑。 有不同的逻辑表现:

  1. 一般的syscall,仅是保存现场调用系统内核,不会让出P。如果系统调用时间很长,则会由sysmon将P与M强制分离。

  2. 在系统调用时,主动让出P。

  3. 在系统调用时,记住P后主动让出,当调用结束后,优先尝试获取原配P。

sysmon 运行时,监控线程

通过newm(sysmon, nil)函数在系统栈被调用。创建一个监控线程。其中sysmon,是创建协程的函数指针,其意思就是创建一个M,M执行sysmon函数内的逻辑。在第二个参数为nil时,这个M会在始终没有P的情况下运行。这是一个go runtime留下的后门,这个线程不进行协程调度。

if GOARCH != "wasm" { // no threads on wasm yet, so no sysmonsystemstack(func() {newm(sysmon, nil)
})
}

sysmon函数代码:

func sysmon() {lock(&sched.lock)
sched.nmsys++checkdead()unlock(&sched.lock)// If a heap span goes unused for 5 minutes after a garbage collection,// we hand it back to the operating system.scavengelimit := int64(5 * 60 * 1e9)if debug.scavenge > 0 {// Scavenge-a-lot for testing.
forcegcperiod = 10 * 1e6
scavengelimit = 20 * 1e6
}lastscavenge := nanotime()nscavenge := 0lasttrace := int64(0)idle := 0 // how many cycles in succession we had not wokeup somebodydelay := uint32(0)for {if idle == 0 { // start with 20us sleep...
delay = 20
} else if idle > 50 { // start doubling the sleep after 1ms...
delay *= 2
}if delay > 10*1000 { // up to 10ms
delay = 10 * 1000
}usleep(delay)if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {lock(&sched.lock)if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {
atomic.Store(&sched.sysmonwait, 1)unlock(&sched.lock)// Make wake-up period small enough// for the sampling to be correct.maxsleep := forcegcperiod / 2if scavengelimit < forcegcperiod {
maxsleep = scavengelimit / 2
}shouldRelax := trueif osRelaxMinNS > 0 {next := timeSleepUntil()now := nanotime()if next-now < osRelaxMinNS {
shouldRelax = false
}
}if shouldRelax {osRelax(true)
}notetsleep(&sched.sysmonnote, maxsleep)if shouldRelax {osRelax(false)
}lock(&sched.lock)
atomic.Store(&sched.sysmonwait, 0)noteclear(&sched.sysmonnote)
idle = 0
delay = 20
}unlock(&sched.lock)
}// trigger libc interceptors if neededif *cgo_yield != nil {asmcgocall(*cgo_yield, nil)
}// poll network if not polled for more than 10mslastpoll := int64(atomic.Load64(&sched.lastpoll))now := nanotime()if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))gp := netpoll(false) // non-blocking - returns list of goroutinesif gp != nil {// Need to decrement number of idle locked M's// (pretending that one more is running) before injectglist.// Otherwise it can lead to the following situation:// injectglist grabs all P's but before it starts M's to run the P's,// another M returns from syscall, finishes running its G,// observes that there is no work to do and no other running M's// and reports deadlock.incidlelocked(-1)injectglist(gp)incidlelocked(1)
}
}// retake P's blocked in syscalls// and preempt long running G'sif retake(now) != 0 {
idle = 0
} else {
idle++
}// check if we need to force a GCif t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {lock(&forcegc.lock)
forcegc.idle = 0
forcegc.g.schedlink = 0injectglist(forcegc.g)unlock(&forcegc.lock)
}// scavenge heap once in a whileif lastscavenge+scavengelimit/2 < now {
mheap_.scavenge(int32(nscavenge), uint64(now), uint64(scavengelimit))
lastscavenge = now
nscavenge++
}if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
lasttrace = nowschedtrace(debug.scheddetail > 0)
}
}
}

scavengelimit := int64(5 * 60 * 1e9),定义当堆栈有空间超过5分钟没有使用,则将其内在归还给操作系统,每10ms,轮询一次网络,将长期未处理的netpoll加入到队列。每2分钟强制执行一次GC。强制回收系统调用组赛的G。 当一个G执行时间过长,Sysmon会强制进行抢占式调度,将G的可抢占标志位置1,当G运行时,发生协程栈的扩容时,编译期插入的检查可抢占标志位的代码,当可抢占时,则会将这个G状态数据写入G对象,然后放入P中,调用下一个可运行的G执行。完成抢占式调度。

自此,协程调度的代码最基本的就是这些,今后有机会,在详细讨论其中细节。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值