1 Runtime简介
Go语言是互联网时代的C,因为其语法简洁易学,对高并发拥有语言级别的亲和性。而且不同于虚拟机的方案。Go通过在编译时嵌入平台相关的系统指令可直接编译为对应平台的机器码,同时嵌入Go Runtime,在运行时实现自身的调度算法和各种并发控制方案,避免进入操作系统级别的进程/线程上下文切换,以及通过原子操作、自旋、信号量、全局哈希表、等待队列多种技术避免进入操作系统级别锁,以此来提升整体性能。
Go的runtime是与用户代码一起打包在一个可执行文件中,是程序的一部分,而不是向Java需要单独安装,与程序独立。所以用户代码与runtime代码在执行时没有界限都是函数调用。在Go语言中的关键字编译时会变成runtime中的函数调用。
Go Runtime核心主要涉及三大部分:内存分配、调度算法、垃圾回收;本篇文章我们主要介绍GMP调度原理。关于具体应该叫GPM还是GMP,我更倾向于成为GMP,因为在runtime代码中经常看到如下调用:
1 buf := &getg().m.p.ptr().wbBuf
其中getg代表获取当前正在运行的g即goroutine,m代表对应的逻辑处理器,p是逻辑调度器;所以我们还是称为GMP。
(以上部分图文来自:https://zhuanlan.zhihu.com/p/95056679)
2 GMP概览
下面这个图虽然有些抽象(不如花花绿绿的图片),确是目前我看到对整个调度算法设计的重要概念覆盖最全的。
1 +-------------------- sysmon ---------------//------+ 2 | | 3 | | 4 +---+ +---+-------+ +--------+ +---+---+ 5 go func() ---> | G | ---> | P | local | <=== balance ===> | global | <--//--- | P | M | 6 +---+ +---+-------+ +--------+ +---+---+ 7 | | | 8 | +---+ | | 9 +----> | M | <--- findrunnable ---+--- steal <--//--+ 10 +---+ 11 | 12 mstart 13 | 14 +--- execute <----- schedule 15 | | 16 | | 17 +--> G.fn --> goexit --+
我们来看下其中的三大主要概念:
- G:Groutine协程,拥有运行函数的指针、栈、上下文(指的是sp、bp、pc等寄存器上下文以及垃圾回收的标记上下文),在整个程序运行过程中可以有无数个,代表一个用户级代码执行流(用户轻量级线程);
- P:Processor,调度逻辑处理器,同样也是Go中代表资源的分配主体(内存资源、协程队列等),默认为机器核数,可以通过GOMAXPROCS环境变量调整
- M:Machine,代表实际工作的执行者,对应到操作系统级别的线程;M的数量会比P多,但不会太多,最大为1w个。
其中G分为三类:
- 主协程,用来执行用户main函数的协程
- 主协程创建的协程,也是P调度的主要成员
- G0,每个M都有一个G0协程,他是runtime的一部分,G0是跟M绑定的,主要用来执行调度逻辑的代码,所以不能被抢占也不会被调度(普通G也可以执行runtime_procPin禁止抢占),G0的栈是系统分配的,比普通的G栈(2KB)要大,不能扩容也不能缩容
- sysmon协程,sysmon协程也是runtime的一部分,sysmon协程直接运行在M不需要P,主要做一些检查工作如:检查死锁、检查计时器获取下一个要被触发的计时任务、检查是否有ready的网络调用以恢复用户G的工作、检查一个G是否运行时间太长进行抢占式调度。
M分为两类:
- 普通M,用来与P绑定执行G中任务
- m0:Go程序是一个进程,进程都有一个主线程,m0就是Go程序的主线程,通过一个与其绑定的G0来执行runtime启动加载代码;一个Go程序只有一个m0
- 运行sysmon的M,主要用来运行sysmon协程。
刚才说道P是用来调度G的执行,所以每个P都有自己的一个G的队列,当G队列都执行完毕后,会从global队列中获取一批G放到自己的本地队列中,如果全局队列也没有待运行的G,则P会再从其他P中窃取一部分G放到自己的队列中。而调度的时机一般有三种:
- 主动调度,协程通过调用`runtime.Goshed`方法主动让渡自己的执行权利,之后这个协程会被放到全局队列中,等待后续被执行
- 被动调度,协程在休眠、channel通道阻塞、网络I/O堵塞、执行垃圾回收时被暂停,被动式让渡自己的执行权利。大部分场景都是被动调度,这是Go高性能的一个原因,让M永远不停歇,不处于等待的协程让出CPU资源执行其他任务。
- 抢占式调度,这个主要是sysmon协程上的调度,当发现G处于系统调用(如调用网络io)超过20微秒或者G运行时间过长(超过10ms),会抢占G的执行CPU资源,让渡给其他协程;防止其他协程没有执行的机会;(系统调用会进入内核态,由内核线程完成,可以把当前CPU资源让渡给其他用户协程)
Go的协程调度与操作系统线程调度区别主要存在四个方面:
- 调度发生地点:Go中协程的调度发生在runtime,属于用户态,不涉及与内核态的切换;一个协程可以被切换到多个线程执行
- 上下文切换速度:协程的切换速度远快于线程,不需要经过内核与用户态切换,同时需要保存的状态和寄存器非常少;线程切换速度为1-2微秒,协程切换速度为0.2微秒左右
- 调度策略:线程调度大部分都是抢占式调度,操作系统通过发出中断信号强制线程切换上下文;Go的协程基本是主动和被动式调度,调度时机可预期
- 栈大小:线程栈一般是2MB,而且运行时不能更改大小;Go的协程栈只有2kb,而且可以动态扩容(64位机最大为1G)
以上基本是整个调度器的概括,不想看原理的同学可以不用往下看了,下面会进行源码级介绍;
3 GMP的源码结构
源码部分主要涉及三个文件:
1 runtime/amd_64.s 涉及到进程启动以及对CPU执行指令进行控制的汇编代码,进程的初始化部分也在这里面 2 runtime/runtime2.go 这里主要是运行时中一些重要数据结构的定义,比如g、m、p以及涉及到接口、defer、panic、map、slice等核心类型 3 runtime/proc.go 一些核心方法的实现,涉及gmp调度等核心代码在这里
这里我们主要关心gmp中与调度相关的代码;
3.1 G源码部分
3.1.1 G的结构
先来看下g的结构定义:
1 // runtime/runtime2.go 2 type g struct { 3 // 记录协程栈的栈顶和栈底位置 4 stack stack // offset known to runtime/cgo 5 // 主要作用是参与一些比较计算,当发现容量要超过栈分配空间后,可以进行扩容或者收缩 6 stackguard0 uintptr // offset known to liblink 7 stackguard1 uintptr // offset known to liblink 8 9 // 当前与g绑定的m 10 m *m // current m; offset known to arm liblink 11 // 这是一个比较重要的字段,里面保存的一些与goroutine运行位置相关的寄存器和指针,如rsp、rbp、rpc等寄存器 12 sched gobuf 13 syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc 14 syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc 15 stktopsp uintptr // expected sp at top of stack, to check in traceback 16 17 // 用于做参数传递,睡眠时其他goroutine可以设置param,唤醒时该g可以读取这些param 18 param unsafe.Pointer 19 // 记录当前goroutine的状态 20 atomicstatus uint32 21 stackLock uint32 // sigprof/scang lock; TODO: fold in to atomicstatus 22 // goroutine的唯一id 23 goid int64 24 schedlink guintptr 25 26 // 标记是否可以被抢占 27 preempt bool // preemption signal, duplicates stackguard0 = stackpreempt 28 preemptStop bool // transition to _Gpreempted on preemption; otherwise, just deschedule 29 preemptShrink bool // shrink stack at synchronous safe point 30 31 // 如果调用了LockOsThread方法,则g会绑定到某个m上,只在这个m上运行 32 lockedm muintptr 33 sig uint32 34 writebuf []byte 35 sigcode0 uintptr 36 sigcode1 uintptr 37 sigpc uintptr 38 // 创建该goroutine的语句的指令地址 39 gopc uintptr // pc of go statement that created this goroutine 40 ancestors *[]ancestorInfo // ancestor information goroutine(s) that created this goroutine (only used if debug.tracebackancestors) 41 // goroutine函数的指令地址 42 startpc uintptr // pc of goroutine function 43 racectx uintptr 44 waiting *sudog // sudog structures this g is waiting on (that have a valid elem ptr); in lock order 45 cgoCtxt []uintptr // cgo traceback context 46 labels unsafe.Pointer // profiler labels 47 timer *timer // cached timer for time.Sleep 48 selectDone uint32 // are we participating in a select and did someone win the race? 49 }
跟g相关的还有两个数据结构比较重要:
stack是协程栈的地址信息,需要注意的是m0绑定的g0是在进程被分配的系统栈上分配协程栈的,而其他协程栈都是在堆上进行分配的。
gobuf中保存了协程执行的上下文信息,这里也可以看到协程切换的上下文信息极少;sp代表cpu的rsp寄存器的值,pc代表CPU的rip寄存器值、bp代表CPU的rbp寄存器值;ret用来保存系统调用的返回值,ctxt在gc的时候使用。
其中几个寄存器作用如下:
- SP:永远指向栈顶位置
- BP:某一时刻的栈顶位置,当新函数调用时,把当前SP地址赋值给BP、SP指向新的栈顶位置
- PC:代表代码经过编译为机器码后,当前执行的机器指令(可以理解为当前语句)
1 // Stack describes a Go execution stack. 2 // The bounds of the stack are exactly [lo, hi), 3 // with no implicit data structures on either side. 4 // goroutine协程栈的栈顶和栈底 5 type stack struct { 6 lo uintptr // 栈顶,低地址 7 hi uintptr // 栈底,高地址 8 } 9 10 // gobuf中保存了非常重要的上下文执行信息, 11 type gobuf struct { 12 // 代表cpu的rsp寄存器的值,永远指向栈顶位置 13 sp uintptr 14 // 代表代码经过编译为机器码后,当前执行的机器指令(可以理解为当前语句) 15 pc uintptr 16 // 指向所保存执行上下文的goroutine 17 g guintptr 18 // gc时候使用 19 ctxt unsafe.Pointer 20 // 用来保存系统调用的返回值 21 ret uintptr 22 lr uintptr 23 // 某一时刻的栈顶位置,当新函数调用时,把当前SP地址赋值给BP、SP指向新的栈顶位置 24 bp uintptr // for framepointer-enabled architectures 25 }
3.1.2 G的状态
就像线程有自己的状态一样,goroutine也有自己的状态,主要记录在atomicstatus字段上:
1 // defined constants 2 const ( 3 // 代表协程刚开始创建时的状态,当新创建的协程初始化后,为变为_Gdead状态,_Gdread也是协程被销毁时的状态; 4 // 刚创建时也被会置为_Gdead主要是考虑GC可以去用去扫描dead状态下的协程栈 5 _Gidle = iota // 0 6 // 代表协程正在运行队列中,等待被运行 7 _Grunnable // 1 8 // 代表当前协程正在被运行,已经被分配了逻辑处理的线程,即p和m 9 _Grunning // 2 10 // 代表当前协程正在执行系统调用 11 _Gsyscall // 3 12 // 表示当前协程在运行时被锁定,陷入阻塞,不能执行用户代码 13 _Gwaiting // 4 14 15 _Gmoribund_unused // 5 16 // 新创建的协程初始化后,或者协程被销毁后的状态 17 _Gdead // 6 18 19 // _Genqueue_unused is currently unused. 20 _Genqueue_unused // 7 21 // 代表在进行协程栈扫描时发现需要扩容或者缩容,将协程中的栈转移到新栈时的状态;这个时候不执行用户代码,也不在p的runq中 22 _Gcopystack // 8 23 24 // 代表g被抢占后的状态 25 _Gpreempted // 9 26 27 // 这几个状态是垃圾回收时涉及,后续文章进行介绍 28 _Gscan = 0x1000 29 _Gscanrunnable = _Gscan + _Grunnable // 0x1001 30 _Gscanrunning = _Gscan + _Grunning // 0x1002 31 _Gscansyscall = _Gscan + _Gsyscall // 0x1003 32 _Gscanwaiting = _Gscan + _Gwaiting // 0x1004 33 _Gscanpreempted = _Gscan + _Gpreempted // 0x1009 34 )
这里是利用常量定义的枚举。
Go的状态变更可以看下图:
3.1.3 G的创建
当我们使用go关键字新建一个goroutine时,编译器会编译为runtime中对应的函数调用(newproc,而go 关键字后面的函数成为协程的任务函数),进行创建,整体步骤如下:
1. 用 systemstack 切换到系统堆栈,调用 newproc1 ,newproc1 实现g的获取。
2. 尝试从p的本地g空闲链表和全局g空闲链表找到一个g的实例。
3. 如果上面未找到,则调用 malg 生成新的g的实例,且分配好g的栈和设置好栈的边界,接着添加到 allgs 数组里面,allgs保存了所有的g。
4. 保存g切换的上下文,这里很关键,g的切换依赖 sched 字段。
5. 生成唯一的goid,赋值给该g。
6. 调用 runqput 将g插入队列中,如果本地队列还有剩余的位置,将G插入本地队列的尾部,若本地队列已满,插入全局队列。
7. 如果有空闲的p 且 m没有处于自旋状态 且 main goroutine已经启动,那么唤醒或新建某个m来执行任务。
这里对应的是newproc函数:
1 func newproc(siz int32, fn *funcval) { 2 argp := add(unsafe.Pointer(&fn), sys.PtrSize) 3 gp := getg() 4 // 获取调用者的指令地址,也就是调用newproc时又call指令压栈的函数返回地址 5 pc := getcallerpc() 6 // systemstack的作用是切换到m0对应的g0所属的系统栈 7 // 使用g0所在的系统栈创建goroutine对象 8 // 传递参数包括goroutine的任务函数、argp参数起始地址、siz是参数长度、调用方的pc指针 9 systemstack(func() { 10 newg := newproc1(fn, argp, siz, gp, pc) 11 // 创建完成后将g放到创建者(某个g,如果是进程初始化启动阶段则为g0)所在的p的队列中 12 _p_ := getg().m.p.ptr() 13 runqput(_p_, newg, true) 14 15 if mainStarted { 16 wakep() 17 } 18 }) 19 }
其中systemstack是一段汇编代码,位于asm_amd64.s文件中,主要是寄存器指令的操作,笔者不懂汇编这里先不做介绍。
newproc1是获取newg的函数,主要步骤:
1、首先防止当前g被抢占,绑定m
2、对传入的参数占用的内存空间进行对齐处理
3、从p的空闲队列中获取一个空闲的g,如果么有就创建一个g,并在堆上创建协程栈,并设置状态为_Gdead添加到全局allgs中
4、计算整体协程任务函数的参数空间大小,并设置sp指针
5、执行参数从getg的堆栈到newg堆栈的复制
6、设置newg的sched和startpc、gopc等跟上下文相关的字段值
7、设置newg状态为runable并设置goid
8、接触getg与m的防抢占状态
代码注释如下:
1 func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g { 2 ..... 3 // 如果是初始化时候这个代表g0 4 _g_ := getg() 5 6 if fn == nil { 7 _g_.m.throwing = -1 // do not dump full stacks 8 throw("go of nil func value") 9 } 10 // 使_g_.m.locks++,来防止这个时候g对应的m被抢占 11 acquirem() // disable preemption because it can be holding p in a local var 12 // 参数的地址,下面一句目的是为了做到内存对齐 13 siz := narg 14 siz = (siz + 7) &^ 7 15 16 // We could allocate a larger initial stack if necessary. 17 // Not worth it: this is almost always an error. 18 // 4*PtrSize: extra space added below 19 // PtrSize: caller's LR (arm) or return address (x86, in gostartcall). 20 if siz >= _StackMin-4*sys.PtrSize-sys.PtrSize { 21 throw("newproc: function arguments too large for new goroutine") 22 } 23 24 _p_ := _g_.m.p.ptr() 25 newg := gfget(_p_) // 首先从p的gfree队列中看看有没有空闲的g,有则使用 26 if newg == nil { 27 // 如果没找到就使用new关键字来创建一个g并在堆上分配栈空间 28 newg = malg(_StackMin) 29 // 将newg的状态设置为_Gdead,因为这样GC就不会去扫描一个没有初始化的协程栈 30 casgstatus(newg, _Gidle, _Gdead) 31 // 添加到全局的allg切片中(需要加锁访问) 32 allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack. 33 } 34 // 下面是检查协程栈的创建情况和状态 35 if newg.stack.hi == 0 { 36 throw("newproc1: newg missing stack") 37 } 38 39 if readgstatus(newg) != _Gdead { 40 throw("newproc1: new g is not Gdead") 41 } 42 // 计算运行空间大小并进行内存对齐 43 totalSize := 4*sys.PtrSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame 44 totalSize += -totalSize & (sys.StackAlign - 1) // align to StackAlign 45 // 计算sp寄存器指针的位置 46 sp := newg.stack.hi - totalSize 47 // 确定参数入栈位置 48 spArg := sp 49 ......... 50 if narg > 0 { 51 // 将参数从newproc函数的栈复制到新的协程的栈中,memove是一段汇编代码 52 // 从argp位置挪动narg大小的内存到sparg位置 53 memmove(unsafe.Pointer(spArg), argp, uintptr(narg)) 54 // 因为涉及到从栈到堆栈上的复制,go在垃圾回收中使用了三色标记和写入屏障等手段,所以这里要考虑屏障复制 55 // 目标栈可能会有垃圾存在,所以设置屏障并且标记为灰色 56 if writeBarrier.needed && !_g_.m.curg.gcscandone { // 如果启用了写入屏障并且源堆栈为灰色(目标始终为黑色),则执行屏障复制。 57 f := findfunc(fn.fn) 58 stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps)) 59 if stkmap.nbit > 0 { 60 // We're in the prologue, so it's always stack map index 0. 61 bv := stackmapdata(stkmap, 0) 62 bulkBarrierBitmap(spArg, spArg, uintptr(bv.n)*sys.PtrSize, 0, bv.bytedata) 63 } 64 } 65 } 66 // 把newg的sched结构体成员的所有字段都设置为0,其实就是初始化 67 memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched)) 68 newg.sched.sp = sp 69 newg.stktopsp = sp 70 // pc指针表示当newg被调度起来时从这个位置开始执行 71 // 这里是先设置为goexit,在gostartcallfn中会进行处理,更改sp为这里的pc,将pc改为真正的协程任务函数fn的指令位置 72 // 这样使得任务函数执行完毕后,会继续执行goexit中相关的清理工作 73 newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function 74 newg.sched.g = guintptr(unsafe.Pointer(newg)) // 保存当前的g 75 gostartcallfn(&newg.sched, fn) // 在这里完成g启动时所有相关上下文指针的设置,主要为sp、pc和ctxt,ctxt被设置为fn 76 newg.gopc = callerpc // 保存newproc的pc,即调用者创建时的指令位置 77 newg.ancestors = saveAncestors(callergp) 78 // 设置startpc为任务函数,主要用于函数调用栈的trackback和栈收缩工作 79 // newg的执行开始位置并不依赖这个字段,而是通过sched.pc确定 80 newg.startpc = fn.fn 81 if _g_.m.curg != nil { 82 newg.labels = _g_.m.curg.labels 83 } 84 // 判断newg的任务函数是不是runtime系统的任务函数,是则sched.ngsys+1; 85 // 主协程则代表runtime.main函数,在这里就为判断为真 86 if isSystemGoroutine(newg, false) { 87 atomic.Xadd(&sched.ngsys, +1) 88 } 89 // Track initial transition? 90 newg.trackingSeq = uint8(fastrand()) 91 if newg.trackingSeq%gTrackingPeriod == 0 { 92 newg.tracking = true 93 } 94 // 更改当前g的状态为_Grunnable 95 casgstatus(newg, _Gdead, _Grunnable) 96 // 设置g的goid,因为p会每次批量生成16个id,每次newproc如果新建一个g,id就会加1 97 // 所以这里m0的g0的id为0,而主协程的goid为1,其他的依次递增 98 if _p_.goidcache == _p_.goidcacheend { 99 // Sched.goidgen is the last allocated id, 100 // this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch]. 101 // At startup sched.goidgen=0, so main goroutine receives goid=1. 102 // 使用原子操作修改全局变量,这里的sched是在runtime2.go中的一个全局变量类型为schedt 103 // 原子操作具有多线程可见性,同时比加锁性能更高 104 _p_.goidcache = atomic.Xadd64(&sched.goidgen, _GoidCacheBatch) 105 _p_.goidcache -= _GoidCacheBatch - 1 106 _p_.goidcacheend = _p_.goidcache + _GoidCacheBatch 107 } 108 newg.goid = int64(_p_.goidcache) 109 _p_.goidcache++ 110 if raceenabled { 111 newg.racectx = racegostart(callerpc) 112 } 113 if trace.enabled { 114 traceGoCreate(newg, newg.startpc) 115 } 116 // 释放getg与m的绑定 117 releasem(_g_.m) 118 119 return newg 120 }
其中有几个关键地方需要强调
3.1.4 协程栈在堆空间的分配
malg函数,用来创建一个新g和对应的栈空间分配,这个函数主要强调的是栈空间分配部分,通过切换到系统栈上进行空间分配,分配完后设置栈底和栈顶的两个位置的保护字段,当栈上进行分配变量空间发现超过stackguard1时,会进行扩容,同时在某些条件下也会进行缩容
1 // Allocate a new g, with a stack big enough for stacksize bytes. 2 func malg(stacksize int32) *g { 3 newg := new(g) 4 if stacksize >= 0 { 5 stacksize = round2(_StackSystem + stacksize) 6 systemstack(func() { 7 newg.stack = stackalloc(uint32(stacksize)) 8 }) 9 newg.stackguard0 = newg.stack.lo + _StackGuard 10 newg.stackguard1 = ^uintptr(0) 11 // Clear the bottom word of the stack. We record g 12 // there on gsignal stack during VDSO on ARM and ARM64. 13 *(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0 14 } 15 return newg 16 }
stackalloc代码位于runtime/stack.go文件中;
协程栈首先在进程初始化时会创建栈的管理结构:
1、栈池stackpool,这个栈池主要用来对大小为2、4、8kb的小栈做缓存使用,使用的同样是mspan这种结构来存储;
2、为大栈分配的stackLarge
1 OS | FixedStack | NumStackOrders 2 -----------------+------------+--------------- 3 linux/darwin/bsd | 2KB | 4 4 windows/32 | 4KB | 3 5 windows/64 | 8KB | 2 6 plan9 | 4KB | 3 7 8 // Global pool of spans that have free stacks. 9 // Stacks are assigned an order according to size. 10 // order = log_2(size/FixedStack) 11 // There is a free list for each order. 12 var stackpool [_NumStackOrders]struct { 13 item stackpoolItem 14 _ [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte 15 } 16 17 //go:notinheap 18 type stackpoolItem struct { 19 mu mutex 20 span mSpanList 21 } 22 23 // Global pool of large stack spans. 24 var stackLarge struct { 25 lock mutex 26 free [heapAddrBits - pageShift]mSpanList // free lists by log_2(s.npages) 27 } 28 29 func stackinit() { 30 if _StackCacheSize&_PageMask != 0 { 31 throw("cache size must be a multiple of page size") 32
全部评论
专题导读
热门推荐
热门话题
阅读排行榜
请发表评论