前言
知识搬运工,不生成新知识
前文我们介绍了runtime对于go的重要性,实际上GO语言的runtime包括4个模块:
- scheduler
调度管理所有GMP,在后台执行调度循环 - netpoll
网络轮询负责管理网络FD相关的读写,就绪和事件 - memory
当代码需要内存时,负责内存分配工作 - garbage
垃圾回收
go的调度模型GMP我们也是常常听到的,现在来仔细了解一下
1.GMP模型
runtime/proc.go文件中对GMP的描述
GMP// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
这里也了解到P是一个抽象的概念
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
- G(gorutine)
每个gorutine都有自己的栈空间,定时器,初始化的栈空间在2K左右,表示一个用户的代码执行流(下面代码仅截取部分,go/src/runtime/runtime2.go)
type g struct {
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf
stack是什么样的呢?是由两个uintpr组成的结构体(uintptr是golang的内置类型,是能存储指针的整型,补充一个知识点,CPU描述栈是利用栈段寄存器SS和栈顶偏移指针SP寄存器来寻找一个栈地址的,即栈顶和栈底)
type stack struct {
lo uintptr
hi uintptr
}
//实际是无符号长整形
typedef unsigned long long int uint64;
typedef uint64 uintptr;
回到G数据结构,结构体成员stack,stackguard0、stackguard1都是描述goroutine的栈的,在GO的术语里,G的stack叫做user stack用户栈,而M的叫做system stack系统栈。上面提到过栈初始空间为2KB,一个go进程的G是可以有上万个的,如果空间太大那么内存空间就会很快消耗完毕,但如果G的调用链很深则用户栈会动态增加(像slice一样根据cap动态增加)。
G的定义里还包括等待被执行的_panic和_defer函数链表,链表节点里有指向下一个节点的指针(这里以defer为例)
type _defer struct {
started bool
heap bool
openDefer bool
sp uintptr // sp at time of defer
pc uintptr // pc at time of defer
fn func() // can be nil for open-coded defers
_panic *_panic // panic that is running defer
link *_defer // next defer on G; can point to either heap or stack!
varp uintptr
framepc uintptr
}
还包括了sp和pc寄存器的值
- M(machine)
代表内核线程,记录内核线程栈信息,当gorutine调度到线程时,使用该gorutine自己的栈信息
type m struct {
g0 *g // goroutine with scheduling stack
curg *g // current running goroutine
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
id int64
curg表示正在运行的G,p表示当前使用的P,每一个m在创建时都会生成一个g0(这个后文再说,先留个印象)
- P(process)
代表调度器,负责调度gorutine,M从P上获取G执行
type p struct {
m muintptr // back-link to associated m (nil if idle)
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
schedtick uint32
一个p对应一个m
简化GMPP的主要作用是从一个队列里调度G,其中这个队列(local run queue,LRQ)里的G是“准备好”的(即G没有在执行I/O操作等阻塞操作,而是可以继续执行计算,处于等待中的G被存放在netpoll中)。其中队列除了有本地队列LRQ外还有全局队列(Global run queue,GRQ),P的存在很大程度上是为了有多个LRQ,我们想一想CPU有多个Core意味着有多个M(一个线程对应一个核),如果M都从GRQ中取G来执行,那么就需要同步机制如mutex来保护,M在取G的时候就需要等待mutex,那么每个M从自己对应的P中的LRQ来取G执行就不要这个mutex。P为M从LRQ提供G,来减少争抢锁消耗的时间,即为生产消费流程少加锁,那LRQ有了后,GRQ不就没有存在的意义了?LRQ是有长度限制的,GRQ是没有的,新创建的G会默认被放进LRQ(满了就排挤出一个老G),被挤出的则放进GRQ,GRQ的调度机会?
runtime/proc.go里的 schedule函数
func schedule(){
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())
// We can see gp != nil here even if the M is spinning,
// if checkTimers added a local goroutine via goready.
}
}
schedtick%61 == 0,调度器每执行61次,执行globrunqget(g.m.p.ptr(), 1),从GRQ中取一个G(runqget是LRQ即全局队列没有的话回去本地队列取)
小结
至此介绍了GMP的大致数据结构、LRQ和GRQ
1.1M的创建
M是核心线程,创建需要调用系统调用,在GO中他的创建是调用了runtime/proc.go的newm函数
func newm(fn func(), _p_ *p, id int64) {
mp := allocm(_p_, fn, id)
mp.nextp.set(_p_)
mp.sigmask = initSigmask
...
newm1(mp)
}
func newm1(mp *m) {...newosproc(mp)...}
fn是线程要启动后执行的GO函数如果为nil则默认执行scheduler,继续看runtime/os_linux.go的newosproc函数
func newosproc(mp *m) {
...
ret := clone(cloneFlags, stk, unsafe.Pointer(mp),
unsafe.Pointer(mp.g0),
unsafe.Pointer(funcPC(mstart)))
它调用了一个叫 clone 的函数,来创建线程,并且执行 mstart 函数(当 fn 是 nil时)。clone定义在汇编文件sys_linux_amd64.s里面,通过SYSCALL指令发起系统调用
#define SYS_clone 56
TEXT runtime·clone(SB),NOSPLIT,$0
...
MOVL $SYS_clone, AX
SYSCALL
// In parent, return.
...
RET
// In child, on new stack.
MOVQ fn+32(FP), R12
// Call fn
CALL R12
汇编懂得也不多,大意就是往AX寄存器写入56,SYSCALL指令发起对56号操作系统功能的调用,创建新线程,新线程执行clone再创建子线程,父线程RET返回,新线程MOVQ执行参数fn,fn是nil时默认为mastart函数,调用schedule函数(上文M的执行函数P从队列拉取G)。至此我们大致了解了M的创建流程,当然M必须获得P才能执行代码,否则休眠(P相当于一种token,具有在CPU CORE上执行的权力,m结构体上有一个p指针)
1.2g0-M执行G
上文当M被创建时,他会执行schedule代码(从LRQ或GRQ获取G执行),我们就可以把这件事情(M执行schedule)抽象成一个gorutine在执行(这个G叫做M上的g0,见上文m的数据结构体g0成员),那如何做到G的切换呢?即调用其他G函数(下面摘自王爽老师的比喻)
函数(一个函数就是一堆指令的集合)的调用在汇编中通过Call指令实现,在stack(ss:sp)上新建一个frame(帧)(栈顶指针SP上挪frame size空间),拷贝进参数,CPU执行被调用函数就是PC寄存器变成函数起始地址。(Call指令在修改SP栈顶指针和PC寄存器前都会保存,供RET指令返回使用,这样CPU就能执行CALL指令之后的指令即继续执行调用函数),gorutine的切换和函数调用类似,修改CPU的SP和PC寄存器值,但是SP不再在当前stack挪动,而是另一个goroutine的stack,那么当Ret指令返回要恢复上下文时,就回到之前的gorutine。
回到GMP,当schedule函数取得G发起gorutine切换时,M不再执行g0,而是执行取得的新G,G执行的是一个GO函数,函数最后指令是RET,当G执行完后SP和PC值恢复继续执行g0(g0是schedule函数里的无线循环,负责寻找G,然后切过去)
调度
实际上是4个函数循环执行,还记得P结构体上有一个成员是schedtick它记录的是调度循环次数,4个函数循环一次+1
1.3G的创建
go func(){}创建G的过程
G的创建在runtime/proc.go的newproc中
func newproc(fn *fncval){
gp :=getg()
pc:=getcallerpc()
systemstack(func(){
... newproc1(fn,gp,pc)...
runqput(_p_, newg, true)
if mainStarted {
wakep()
}
})}
尝试获取参数位置和函数执行完跳回的位置,再调用systemstack--调用newproc1(切换到g0上执行并使用g0的栈),newproc1是真正负责初始化G的,之后runqput进入队列
1.3.1G入队流程
看runqput代码可以发现还有一个p.runnext,其实新G进入LRQ之前还会先进入runnext(优先级更高,runnext只是一个指针可以理解为容量为1的队列,越是新G被调度几率越大)
当LRQ满的时候会携带一半塞GRQ(链表,无限大)
1.4P找G的机制
按照优先级:
从runnext获取任务
从本地队列获取任务
从全局队列获取任务
从网络轮询器获取任务
从其它的处理器的本地队列窃取任务,
当LRQ和GRQ没有g时, 会先看看网络轮询器有没有需要处理的g.如果也没有,才会进行work-stealing
1.5处理阻塞
前文在谈P的时候我们知道LRQ,GRQ存放的是没有阻塞操作的、“准备好”的、可继续执行计算的G,那么有阻塞任务的G怎么办?
阻塞情况(内中断):
- 没有缓冲区的channel
var ch = make(chan int)
ch<- 1
- 休眠
sleep(time.Second)
- 等待锁
- I/O
- 事件循环
M遇到阻塞任务会将G挂起,放入netpoll等,待其ready后继续执行,此时M进入schedule继续执行其他G
以上是可以接管的阻塞,如果是不可接管的阻塞呢如syscall,cgo和长时间运行等需要sysmon将其剥离P执行(一般10ms),sysmon是一个后台高优先级循环,会发信号抢占执行时间长的G
总结
介绍了GMP模型,go函数实际是向runtime提交一个计算任务(G),这个新G会被送入runnext,老G会被送入某一个P的LRQ,队伍满时LRQ踢出的G会被送入GRQ,M启动时默认执行schedule函数来不断从P中获取G(GO的调度流程本质上是一个生产消费流程)
参考
1.runtime调度机制概览
2.深度解读scheduler
3.GO并发模型
4.汇编:函数调用
5.GMP模型
6.uinptr原理
7.万字长文goruntime
网友评论