CPU寄存器
- 通用寄存器: rax,rbx,rcx,rdx,rsi,rdi,rbp,rsp,r8-r15 64位,可以作为32/16/8位寄存器使用,需要换个名字,如eax,ax,al/ah
- 程序计数器: rip,存放下一条即将执行的指令地址 64位
- 段寄存器:fs和gs。一般用来实现线程本地存储TLS。 16位
内存
一个内存单元是1字节,任何大于一个字节的变量在内存中都存储在相邻连续的的几个内存单元之中。存储模式有两种(存储模式和CPU相关):
- 大端:数据的高字节保存在内存的低地址中,低字节保存在高地址中
- 小端:数据的高字节保存在内存的高地址中,低字节保存在低地址中
函数调用栈
进程在内存中的布局: 从高到低 内核->栈->堆->全局数据->代码->系统保留区
- 代码:能被CPU执行的机器代码(指令)和只读数据(如字符串常量),程序加载后该区域大小不变化
- 全局数据:程序的全局变量,静态变量(C中有Go不包含),程序加载后大小不变
- 堆: 程序运行时动态分配的内存,由内存分配器管理。大小随程序运行而变化。C++代码必须小心处理内存分配和释放,GO有垃圾回收器。
栈主要用来:保存函数局部变量;向被调用函数传参;返回函数的返回值;保存函数的返回地址(指从被调用函数返回后调用者应该继续执行的指令地址)
位于栈内存中的函数局部变量所使用的内存随函数调用而分配,随函数返回后自动释放。rsp寄存器始终指向栈顶,rbp寄存器指向函数栈帧的起始位置。
即便是同一个函数,每次调用都会产生一个不同的栈帧,因此对于递归要考虑是否存在栈溢出的风险。
汇编指令
AT&T格式的汇编指令格式:
- 寄存器名前要加%作为前缀
- 有2个操作数的,第一个为源操作数,第二个为目的操作数。
- 立即操作数前面$前缀
- 寄存器间接寻址的格式为offset(%register),offset为0时省去。 括号也省去时表示直接寻址
常用指令:
- add/sub
- call/ret call首先会把rip寄存器的值入栈,然后设置rip值为目标地址。ret指令从被调用函数返回调用函数,实现原理是把call入栈的返回地址返回弹出给rip寄存器
- jmp/je/jle/jge 跳转指令
- push/pop指令,自动修改rsp寄存器
- leave 指令没有操作数,一般放在函数尾部ret指令之前,用于调整rsp和rbp
GO汇编语言
引入了几个没有任何硬件寄存器对应的虚拟寄存器:
- FP虚拟寄存器: 主要用来引用函数参数,指向调用者的栈帧
- SB寄存器:保存程序地址空间的起始地址。即代码区的起始地址。主要用来定位全局符号
操作码: 寄存器操作码没有%前缀,且寄存器名称用大写
操作数宽度: 寄存器名称没有位数之分,操作码带后缀B(8) W(16) D(32) Q(64)
TEXT runtime·gogo(SB), NOSPLIT, $16-8
- TEXT runtime·gogo(SB) 在代码区定义一个名称为gogo的全局函数,函数属于runtime包
- NOSPLIT 编译器不要在此函数中插入栈是否溢出的代码
- $16-8 函数栈帧大小为16字节,参数和返回值一共占用8字节。
从汇编层面看函数调用的实现原理
函数调用过程:
- 参数传递:GCC编译器的C、C++代码一般通过寄存器传递参数,在AMD64 linux平台,gcc约定函数调用前面6个参数分别通过rdi rsi rdx r10 r8 r9传递。GO语言通过栈传递给别调用函数,最后一个参数最先入栈。参数在调用者的栈帧中,被调用函数通过rsp加一定的偏移量来获取参数;
- call指令负责把执行call指令的rip寄存器入栈 (函数返回地址)
- GCC通过rbp+偏移量的方式来访问局部和临时变量。GO编译器则通过rsp寄存器加偏移量的方式
- ret指令负责将call指令入栈的返回地址出站给rip,从而实现从被调用函数返回后继续执行
- GCC使用rax寄存器返回函数的返回值,GO语言使用栈返回函数调用的返回值。
main函数
push %rbp
mov %rsp,%rbp
sub $0x20,%rsp 栈给函数局部变量和临时变量预留32字节栈空间
普通函数开始
push %rbp
mov %rsp,%rbp
leaveq 相当于:
mov %rbp,%rsp
pop %rbp
系统调用
系统调用是指使用类似函数调用的方式调用操作系统提供的API。
用户代码调用操作系统API不是通过函数名调用,而是需要根据操作系统为每个API提供一个整型编号来调用。AMD64 Linux约定在系统调用时用rax寄存器存放该编号,同时约定使用rdi rsi rdx r10 r8 r9来传递前6个系统调用参数。
mov 0x10(%rsp),%rdi #第1个参数
mov 0x18(%rsp),%rsi #第2个参数
mov 0x20(%rsp),%rdx #第3个参数
mov 0x28(%rsp),%r10 #第4个参数
mov 0x30(%rsp),%r8 #第5个参数
mov 0x38(%rsp),%r9 #第6个参数
mov 0x8(%rsp),%rax #系统调用编号 rax = 267,表示调用openat系统调用
syscall #系统调用指令,进入Linux内核
操作系统线程及线程调度
C语言中一般使用pthread线程库,其创建的用户态线程其实就是Linux内核所支持的线程。和Go语言工作线程一样,都是由Linux内核负责管理和调度。
Go语言在操作系统线程之上又做了Goroutine,实现了一个二级线程模型。
什么时候会发生调度?
- 用户程序使用系统调用进入操作系统内核;
- 硬件中断。硬件中断由操作系统提供,当硬件发生中断时,就会执行操作系统代码。其中比较重要的是时钟中断,这是操作系统发起抢占调度的基础。
调度的时候会做哪些事情?
操作系统把不同的线程调度到同一个CPU上运行,每个线程都会使用CPU的寄存器。所以操作系统把线程B调度运行时首先把之前的A线程所使用的寄存器值保存在内存。然后把保存在内存中的B线程的寄存器值放回CPU寄存器,接着线程B恢复之前状态运行。
线程调度时操作系统还需要保存指令指针寄存器rip以及与栈相关的rsp和rbp。所以恢复寄存器的值相当于改变的CPU下一条需要执行的指令,同时切换了函数调用栈。
因此可以对线程进行如下定义: 操作系统线程是由内核负责调度且拥有自己私有的一组寄存器值和栈的执行流。
线程本地存储及实现原理
TLS,其实就是存储线程私有的全局变量。 普通的全局变量在多线程中共享,而线程的私有全局变量是其私有财产,每个线程都有一个副本,线程对其修改只会修改到自己的副本。
int g = 0;
void* start(void* arg){
printf("start, g[%p]: %d \n", &g, g) // print start, g[0x601064] : 100
g++;
return NULL;
}
int main(int argc, char* argv[]) {
pthread_t tid;
g = 100;
pthread_create(&tid, NULL, start, NULL); //start thread to run start()
pthread_join(tid, NULL); // wait thread finish
printf("main, g[%p]: %d \n", &g, g) // print main, g[0x601064] : 101
return 0;
}
------------------------------------------
__thread int g = 0;
void* start(void* arg){
printf("start, g[%p]: %d \n", &g, g) // print start, g[0x7f0181b046fc] : 0
g++;
return NULL;
}
int main(int argc, char* argv[]) {
pthread_t tid;
g = 100; // => 0x0000000000400793 <+30>:movl $0x64,%fs:0xfffffffffffffffc fs段基址-4
pthread_create(&tid, NULL, start, NULL); //start thread to run start()
pthread_join(tid, NULL); // wait thread finish
printf("main, g[%p]: %d \n", &g, g) // print main, g[0x7f01823076fc] : 100
return 0;
}
子线程fs段基地址为0x7f36757c8700,g的地址为0x7f36757c86fc,它正好是基地址 - 4
主线程fs段基地址为0x7f3675fcb700,g的地址为0x7f3675fcb6fc,它也是基地址 - 4
gcc编译器(其实还有线程库以及内核的支持)使用了CPU的fs段寄存器来实现线程本地存储,不同的线程中fs段基地址是不一样的
goroutine简介
操作系统线程有两个问题:
- 创建和切换开销过大: 都需要进入内核,存在性能消耗
- 内存使用过大:内核在创建线程时都会默认为其分配一个较大的栈内存(虚拟地址空间),多数情况下用不了。栈内存空间创建后就不会变化,在某些场景下存在溢出风险。
用户态goroutine则相对轻量级:
- 用户态,创建和切换无需进入内核,开销较小;
- 启动时默认栈为2K,多数情况足够,不够还可以动态扩容和收缩。
goroutine建立在操作系统线程基础上,与操作系统线程实现了多对多的两级线程模型。对goroutine的调度就是程序代码按照一定的算法在适当的时候挑选合适的goroutine并放到CPU上运行的过程。
调度的实质同样也是,通过保存和修改CPU寄存器的值来达到切换线程 goroutine的目的。
为了实现需要引入一个数据结构来保存cpu寄存器的值以及goroutine的其他状态信息。在Go调度器源码中,这个数据结构是一个g的结构体,保存了goroutine的所有信息。一个实例对象对应了一个goroutine,调度器代码可以通过g对象来对goroutine进行调度。
仅仅有g结构体对象是不够的,至少还需要一个存放所有goroutine的容器。Go调度器引入schedt结构体,一方面保存调度器自身状态,另一方面还用来保存goroutine的运行队列。 因为每个Go程序中schedt结构体只有一个实例对象,共享的全局变量,每个工作线程都可以访问它,因此它拥有的运行队列为全局运行队列。
全局队列访问需要频繁加锁,因此调度器又为每个工作线程引入一个私有的局部goroutine运行队列。该局部队列被包含在p结构体的实例对象中。每个运行着go代码的工作线程都会与一个p结构体的实例对象关联在一起。
代表工作线程的是m结构体,每个工作线程都有唯一一个m结构体的实例对象与之对应。m结构体对象除了记录工作线程的诸如栈的起止位置、当前正在执行的goroutine以及是否空闲等状态信息,还通过指针维持着与p结构体的实例对象之间的绑定关系。
通过TLS,每个工作线程拥有各自私有的m结构体全局变量,因此能在不同的工作线程中使用相同的全局变量名来访问不同的m结构体对象。
for i = 0; i < N; i++ {
create_os_thread(schedule)
}
ThreadLocal self *m //定义一个线程私有全局变量,一个指向m结构体对象的指针
schedule(){
self = initm()
if(self.p.runqueue is empty) {
g = find_a_runnable_goroutine_from_global_runqueue()
}else {
g = find_a_runnable_goroutine_from_local_runqueue()
}
run_g(g)
save_status_of_g(g)
}
程序入口 JMP _rt0_amd64(SB), 这个符号中分别执行了下列步骤
TEXT _rt0_amd64(SB), NOSPLIT, $-8
MOVQ 0(SP), DI //argc
MOVQ 8(SP), SI //argv
JMP runtime.rt0_go(SB) //rt0_go函数完成了go程序启动时的所有初始化工作
初始化g0,g0主要作用是提供一个栈供runtime代码执行,这里主要对g0的几个与栈有关的成员进行初始化。 栈大约64K,SP-641024+104 ~ SP, 其中stackguard1,stackguard0,stack.hi均指向SP-641024+104, stack.hi指向SP
主线程和m0绑定,首先调用settls函数初始化主线程的TLS,把m0和主线程关联在一起。验证TLS功能是否正常,不正常直接abort退出程序。主线程通过get_tls可以获取g0,通过g0的m成员又可以找到m0
//把m0和g0关联起来m0->g0 =g0,g0->m =m0
// save m->g0 =g0
MOVQ CX, m_g0(AX) //m0.g0 = &g0
// save m0 to g0->m
MOVQ AX, g_m(CX) //g0.m = &m0
初始化m0:接下来命令行参数处理完后调用osinit函数获取CPU核数量并保存在全局变量ncpu中。全局变量sched.maxmcount=10000,最多启动1w个工作线程。 mcommoninit将m0放入全局链表allm之中,确保GC不会自动回收。然后继续调用procresize创建和初始化p结构体对象。会创建指定个数(CPU核数和GOMAXPROCS决定)的p结构体对象,然后放到allp全局变量中。并把m0和allp[0]绑定在一起。
m0.p = allp[0]
allp[0].m = &m0
至此,m0,g0,和m需要的p完全关联在一起。
其中,procresize初始化allp流程如下
- 使用make([]*p, nprocs)初始化全局变量allp
- 循环创建并初始化nprocs个p结构体对象,并依次放入allp切片中
- 把m0和allp[0]绑定在一起,即m0.p=allp[0],allp[0].m=m0
- 把除了allp[0]之外的所有p放入全局变量sched.pidle空闲队列之中
创建main goroutine
schedinit完成调度系统初始化后,返回到rt0_go函数开始调用newproc()创建一个新的goroutine用于执行main函数。
newproc有两个参数,第二个参数fn是新建的goroutine将从这个fn函数开始执行,第一个参数是fn函数的参数以字节为单位的大小。
newproc是对newproc1的一个包装,主要获取fn函数第一个参数的地址,另一个使用systemstack函数切换到g0栈上(初始化场景本来就在g0,无需切换)
newproc1第一个参数fn,第二个参数是fn函数第一个参数的地址,第三个参数是fn参数以字节为单位的大小。其代码主要:从堆上分配一个g结构体对象newg,并为其分配一个2k的栈,并设置好栈成员。然后把newg需要执行的函数参数从newproc函数的栈拷贝到newg栈中。 然后对newg的sched成员初始化,该成员包含了调度器代码在调度goroutine到CPU运行时所必须的一些信息。其中sched的sp表示newg被调度运行时应该使用的栈的栈顶,pc成员表示当newg调度运行时从这个地址开始执行指令。
然而代码中newg.sched.pc被设置成了goexit函数地址+1,即其第二条指令地址,而不是fn.fn
主要是因为gostartcallfn函数的处理,其首先从参数fc中提取出函数地址(初始化是runtime.main,然后调用gostartcall函数)。
gostartcall函数作用:1.调整newg的栈空间,把goexit函数的第二条指令地址入栈,伪造成goexit函数调用了fn,从而使fn执行完ret指令时返回到goexit函数执行最后的清理工作 2.重新设置newg.buf.pc为需要执行的函数的地址,即fn
newproc1函数最后设置了几个无关调度的成员变量,然后把newg状态改为_Grunnable,并放入运行队列。
调度main goroutine
mstart1首先调用save函数来保存g0的调度信息。(非常重要,是理解调度循环的关键点之一)
//getcallerpc()获取mstart1执行完的返回地址
//getcallersp()获取调用mstart1时的栈顶地址
save(getcallerpc(), getcallersp())
func save(pc, sp uintptr) {
_g_ := getg()
_g_.sched.pc = pc //再次运行时的指令地址
_g_.sched.sp = sp //再次运行时到栈顶
_g_.sched.lr = 0
_g_.sched.ret = 0
_g_.sched.g = guintptr(unsafe.Pointer(_g_))
// We need to ensure ctxt is zero, but can't have a write
// barrier here. However, it should always already be zero.
// Assert that.
if _g_.sched.ctxt != nil {
badctxt()
}
}
调度核心函数schedule()
func schedule(){
_g_ := getg()
var gp *g
if gp == nil {
//为了保证调度公平,每进行61次调度就优先从全局队列中获取goroutine
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(),1) //从全局队列中获取1个goroutine
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")
}
}
//如果本地和全局都没有找到需要运行的,调用findrunnable从其他工作的线程运行队列中偷取,偷取不到则进入睡眠
//直到获取到需要运行的goroutine之后findrunnable才返回
if gp == nil {
gp,inheritTime = findrunnable() //blocks until work is available
}
execute(gp, inheritTime) //切换到gp的代码和栈空间运行
}
func execute() {
_g_ := getg() //g0
casgstatus(gp, _Grunnable, _Grunning)
_g_.m.curg = gp
gp.m = _g_.m
gogo(&gp.sched) //gogo完成从g0到gp真正的切换
}
execute函数的第一个参数gp即是需要调度起来运行的goroutine,这里首先把gp的状态从_Grunnable修改为_Grunning,然后把gp和m关联起来,这样通过m就可以找到当前工作线程正在执行哪个goroutine,反之亦然。
完成gp运行前的准备工作之后,execute调用gogo函数完成从g0到gp的的切换:CPU执行权的转让以及栈的切换。
gogo函数通过汇编编写,因为涉及寄存器及函数调用栈的切换,只能靠汇编指令来达成。
- 把gp.sched的成员恢复到CPU的寄存器完成状态以及栈的切换;
- 跳转到gp.sched.pc所指的指令地址(runtime.main)处执行
main函数工作流程:
- 启动一个sysmon系统监控线程,该线程负责整个程序的gc、抢占调度以及netpoll等功能的监控,在抢占调度一章我们再继续分析sysmon是如何协助完成goroutine的抢占调度的;
- 执行runtime包的初始化;
- 执行main包以及main包import的所有包的初始化;
- 执行main.main函数;
- 从main.main函数返回后调用exit系统调用退出进程;
runtime.main是main函数的入口,通过schedule()->execute()->gogo()调用链的gogo函数中用汇编代码直接跳转过来的,所以不需要返回。
总结来说 从g0切换到main goroutine的流程:
- 保存g0的调度信息,主要是保存CPU栈顶寄存器SP到g0.sched.sp成员之中;
- 调用schedule函数寻找需要运行的goroutine,我们这个场景找到的是main goroutine;
- 调用gogo函数首先从g0栈切换到main goroutine的栈,然后从main goroutine的g结构体对象之中取出sched.pc的值并使用JMP指令跳转到该地址去执行;
- main goroutine执行完毕直接调用exit系统调用退出进程。
非main goroutine的调度及退出循环
非main gouroutine返回时直接返回goexit的第二条指令,该指令继续调用goexit1函数。goexit1函数通过mcall从当前运行的g2 goroutine切换到g0,然后在g0栈上调用和指定goexit0函数。
mcall函数首先从当前运行的g2切换到g0,包括保存当前g的调度信息,把g0设置到tls中,修改CPU和rsp寄存器使其指向g0的栈;然后以当前运行的g2位参数调用fn函数(此处为gexit0)。
mcall和gogo函数完全相反,gogo实现从g0切换到某个goroutine运行,而mcall从某个goroutine切换到g0来运行。两者代码相似,区别在于gogo切换goroutine时先切换栈,然后通过跳转指令从runtime代码切换到了用户goroutine代码。mcall则只切换了栈,原因在于mcall函数本身就是runtime代码。
从g2切换到g0之后,在g0栈上执行goexit0函数,完成最后的清理操作。把g的状态从_Grunning变为_Gdead,把g的一些字段清空为0,调用dropg函数解除g和m之间的关系。g->m=nil,m->currg=nil,把g放在p的free列表缓存起来方便下次创建g时快读获取,最后调用schedule再次调度
调度循环
schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall->goexit0()->schedule()
一轮调度都是从调用schedule函数开始,经过一系列代码的执行到最后再次调用schedule开始进行新一轮的调度。这是一个工作线程的调度循环,同一个Go程序中可能存在多个工作线程,其中每个工作线程都有自己的调度循环。
每调用一次函数都会消耗一定的栈空间,而如果一直这样无返回的调用下去无论g0有多少栈空间终究会耗尽。该调度的关键在于每次执行mcall切换到g0栈时都是切换到g0.sched.sp所指的固定位置,因为从schedule函数开始之后的一系列函数永远都不会返回,所以重用这些函数上一轮调度时所使用的栈内存没有问题。
总结来说,工作线程的执行流程如下:
- 初始化,调用mstart函数;
- 调用mstart1函数,在该函数中调用save函数设置g0.sched.sp和g0.sched.pc等调度信息,其中g0.sched.sp指向mstart函数栈帧的栈顶
- 依次调用schedule->execute->gogo函数执行调度
- 运行用户的goroutine代码
- 用户goroutine代码执行过程中调用runtime的某些函数,然后这些函数调用mcall切换到g0.sched.sp所指向的栈并最终再次调用schedule函数进入新一轮的调度,之后工作线程一直循环执行3-5这一调度循环直到进程退出
goroutine的调度策略
schedule函数分三步从运行队列中寻找可运行的goroutine:
- 从全局队列中寻找goroutine。为了保证调度公平性,每个工作线程经过61次调度就需要优先尝试从全局队列中找到一个goroutine来运行,保证位于全局队列的g也有得到调度的机会。访问全局队列时需要加锁。
- 从工作线程本地队列中寻找goroutine,如果不需要或不能从全局队列中获取到则从本地队列中获取;
- 从其他工作线程的本地运行队列中偷取。如果上述两步骤都没获取到可运行的g那么调用findrunnable从其他工作线程的运行队列中偷取。该函数在偷取之前还会再次尝试从全局和当前线程本地队列中查找需要运行的g。
从全局队列中获取
globrunqget(p *p, max int32)函数第一个参数是与当前工作线程绑定的p,第二个参数是最多可以从全局队列中拿多少g到当前工作线程的本地运行队列中。
根据p的数量平分全局队列中的g,最大为全局队列中的p总和。如果该值大于第二个参数max,则重新赋值为max。判断此时该值是否大于本地队列容量的一半,如果仍大于,则重新赋值为本地队列的一半。然后pop从全局队列中依次取出N个g,放入本地队列中。
从本地队列中获取
本地队列分为两个部分,一部分由P的runq,runqhead,runqtail组成的一个无锁循环队列,该队列最多包含256个G;另一部分是p的runnext成员,指向一个指向g结构体对象的指针,最多只包含一个g。
本地队列寻找时通过runqget函数完成,代码首先查看runnext成员是否为空,不为空则返回runnext指向的goroutine,并把runnext成员清零,如果为空则继续从循环队列中查找goroutine。
无论runnext还是从队列中取都使用了CAS,防止其他工作线程窃取。其次对runqhead的操作使用了atomic.LoadAcq, atomic.CasRel。runqtail则不需要因为其只会被当前工作线程修改。
Go语言调度–窃取Goroutine
尽力从各个运行队列中寻找goroutine,如果实在找不到则进入睡眠状态。
工作线程M的spinning,从其他工作线程本地队列中窃取g时的状态称为自旋状态。去其他p的运行队列中窃取goroutine之前把spinning标志设为true,同时增加自旋M的数量。当有空闲P又有goroutine需要运行时,处于自旋状态的M的数量决定了是否需要唤醒或者创建新的工作线程。
盗取本质是遍历allp中的所有p查看其运行队列是否有g,如果有则窃取一半后返回,如果没有则继续遍历。为了保证公平性,遍历并不是固定的从allp[0]开始,而是从随机位置上的p开始,并使用伪随机的方式(随机选取质数作为步长)遍历。
窃取一半时通过runqtail-runqhead/2进行计算后,还需要和队列长度的一半进行判断。因为读取上述两个值的操作不是一个原子操作,需要检测读取过程中是否有其他线程快速增加这两个值。
如果工作线程经过多次努力一直找不到需要运行的g则调用stopm进入睡眠状态,等待被其他工作线程唤醒。stopm调用mput把m结构体对象放入sched的midle空闲队列,并通过notesleep函数让自己进入睡眠状态。
后来再有可运行的g时通过全局的m空闲队列找到处于睡眠状态的m,通过notewakeup进行唤醒。
Goroutine被动调度
goroutine在执行某个操作因条件不满足需要等待而发生的调度。
阻塞时通过runqput(p p, gp g, next bool)函数把goroutine挂入运行队列。首先把gp放在p.runnext成员中,runnext成员中的goroutine会被优先调度起来运行。CAS操作如果此时有其他线程操作runnext成员需要重试。原本runnext的gp放在runq的尾部。如果本地队列满了则通过runqputslow把gp放入全局运行队列。
runqputslow首先用链表把从p的本地队列中取出的一半连同gp一起串联起来,加锁成功后通过globalrunqputbatch把该链表链入全局队列。其中要注意的是加锁是等待所有准备工作完成后才进行,尽量减少冲突。
goroutine对channel进行操作时,chanrecv会判断channel是否有数据可读,如果有取出并返回,如果没有则需要把当前goroutine挂入channel的读取队列中并调用goparkunlock函数阻塞该goroutine。
goparkunlock直接调用gopark,后者调用mcall从当前goroutine切换到g0去执行park_m函数。park_m将当前g的状态设置为_Gwaiting,然后调用dropg函数解除g和m之间的关系。最后调用schedule函数进入调度循环。
工作线程的唤醒及创建
为了充分利用CPU资源,ready函数在唤醒goroutine之后会去判断是否需要启动新工作线程。规则是如果当前有空闲的p而且没有工作线程正在尝试从各个工作线程的本地队列窃取goroutine时,需要把空闲的p唤醒起来工作。
wakep首先通过CAS操作再次确认是否有其他工作线程处于spinning状态,如果没有则调用startm创建一个新的或唤醒一个处于睡眠状态的工作线程出来工作。
startm首先判断是否有空闲的p,如果没有则直接返回。如果有则首先尝试从m的空闲队列中查找处于休眠状态的工作线程,如果找到则notewakeup唤醒,否则调用newm创建一个工作线程与之绑定,把空闲的p利用起来。
Go调度器主动调度
主动调度是指当前正在运行的goroutine通过直接调用runtime.Gosched()函数暂时放弃运行而发生的调度。
用户代码自己控制,根据代码就可以预见什么地方一定会发生调度。
主要调度逻辑在goschedImpl函数中,首先把当前运行g2的状态从_Grunning 切换到_Grunnable,并通过dropg函数解除当前线程m和g2的关系,m.curg=nil,g2.m=nil,然后调用globrunqput函数把g2放入全局队列中。
goroutine运行时间过长而发生的抢占调度
sysmon系统监控线程会定期(10ms)通过retake函数对goroutine发起抢占。根据p的两种不同状态检查是否需要抢占:1)_Prunning 表示对应的goroutine正在运行,如果运行时间超过10ms则需要抢占, 2)_Psyscall表示对应的goroutine正在执行系统调用,此时需要根据多个条件来判断是否需要抢占。
普通运行时间超时
如果监控到运行时间超过10ms,则会调用preemptone函数向该goroutine发出抢占请求。该函数只是简单的设置了被抢占goroutine对应的g结构体中的preempt成员为true,stackguard0成员为stackPreempt(常量)就返回了,并没有真正强制被抢占的goroutine暂停。
被抢占的goroutine会处理响应监控线程提出的抢占请求。在进行一些基本检查后如果可以被抢占则调用gopreempt_m函数完成调度。此函数通过goschedImpl函数完成实际的调度切换工作,和主动调度类似。
系统调用时间超时
如果满足以下条件任意一个就需要对处于PSyscall状态的P进行抢占
- p的运行队列中有等待运行的goroutine。用来保证当前p的本地队列中goroutine及时调度,因为该p对应的工作线程正处于系统调用,无法调度队列中的goroutine,需要找另外一个工作线程来接管这个p
- 没有空闲的p。表示其他所有的p都已经和工作线程绑定并且忙于执行,说明系统比较繁忙,需要抢占当前处于系统调用之中而实际上系统调用并不需要的这个p,并把它分配给其他工作线程去调度其他的goroutine。
从上一次监控观察到p对应的m处于系统调用之中到现在超过了10ms。表示只要系统调用超时就对其进行抢占,不管是否真的有goroutine需要调度。
这里对正在进行系统调用的goroutine的抢占实质上是剥夺与其对应的工作线程所绑定的p,虽然说处于系统调用之中的工作线程并不需要p,但一旦从操作系统内核返回到用户空间之后就必须绑定一个p才能运行go代码,那么,工作线程从系统调用返回之后如果发现它进入系统调用之前所使用的p被监控线程拿走了,该怎么办呢?
因为工作线程没有绑定p是不能运行goroutine的,所以这里会再次尝试从全局空闲队列找一个p出来绑定,找到了就通过execute函数继续执行当前这个goroutine,如果找不到则把当前goroutine放入全局运行队列,由其它工作线程负责把它调度起来运行,自己则调用stopm函数进入睡眠状态。
对于运行时间过长的goroutine,系统监控线程首先会提出抢占请求,然后工作线程在适当的时候会去响应这个请求并暂停被抢占goroutine的运行,最后工作线程再调用schedule函数继续去调度其它goroutine;
而对于系统调用执行时间过长的goroutine,调度器并没有暂停其执行,只是剥夺了正在执行系统调用的工作线程所绑定的p,要等到工作线程从系统调用返回之后绑定p失败的情况下该goroutine才会真正被暂停运行。