go语言学习笔记(四):调度器基础-爬上那座山

目录 

调度器概述

调度器初始化

第一个goroutine,main goroutine的创建

第一个goroutine,main goroutine的调度

非main goroutine的创建、退出以及调度循环

调度策略

调度时机


调度器概述

调度的本质,即线程上下文在CPU中的切换,内核调度器负责对内核线程的调度,Go调度器做的,也是相似的工作,负责goroutine的调度。

重要的全局变量

allgs       []*g    // 所有g的列表  
allm        *m      // 所有m的链表
allp        []*p    // 所有p的列表
ncpu        int32   // cpu核数
gomaxprocs  int32   // p个数环境变量
sched       schedt  // 调度器对象
m0          m       // 主线程对应的m
g0          g       // m0的g0

 stack结构体

type stack struct {
     lo uintptr  // 指向内存低地址
     hi uintptr  // 指向内存高地址
}

gobuf结构体

type gobuf struct {
    sp    uintptr           // 保存CPU的rsp寄存器值
    pc    uintptr           // 保存CPU的rip寄存器值
    g     guintptr          // 此gobuf指向的g
    ctxt  unsave.pointer    // 此gobuf的上下文
    ret   sys.Uintreg       // 系统调用返回值
    lr    uintptr           // 
    bp    uintptr           // 保存CPU的rbp寄存器值
}

g结构体

type g struct {  
    stack        stack     // 记录goroutine使用的栈 
    stackguard0  uintptr   // 用于栈大小的伸缩
    stackguard1  uintptr   // 用于栈大小的伸缩
    ...
    m            *m        // 记录goroutine当前绑定的m
    sched        gobuf     // 记录goroutine寄存器相关值
    ...
    schedlink    guintptr  // 指向全局链表中下一个g
    ...
    preempt      bool      // 抢占标志
    ...
}

p结构体

type p struct {
    lock         mutex          // 锁
    status       uintptr        // 当前p状态
    link         puintptr       // 指向全局p链表下一个P
    schedtick    uint32         // p被m关联次数自增变量
    syscalltick  uint32         // p被系统调用次数自增变量
    m            muintptr       // 当前p关联的m
    ...
    runqhead     uint32         // 本地可运行g列表头
    runqtail     uint32         // 本地可运行g列表尾
    runq         [256]guintptr  // 本地可运行g列表   
    runnextg     uintptr        // 下一个将要运行的g
    ...
}

m结构体

type m struct {
    g0         *g          // 指向当前内核线程使用的栈,大概取了64k,用于执行Go调度器代码
    tls        [6]uintptr  // 用于实现线程本地存储
    mstartfn   func()      // 
    curg       *g          // 当前m正在运行的g
    p          puintptr    // 当前m关联的p
    nextp      puintptr    // 预关联的p
    oldp       puintptr    // 上次关联的p
    spinnig    bool        // 自旋态
    blocked    bool        // 锁
    park       note        //
    alllink    *m          // 指向下一个m
    schedlink  muintptr    // 所有m的一个链表
    thread     uintptr     // 当前操作系统线程ID
    freelink   *m          // 空闲m链表
    ...
}

schedt结构体

type schedt struct {
    goidgen       uint64    //
    lastpoll      uint64    // 
    lock          mutex     // 锁
    midle         muintptr  // m空闲链表
    nmidle        int32     // 空闲m个数
    nmidlelocked  int64     // 空闲m被锁住个数
    mnext         int64     // 已创建m的个数的下一个m的ID
    maxmcount     int32     // m最大个数
    nmsys         int64     // 系统线程对应的m未死锁的个数
    nmfreed       uint32    // 已经休眠的m的个数
    ngsys         uint32    // 系统线程正在运行的未死锁的g的个数
    pidle         puintptr  // p的空闲链表
    npidle        uint32    // 空闲p的个数
    nmspinning    uint32    // 自旋m的个数
    runq          gQueue    // 全局可运行g队列
    runqsize      int32     // 全局可运行g队列中g的个数
    gFree struct {          // 已经退出的g的链表
        lock     mutex
        stack    gList
        noStack  gList
        n        int32
    }
    ...
}

调度器初始化

下面从调度器的初始化开始,逐步分析go语言调度器在goroutine的一生中所扮演的重要角色。

任何一门编译型语言所编写的程序在被操作系统加载运行起来都会顺序经历下面几个阶段:

1.从磁盘上把可执行程序加载到内存里

2.创建进程和主线程

3.为主线程分配栈空间

4.把由用户在命令行输入的参数拷贝到主线程的栈

5.把主线程放入操作系统的运行队列等待被调度执行起来

在主线程第一次被调度起来执行第一条指令以前,主线程的函数栈如下图所示:

go语言学习笔记(四):调度器基础-爬上那座山_第1张图片

 在Linux中用命令go build 编译程序,可得到可执行程序,然后用gdb调试,可以得到程序的入口为runtime/rt0_linux_amd64.s文件的第八行,可以看到程序执行的第一条指令

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP _rt0_amd64(SB)

_rt0_amd64 这个符号的定义在runtime/asm_amd64.s 文件中:

TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ   0(SP), DI// argc 
    LEAQ   8(SP), SI// argv
    JMP    runtime·rt0_go(SB)

前两行指令把操作系统内核传递过来的参数argc和argv数组的地址分别放在DI和SI寄存器中,第三行指令跳转到 rt0_go 去执行,rt0_go函数完成了go程序启动时的所有初始化工作,下面我们把其中关键的地方拿出来看看。

初始化g0:

MOVQ  $runtime·g0(SB), DI     
LEAQ  (-64*1024+104)(SP), BX
MOVQ  BX, g_stackguard0(DI) 
MOVQ  BX, g_stackguard1(DI) 
MOVQ  BX, (g_stack+stack_lo)(DI)
MOVQ  SP, (g_stack+stack_hi)(DI) 

上面这段代码在主线程栈上给g0划分了一块大概64k大小的空间。

主线程绑定m0

LEAQ runtime·m0+m_tls(SB), DI  //DI=&m0.tls,取m0的tls成员的地址到DI寄存器
CALL runtime·settls(SB)        //调用settls设置线程本地存储,settls函数的参数在DI寄存器中

//验证settls是否可以正常工作,如果有问题则abort退出程序
get_tls(BX)         //获取fs段基地址并放入BX寄存器,get_tls的代码由编译器生成
MOVQ $0x123, g(BX)  //把整型常量0x123拷贝到fs段基地址偏移-8的内存位置,也就是m0.tls[0] =0x123
MOVQ runtime·m0+m_tls(SB), AX
CMPQ AX, $0x123     //检查m0.tls[0]的值是否是通过线程本地存储存入的0x123来验证tls功能是否正常
JEQ 2(PC)
CALL runtime·abort(SB) //如果线程本地存储不能正常工作,退出程序

首先调用settls函数初始化主线程的线程本地存储(TLS),把m0与主线程关联在一起,然后验证TLS功能是否正常,如果不正常则直接abort退出程序。

下面我们详细来详细看一下settls函数是如何实现线程私有全局变量的:

TEXT runtime·settls(SB),NOSPLIT,$32
ADDQ $8, DI       // ELF wants to use -8(FS)

MOVQ DI, SI
MOVQ $0x1002, DI  // ARCH_SET_FS
MOVQ $SYS_arch_prctl, AX
SYSCALL

CMPQ AX, $0xfffffffffffff001
JLS 2(PC)
MOVL $0xf1, 0xf1  // crash
RET

通过arch_prctl系统调用把m0.tls[1]的地址设置成了fs段的段基址。CPU中有个叫fs的段寄存器与之对应,而每个线程都有自己的一组CPU寄存器值,操作系统在把线程调离CPU运行时会帮我们把所有寄存器中的值保存在内存中,调度线程起来运行时又会从内存中把这些寄存器的值恢复到CPU,这样,在此之后,工作线程代码就可以通过fs寄存器来找到m.tls。

g0和m0互相绑定

// set the per-goroutine and per-mach "registers"
get_tls(BX)
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX) 
LEAQ runtime·m0(SB), AX

MOVQ CX, m_g0(AX)  // save m->g0 = g0
MOVQ AX, g_m(CX)   // save g0->m = m0

首先把g0的地址放入主线程的线程本地存储中,然后把m0和g0绑定在一起,这样,之后在主线程中通过get_tls(),g()这两个函数就可以获取到g0,通过g0的m成员又可以找到m0,于是这里就实现了m0和g0与主线程之间的关联。

初始化m0

MOVL 16(SP), AX        // AX = argc
MOVL AX, 0(SP)         // argc放在栈顶
MOVQ 24(SP), AX        // AX = argv
MOVQ AX, 8(SP)         // argv放在SP + 8的位置
CALL runtime·args(SB)  //处理操作系统传递过来的参数

CALL runtime·osinit(SB)     // 全局变量 ncpu = CPU核数
CALL runtime·schedinit(SB)  // 调度系统初始化

到调度系统初始化函数里看一看:

func schedinit() {
    //getg函数在源代码中没有对应的定义,由编译器插入类似下面两行代码
    //get_tls(CX) 
    //MOVQ g(CX), BX
    _g_ := getg() // _g_ = &g0
    ......
   //设置最多启动10000个操作系统线程,也是最多10000个M
    sched.maxmcount=10000
    ......
    mcommoninit(_g_.m) //初始化m0
    ......
    sched.lastpoll = uint64(nanotime())
    procs := ncpu 
    if n, ok: = atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n
    }
    if procresize(procs) != nil {//创建和初始化全局变量allp
        throw("unknown runnable goroutine during bootstrap")
    }
    ......
}

schedinit函数除了初始化m0和p,还设置了全局变量sched的maxmcount成员为10000,限制最多可以创建10000个操作系统线程出来工作。

下面具体看看m0是如何初始化的:

func mcommoninit(mp*m) {
    _g_ := getg() 
 
    // g0 stack won't make sense for user (and is not necessary unwindable).
    if _g_ != _g_.m.g0 {  
        callers(1, mp.createstack[:])
    }
 
    lock(&sched.lock)
    if sched.mnext + 1 < sched.mnext {
        throw("runtime: thread ID overflow")
    }
    mp.id = sched.mnext
    sched.mnext++
    checkmcount() 
 
    mp.fastrand[0] = 1597334677*uint32(mp.id)
    mp.fastrand[1] = uint32(cputicks())
    if mp.fastrand[0]|mp.fastrand[1] ==0{
        mp.fastrand[1] =1
    }
 
    //创建用于信号处理的gsignal,只是简单的从堆上分配一个g结构体对象,然后把栈设置好就返回了
    mpreinit(mp)
    if mp.gsignal!=nil {
        mp.gsignal.stackguard1=mp.gsignal.stack.lo+_StackGuard
    }
 
    //把m挂入全局链表allm之中
    // Add to allm so garbage collector doesn't free g->m
    // when it is just in a register or thread-local storage.
    mp.alllink = allm
 
    // NumCgoCall() iterates over allm w/o schedlock,
    // so we need to publish it safely.
    atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
    unlock(&sched.lock)
 
    // Allocate memory to hold a cgo traceback if the cgo call crashes.
    if iscgo || GOOS == "solaris" || GOOS == "windows" {
        mp.cgoCallers = new(cgoCallers)
    }
}

下面继续调用procresize函数创建和初始化p结构体对象,这里只看和调度器初始化相关的部分:

func procresize(nprocsint32) *p {
    old := gomaxprocs
    ......
   // Grow allp if necessary.
   if nprocs > int32(len(allp)) { 
        // Synchronize with retake, which could be running
        // concurrently since it doesn't run on a P.
        lock(&allpLock)
        if nprocs <= int32(cap(allp)) {
            allp = allp[:nprocs]
        } else { 
            nallp:=make([]*p, nprocs)
            // Copy everything up to allp's cap so we
            // never lose old allocated Ps.
            copy(nallp, allp[:cap(allp)])
            allp=nallp
        }
        unlock(&allpLock)
    }
 
    // initialize new P's
    for i := int32(0); i= 0; i-- {
        p := allp[i]
        if _g_.m.p.ptr() == p {
            continue
        }
        p.status = _Pidle
        if runqempty(p) {
            pidleput(p)
        } else {
            ......
        }
    }
    ......
    return runnablePs
}

函数代码比较长,但并不复杂,总结一下这个函数的主要流程:

  1. 使用make([]*p, nprocs)初始化全局变量allp,即allp = make([]*p, nprocs)

  2. 循环创建并初始化nprocs个p结构体对象并依次保存在allp切片之中

  3. 把m0和allp[0]绑定在一起,即m0.p = allp[0], allp[0].m = m0

  4. 把除了allp[0]之外的所有p放入到全局变量sched的pidle空闲队列之中

到这里,调度器相关的初始化工作就基本结束了,这个时候整个调度器相关的各组成部分之间的联系如下图所示:

go语言学习笔记(四):调度器基础-爬上那座山_第2张图片

第一个goroutine,main goroutine的创建

go程序真正意义上的第一个goroutine,是用来跑runtime.main()函数的,由运行时代码生成,当调度器完成初始化时,schedinit完成调度系统初始化后,返回到rt0_go函数中开始调用newproc() 创建一个新的goroutine用于执行mainPC所对应的runtime·main函数.

# create a new goroutine to start program
MOVQ  $runtime·mainPC(SB), AX
PUSHQ  AX         
PUSHQ  $0
CALL  runtime·newproc(SB)
POPQ  AX
POPQ  AX

# start this M
CALL  runtime·mstart(SB) 

CALL  runtime·abort(SB)  // mstart should never return
RET

DATA  runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOB  Lruntime·mainPC(SB),RODATA,$8

下面看看runtime.newprocs函数:

func 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)
    })
}

在当前goroutine函数栈上存放fn函数的地址的地址加8的位置存放的是fn函数的第一个参数,以此类推,因为fn要在接下来新创建的goroutine的函数栈上执行,所以要把fn的参数拷贝到新goroutine栈上,这就是为什么需要指定siz的缘故。而systemstack函数的作用是从当前栈切换到g0栈执行作为参数的函数.

接下来看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()
    ......
    _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.
    }
    ......
    totalSize := 4*sys.RegSize+uintptr(siz) +sys.MinFrameSize  // extra space in case of reads slightly beyond frame
    totalSize += -totalSize&(sys.SpAlign-1)                    // align to spAlign
    sp := newg.stack.hi-totalSize
    spArg := sp
    ......
    if narg > 0 {
        memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
    }
    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.fn
    ......
    casgstatus(newg, _Gdead, _Grunnable)
    ......
    runqput(_p_, newg, true)
    ......
}

newproc1函数首先在堆上按照_StackMin大小为新的goroutine申请了一块栈空间,然后设置新的goroutine的gobuf结构体对象的成员变量,这里需要注意的是pc的值一开始被设置成goexit函数的第二条指令的地址,是为了在gostartcallfn函数中调用gostartcall函数时伪装fn函数是被goexit函数调用的,使得goroutine中fn函数执行完毕后能够返回来goexit的第二条指令执行剩下的清理工作。

下面我把gostartcallfngostartcall函数列出来,你可以自己看一看他的实现:

// adjust Gobuf as if it executed a call to fn
// and then did an immediate gosave.
func gostartcallfn(gobuf *gobuf, fv *funcval) {
    var fn unsafe.Pointer
    if fv != nil {
       fn = unsafe.Pointer(fv.fn) 
    } else {
        fn = unsafe.Pointer(funcPC(nilfunc))
    }
    gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.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
}

这时,主线程状态大概如下图所示:

go语言学习笔记(四):调度器基础-爬上那座山_第3张图片

第一个goroutine,main goroutine的调度

 从newproc返回到rt0_go,继续往下执行mstart

func mstart() {
    _g_ := getg() 
 
    osStack := _g_.stack.lo == 0
    if 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.hi
        if 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_.stackguard0
     
    mstart1()
 
    // Exit this thread.
    if GOOS == "windows" || GOOS == "solaris" || GOOS == "plan9" || GOOS == "darwin" || GOOS == "aix" {
        // Window, Solaris, Darwin, AIX 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)
}

主线程启动调度循环之时mstart基本就相当于调用mstart1函数,下面看mstart1函数:

func mstart1() {
    _g_ := getg() 

    if _g_ != _g_.m.g0 {
        throw("bad runtime·mstart")
    }
 
    // Record the caller for use as the top of stack in mcall and
    // for terminating the thread.
    // We're never coming back to mstart1 after we call schedule,
    // so other calls can reuse the current frame.
    save(getcallerpc(), getcallersp())
    asminit() 
    minit()  // 与信号相关的初始化
 
    // Install signal handlers; after minit so that minit can
    // prepare the thread to be able to handle the signals.
    if _g_.m == &m0 { 
        mstartm0()  // 信号相关的初始化
    }
 
    if fn := _g_.m.mstartfn; fn != nil { 
        fn()
    }
 
    if _g_.m != &m0 {
        acquirep(_g_.m.nextp.ptr())
        _g_.m.nextp = 0
    }
     
    // schedule函数永远不会返回
    schedule()
}

mstart1函数最重要的工作莫过于调用save函数把mstart函数调用mstart1函数时CALL指令压栈的返回地址保存在了g0.sched.pc里,把mstart函数调用mstart1函数时的栈顶地址保存在了g0.sched.sp里,这样g0每次被调用时都是从固定的sp和pc开始,重用g0的栈,使g0栈在调度循环中也不会溢出。下面可以自己看看save函数内部的代码:

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()
    }
}

save函数执行完成后,返回到mstart1继续其它跟m相关的一些初始化,完成这些初始化后则调用调度系统的核心函数schedule()完成goroutine的调度,之所以说它是核心,原因在于每次调度goroutine都是从schedule函数开始的:

func schedule() {
    _g_ := getg()  
    ......
    var gp *g 
    ...... 
    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
    }
    .....
    execute(gp, inheritTime) 
}

schedule函数通过调用globrunqget()和runqget()函数分别从全局运行队列和当前工作线程的本地运行队列中选取下一个需要运行的goroutine,如果这两个队列都没有需要运行的goroutine则通过findrunnalbe()函数从其它p的运行队列中盗取goroutine,一旦找到下一个需要运行的goroutine,则调用excute函数从g0切换到该goroutine去运行,下面我们来看看execute函数是如何把从运行队列中找出来的goroutine调度到CPU上运行的:

func execute(gp *g, inheritTime bool) {
    _g_ := getg()
    casgstatus(gp, _Grunnable, _Grunning) 
    ......
    _g_.m.curg = gp
    gp.m = _g_.m
    ......
    gogo(&gp.sched)
}

execute函数将当前找到的goroutine的状态更改为_Grunning并与当前线程绑定的m绑定,然后调用gogo函数完成从g0到_g_.m.curg上的真正切换:

TEXT runtime·gogo(SB), NOSPLIT, $16-8
    #buf = &gp.sched
    MOVQ    buf+0(FP), BX     # BX = buf
    
    #gobuf->g --> dx register
    MOVQ    gobuf_g(BX), DX   # DX = gp.sched.g
    
    #下面这行代码没有实质作用,检查gp.sched.g是否是nil,如果是nil进程会crash死掉
    MOVQ    0(DX), CX         # make sure g != nil
    
    get_tls(CX) 
    
    #把要运行的g的指针放入线程本地存储,这样后面的代码就可以通过线程本地存储
    #获取到当前正在执行的goroutine的g结构体对象,从而找到与之关联的m和p
    MOVQ    DX, g(CX)
    
    #把CPU的SP寄存器设置为sched.sp,完成了栈的切换
    MOVQ    gobuf_sp(BX), SP  # restore SP
    
    #下面三条同样是恢复调度上下文到CPU相关寄存器
    MOVQ    gobuf_ret(BX), AX
    MOVQ    gobuf_ctxt(BX), DX
    MOVQ    gobuf_bp(BX), BP
    
    #清空sched的值,因为我们已把相关值放入CPU对应的寄存器了,不再需要,这样做可以少gc的工作量
    MOVQ    $0, gobuf_sp(BX)  # clear to help garbage collector
    MOVQ    $0, gobuf_ret(BX)
    MOVQ    $0, gobuf_ctxt(BX)
    MOVQ    $0, gobuf_bp(BX)
    
    #把sched.pc值放入BX寄存器
    MOVQ    gobuf_pc(BX), BX
    
    #JMP把BX寄存器的包含的地址值放入CPU的IP寄存器,于是,CPU跳转到该地址继续执行指令,
    JMP    BX

最终,CPU开始从runtime.mainPC的代码开始在main goroutine的栈上执行:

// The main goroutine.
func main() {
    g := getg() 
    ......
    // Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
    // Using decimal instead of binary GB and MB because
    // they look nicer in the stack overflow failure message.
    if sys.PtrSize == 8 {
        maxstacksize = 1000000000
    } else {
        maxstacksize = 250000000
    }
 
    // Allow newproc to start new Ms.
    mainStarted = true
 
    if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
        systemstack(func() {
            //创建监控线程,该线程独立于调度器,不需要跟p关联即可运行,我们后续再做分析
            newm(sysmon, nil)
        })
    } 
    ......
    //调用runtime包的初始化函数,由编译器实现
    runtime_init() // must be before defer
 
    // Record when the world started.
    runtimeInitTime = nanotime()
 
    gcenable()  //开启垃圾回收器
    ......
    // main包的初始化函数,也是由编译器实现,会递归的调用我们import进来的包的初始化函数
    fn := main_init  // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
    fn()
    ...... 
    // 调用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 runtime
    fn() 
    ......
    // 进入系统调用,退出进程,可以看出main goroutine并未返回,而是直接进入系统调用退出进程了
    exit(0)
     
    // 保护性代码,如果exit意外返回,下面的代码也会让该进程crash死掉
    for {
        var x *int32
        *x = 0
    }
}

从上述流程可以看出,runtime.main执行完main包的main函数之后就直接调用exit系统调用结束进程了,它并没有返回到调用它的函数,其实runtime.main是main goroutine的入口函数,并不是直接被调用的,而是在schedule()->execute()->gogo()这个调用链的gogo函数中用汇编代码直接跳转过来的,所以从这个角度来说,goroutine确实不应该返回,没有地方可返回啊!可是从前面的分析中我们得知,在创建goroutine的时候已经在其栈上放好了一个返回地址,伪造成goexit函数调用了goroutine的入口函数,这里怎么没有用到这个返回地址啊?其实那是为非main goroutine准备的,非main goroutine执行完成后就会返回到goexit继续执行,而main goroutine执行完成后整个进程就结束了,这是main goroutine与其它goroutine的一个区别,这时,主线程的状态大致如下:

go语言学习笔记(四):调度器基础-爬上那座山_第4张图片

非main goroutine的创建、退出以及调度循环

使用关键字go + func() {} 可以创建一个非main goroutine,相关步骤在这里就省略了,看看主要的,我们说main goroutine退出时会直接执行exit系统调用退出整个进程,而非main goroutine退出时则会进入goexit函数完成最后的清理工作,返回时直接返回到了goexit的第二条指令:CALL runtime·goexit1(SB),该指令继续调用goexit1函数:

// Finishes execution of the current goroutine.
func goexit1() {
    if raceenabled { 
        racegoend()
    }
    if trace.enabled { 
        traceGoEnd()
    }
    mcall(goexit0)
}

goexit1函数通过调用mcall从当前运行的g2 goroutine切换到g0,然后在g0栈上调用和执行goexit0这个函数:

TEXT runtime·mcall(SB), NOSPLIT, $0-8
    # 取出参数的值放入DI寄存器,它是funcval对象的指针,此场景中fn.fn是goexit0的地址
    MOVQ  fn+0(FP), DI

    get_tls(CX)
    MOVQ  g(CX), AX  # AX = g,某个非main goroutine 

    # mcall返回地址放入BX
    MOVQ  0(SP), BX  # caller's PC

    # 保存g的调度信息,因为我们要从当前正在运行的g切换到g0
    MOVQ  BX, (g_sched+gobuf_pc)(AX)  # g.sched.pc = BX,保存g的rip
    LEAQ  fn+0(FP), BX                # caller's SP  
    MOVQ  BX, (g_sched+gobuf_sp)(AX)  # g.sched.sp = BX,保存g的rsp
    MOVQ  AX, (g_sched+gobuf_g)(AX)   # g.sched.g = g
    MOVQ  BP, (g_sched+gobuf_bp)(AX)  # g.sched.bp = BP,保存g的rbp

    # switch to m->g0 & its stack, call fn
    #下面三条指令主要目的是找到g0的指针
    MOVQ  g(CX), BX     # BX = g
    MOVQ  g_m(BX), BX   # BX = g.m
    MOVQ  m_g0(BX), SI  # SI = g.m.g0

    # 此刻,SI = g0, AX = g,所以这里在判断g是否是g0,如果g == g0则一定是哪里代码写错了
    CMPQ  SI, AX# if g == m->g0 call badmcall
    JNE  3(PC)
    MOVQ  $runtime·badmcall(SB), AX
    JMP  AX

    # 把g0的地址设置到线程本地存储之中
    MOVQ  SI, g(CX)

    # 恢复g0的栈顶指针到CPU的rsp积存,这一条指令完成了栈的切换,从g的栈切换到了g0的栈
    MOVQ  (g_sched+gobuf_sp)(SI), SP  # rsp = g0->sched.sp

    # AX = g
    PUSHQ  AX        # fn的参数g入栈 
    MOVQ  DI, DX     # DI是结构体funcval实例对象的指针,它的第一个成员才是goexit0的地址
    MOVQ  0(DI), DI  # 读取第一个成员到DI寄存器
    CALL  DI  #调用goexit0(g)
    POPQ  AX
    MOVQ  $runtime·badmcall2(SB), AX
    JMP  AX
    RET

从g栈切换到g0栈之后,下面开始在g0栈执行goexit0函数,该函数完成最后的清理工作:

func goexit0(gp*g) {
    _g_ := getg()  
 
    casgstatus(gp, _Grunning, _Gdead)
    if isSystemGoroutine(gp, false) {
        atomic.Xadd(&sched.ngsys, -1)
    }
    
    // 清空g保存的一些信息
    gp.m = nil
    locked := 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 = nil
    ......
    // Note that gp's stack scan is now "valid" because it has no stack.
    gp.gcscanvalid=true

    // g->m = nil, m->currg = nil 解绑g和m之关系
    dropg()
    ......

    // g放入p的freeg队列,方便下次重用,免得再去申请内存,提高效率
    gfput(_g_.m.p.ptr(), gp)
    ......

    schedule()
}

到这里这个非main goroutine的生命周期就结束了,工作线程再次调用了schedule函数进入新一轮的调度循环

schedule()->execute()->gogo()->某个非main goroutine执行的函数->goexit()->goexit1()->mcall()->goexit0()->schedule()

调度策略

schedule函数分三步分别从各运行队列中寻找可运行的goroutine:

第一步,从全局运行队列中寻找goroutine。为了保证调度的公平性,每个工作线程每经过61次调度就需要优先尝试从全局运行队列中找出一个goroutine来运行,这样才能保证位于全局运行队列中的goroutine得到调度的机会。全局运行队列是所有工作线程都可以访问的,所以在访问它之前需要加锁。

第二步,从工作线程本地运行队列中寻找goroutine。如果不需要或不能从全局运行队列中获取到goroutine则从本地运行队列中获取。

第三步,从其它工作线程的运行队列中偷取goroutine。如果上一步也没有找到需要运行的goroutine,则调用findrunnable从其他工作线程的运行队列中偷取goroutine,findrunnable函数在偷取之前会再次尝试从全局运行队列和当前线程的本地运行队列中查找需要运行的goroutine。

从全局运行队列中获取goroutine的globrunqget函数

// Try get a batch of G's from the global runnable queue.
// Sched must be locked.
func globrunqget(_p_ *p, max int32) *g {
    if sched.runqsize == 0 { 
        return nil
    }
 
    // 根据p的数量平分全局运行队列中的goroutines
    n := sched.runqsize / gomaxprocs + 1
    if n > sched.runqsize { 
        n = sched.runqsize
    }
    if max > 0 && n > max {
        n = max   
    }
    if n > int32(len(_p_.runq)) / 2 {
        n = int32(len(_p_.runq)) / 2  
    }
 
    sched.runqsize -= n
 
    // 直接通过函数返回gp,其它的goroutines通过runqput放入本地运行队列
    gp := sched.runq.pop()  
    n--
    for ; n > 0; n-- {
        gp1 := sched.runq.pop()  
        runqput(_p_, gp1, false)  
    }
    return gp
}

从工作线程本地运行队列中获取goroutine的runqget函数:

func runqget(_p_ *p) (gp *g, inheritTime bool) {
    // If there's a runnext, it's the next G to run.
    for {
        next := _p_.runnext 
        if next == 0 {
            break
        }
        if _p_.runnext.cas(next, 0) {
            return next.ptr(), true
        }
    }
 
    for {
        // load-acquire, synchronize with other consumers
        h := atomic.LoadAcq(&_p_.runqhead)
        t := _p_.runqtail
        if t == h {
            return nil, false
        }
        gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
        // cas-release, commits consume
        if atomic.CasRel(&_p_.runqhead, h, h+1) {
            return gp, false
        }
    }
}

这里首先需要注意的是不管是从runnext还是从循环队列中拿取goroutine都使用了cas操作,这里的cas操作是必需的,因为可能有其他工作线程此时此刻也正在访问这两个成员,从这里偷取可运行的goroutine。

其次,代码中对runqhead的操作使用了atomic.LoadAcq和atomic.CasRel,它们分别提供了load-acquire和cas-release语义。

对于atomic.LoadAcq来说,其语义主要包含如下几条

  1. 原子读取,也就是说不管代码运行在哪种平台,保证在读取过程中不会有其它线程对该变量进行写入;

  2. 位于atomic.LoadAcq之后的代码,对内存的读取和写入必须在atomic.LoadAcq读取完成后才能执行,编译器和CPU都不能打乱这个顺序;

  3. 当前线程执行atomic.LoadAcq时可以读取到其它线程最近一次通过atomic.CasRel对同一个变量写入的值,与此同时,位于atomic.LoadAcq之后的代码,不管读取哪个内存地址中的值,都可以读取到其它线程中位于atomic.CasRel(对同一个变量操作)之前的代码最近一次对内存的写入。

对于atomic.CasRel来说,其语义主要包含如下几条

  1. 原子的执行比较并交换的操作;

  2. 位于atomic.CasRel之前的代码,对内存的读取和写入必须在atomic.CasRel对内存的写入之前完成,编译器和CPU都不能打乱这个顺序;

  3. 线程执行atomic.CasRel完成后其它线程通过atomic.LoadAcq读取同一个变量可以读到最新的值,与此同时,位于atomic.CasRel之前的代码对内存写入的值,可以被其它线程中位于atomic.LoadAcq(对同一个变量操作)之后的代码读取到。

因为可能有多个线程会并发的修改和读取runqhead,以及需要依靠runqhead的值来读取runq数组的元素,所以需要使用atomic.LoadAcq和atomic.CasRel来保证上述语义。

我们可能会问,为什么读取p的runqtail成员不需要使用atomic.LoadAcq或atomic.load?因为runqtail不会被其它线程修改,只会被当前工作线程修改,此时没有人修改它,所以也就不需要使用原子相关的操作。

最后,由p的runq、runqhead和runqtail这三个成员组成的这个无锁循环队列非常精妙,我们会在后面的章节对这个循环队列进行分析。

从其它工作线程的运行队列偷取goroutine的findrunnable函数:

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from global queue, poll network.
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()
    ......

    // local runq
    if gp, inheritTime := runqget(_p_); gp != nil {
        return gp, inheritTime
    }
 
    // global runq
    if sched.runqsize != 0 {
        lock(&sched.lock)
        gp := globrunqget(_p_, 0)
        unlock(&sched.lock)
        if gp != nil {
            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 g
            if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
                return gp, false
            }
        }
    }
 
stop:    
    ......
 
    // 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 block
    lock(&sched.lock)
    ......
   
    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.spinning
    if _g_.m.spinning {
        _g_.m.spinning = false
        if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
            throw("findrunnable: negative nmspinning")
        }
    }
 
    // check all runqueues once again
    for _, _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
        }
    } 
    ......

    stopm()
    goto top
}

从上面的代码可以看到,工作线程在放弃寻找可运行的goroutine而进入睡眠之前,会反复尝试从各个运行队列寻找需要运行的goroutine,可谓是尽心尽力了。这个函数需要重点注意以下两点:

第一点,工作线程M的自旋状态(spinning)工作线程在从其它工作线程的本地运行队列中盗取goroutine时的状态称为自旋状态。从上面代码可以看到,当前M在去其它p的运行队列盗取goroutine之前把spinning标志设置成了true,同时增加处于自旋状态的M的数量,而盗取结束之后则把spinning标志还原为false,同时减少处于自旋状态的M的数量,从后面的分析我们可以看到,当有空闲P又有goroutine需要运行的时候,这个处于自旋状态的M的数量决定了是否需要唤醒或者创建新的工作线程。

第二点,盗取算法。盗取过程用了两个嵌套for循环。内层循环实现了盗取逻辑,从代码可以看出盗取的实质就是遍历allp中的所有p,查看其运行队列是否有goroutine,如果有,则取其一半到当前工作线程的运行队列,然后从findrunnable返回,如果没有则继续遍历下一个p。但这里为了保证公平性,遍历allp时并不是固定的从allp[0]即第一个p开始,而是从随机位置上的p开始,而且遍历的顺序也随机化了,并不是现在访问了第i个p下一次就访问第i+1个p,而是使用了一种伪随机的方式遍历allp中的每个p,防止每次遍历时使用同样的顺序访问allp中的元素盗取算法比较复杂,目前仍没有完全弄懂,需后续进一步研究,这里先大致给出这个算法的伪代码

offset := uint32(random()) % nprocs
coprime := 随机选取一个小于nprocs且与nprocs互质的数
for i := 0; i < nprocs; i++ {
    p := allp[offset]
    从p的运行队列偷取goroutine
    if 偷取成功 {
        break
    }
    offset += coprime
    offset = offset % nprocs
}

不管nprocs是多少,这个算法都可以保证经过nprocs次循环,每个p都可以得到访问。

挑选出盗取的对象p之后,则调用runqsteal盗取p的运行队列中的goroutine,runqsteal函数再调用runqgrap从p的队列中批量拿出多个goroutine:

func runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
    for {
        h := atomic.LoadAcq(&_p_.runqhead)  // load-acquire, synchronize with other consumers
        t := atomic.LoadAcq(&_p_.runqtail)  // load-acquire, synchronize with the producer
        n := t - h       
        n = n - n/2    
        if n == 0 {
            ......
            return ......
        }

        if n > uint32(len(_p_.runq)/2) {  // read inconsistent h and t
            continue
        }
        ......
    }
}

回到findrunnable函数,如果工作线程经过多次努力一直找不到需要运行的goroutine则调用stopm进入睡眠状态,等待被其它工作线程唤醒。

// Stops execution of the current m until new work is available.
// Returns with acquired P.
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")
   }
 
   lock(&sched.lock)
   mput(_g_.m)   
   unlock(&sched.lock)
   notesleep(&_g_.m.park)  
   
   noteclear(&_g_.m.park)
   acquirep(_g_.m.nextp.ptr())
   _g_.m.nextp = 0
}

stopm的核心是调用mput把m结构体对象放入sched的midle空闲队列,然后通过notesleep(&m.park)函数让自己进入睡眠状态。

note是go runtime实现的一次性睡眠和唤醒机制,一个线程可以通过调用notesleep(*note)进入睡眠状态,而另外一个线程则可以通过notewakeup(*note)把其唤醒。note的底层实现机制跟操作系统相关,不同系统使用不同的机制,比如linux下使用的futex系统调用,而mac下则是使用的pthread_cond_t条件变量,note对这些底层机制做了一个抽象和封装,这种封装给扩展性带来了很大的好处,比如当睡眠和唤醒功能需要支持新平台时,只需要在note层增加对特定平台的支持即可,不需要修改上层的任何代码。

回到stopm,当从notesleep函数返回后,需要再次绑定一个p,然后返回到findrunnable函数继续重新寻找可运行的goroutine,一旦找到可运行的goroutine就会返回到schedule函数,并把找到的goroutine调度起来运行。下面看看notesleep函数的实现:

func notesleep(n *note) {
    gp := getg()
    if gp != gp.m.g0 {
        throw("notesleep not on g0")
    }
    ns := int64(-1)  // 超时时间设置为-1,表示无限期等待
    if *cgo_yield != nil {
        // Sleep for an arbitrary-but-moderate interval to poll libc interceptors.
        ns = 10e6
    }
   
    // 使用循环,保证不是意外被唤醒
    for atomic.Load(key32(&n.key)) == 0 {
        gp.m.blocked = true
        futexsleep(key32(&n.key), 0, ns)
        if *cgo_yield != nil {
            asmcgocall(*cgo_yield, nil)
        }
        gp.m.blocked = false
    }
}

notesleep函数调用futexsleep进入睡眠,这里之所以需要用一个循环,是因为futexsleep有可能意外从睡眠中返回,所以从futexsleep函数返回后还需要检查note.key是否还是0,如果是0则表示并不是其它工作线程唤醒了我们,只是futexsleep意外返回了,需要再次调用futexsleep进入睡眠。

futexsleep调用futex函数进入睡眠:

// Atomically,
// if(*addr == val) sleep
// Might be woken up spuriously; that's allowed.
// Don't sleep longer than ns; ns < 0 means forever.
// go:nosplit
func futexsleep(addr *uint32, val uint32, ns int64) {
    var ts timespec
 
    // Some Linux kernels have a bug where futex of
    // FUTEX_WAIT returns an internal error code
    // as an errno. Libpthread ignores the return value
    // here, and so can we: as it says a few lines up,
    // spurious wakeups are allowed.
    if ns < 0 {
        futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, nil, nil, 0)
        return
    }
 
    // It's difficult to live within the no-split stack limits here.
    // On ARM and 386, a 64-bit divide invokes a general software routine
    // that needs more stack than we can afford. So we use timediv instead.
    // But on real 64-bit systems, where words are larger but the stack limit
    // is not, even timediv is too heavy, and we really need to use just an
    // ordinary machine instruction.
    if sys.PtrSize == 8 {
        ts.set_sec(ns / 1000000000)
        ts.set_nsec(int32(ns % 1000000000))
    } else {
        ts.tv_nsec = 0
        ts.set_sec(int64(timediv(ns, 1000000000, (*int32)(unsafe.Pointer(&ts.tv_nsec)))))
    }
    futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, unsafe.Pointer(&ts), nil, 0)
}

futex是go汇编实现的函数,主要功能就是执行futex系统调用进入操作系统内核进行睡眠:

// int64 futex(int32 *uaddr, int32 op, int32 val,
// struct timespec *timeout, int32 *uaddr2, int32 val2);
TEXT runtime·futex(SB),NOSPLIT,$0
    MOVQ    addr+0(FP), DI
    MOVL    op+8(FP), SI
    MOVL    val+12(FP), DX
    MOVQ    ts+16(FP), R10
    MOVQ    addr2+24(FP), R8
    MOVL    val3+32(FP), R9
    
    MOVL    $SYS_futex, AX   
    SYSCALL 
    MOVL    AX, ret+40(FP)    
    RET

futex系统调用为我们提供的功能为如果 *uaddr == val 则进入睡眠,否则直接返回。顺便说一下,为什么futex系统调用需要第三个参数val,需要在内核判断*uaddr与val是否相等,而不能在用户态先判断它们是否相等,如果相等才进入内核睡眠岂不是更高效?原因在于判断*uaddr与val是否相等和进入睡眠这两个操作必须是一个原子操作,否则会存在一个竞态条件:如果不是原子操作,则当前线程在第一步判断完*uaddr与val相等之后进入睡眠之前的这一小段时间内,有另外一个线程通过唤醒操作把*uaddr的值修改了,这就会导致当前工作线程永远处于睡眠状态而无人唤醒它。而在用户态无法实现判断与进入睡眠这两步为一个原子操作,所以需要内核来为其实现原子操作。

stopm调用notesleep时给它传递的参数是m结构体的park成员,而m又早已通过mput放入了全局的milde空闲队列,这样其它运行着的线程一旦发现有更多的goroutine需要运行时就可以通过全局的m空闲队列找到处于睡眠状态的m,然后调用notewakeup(&m.park)将其唤醒。

调度时机

go语言的调度器会在以下三种情况下对goroutine进行调度:

1)goroutine执行某个操作因条件不满足需要等待而发生的调度;

2)goroutine主动调用Gosched()函数让出CPU而发生的调度;

3)goroutine运行时间太长或长时间处于系统调用之中而被调度器剥夺运行权而发生的调度。

第一种情况是被动调度,以一个demo程序为例来分析因阻塞而发生的被动调度:

package main
 
func start(l chan string) {
    l <- "tx"
}
 
func main() {
    l := make(chan string)
 
    go start(l)
 
    <- l
}

创建goroutine的go关键字会被编译器翻译成了对runtime.newproc函数的调用:

1)切换到g0栈;

2)分配g结构体对象;

3)初始化g对应的栈信息,并把参数拷贝到新g的栈上;

4)设置好g的sched成员,该成员包括调度g时所必须pc, sp, bp等调度信息;

5)调用runqput函数把g放入运行队列;

6)返回

// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the _p_.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool)   {
    if randomizeScheduler && next && fastrand() % 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
        }
        // Kick the old runnext out to the regular run queue.
        gp = oldnext.ptr() 
    }
 
retry:
    h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
    t := _p_.runqtail
    if t - h < uint32(len(_p_.runq))  {
        _p_.runq[t % uint32(len(_p_.runq))].set(gp)
        
        // store-release, makes it available for consumption
        // 虽然没有其它线程并发修改这个runqtail,但其它线程会并发读取该值以及p的runq成员
        // 这里使用StoreRel是为了:
        // 1)原子写入runqtail
        // 2)防止编译器和CPU乱序,保证上一行代码对runq的修改发生在修改runqtail之前
        // 3)可见行屏障,保证当前线程对运行队列的修改对其它线程立马可见
        atomic.StoreRel(&_p_.runqtail, t + 1)
        return
    }
    if runqputslow(_p_, gp, h, t) {
        return
    }

    // the queue is not full, now the put above must succeed
    goto retry
}

runqput函数流程很清晰,它首先尝试把gp放入_p_的本地运行队列,如果本地队列满了,则通过runqputslow函数把gp放入全局运行队列:

// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(_p_ *p, gp *g, h, t uint32) bool  {
    var batch [len(_p_.runq) / 2 + 1]*g  //gp加上_p_本地队列的一半
 
    // First, grab a batch from local queue.
    n := t - h
    n = n / 2
    if n != uint32(len(_p_.runq) / 2)  {
        throw("runqputslow: queue is not full")
    }

    // 取出p本地队列的一半
    for i := uint32(0); i < n; i++ { 
        batch[i] = _p_.runq[(h+i) % uint32(len(_p_.runq))].ptr()
    }

    // 如果cas操作失败,说明已经有其它工作线程从_p_的本地运行队列偷走了一些goroutine,所以直接返回
    if !atomic.CasRel(&_p_.runqhead, h, h + n)  { // cas-release, commits consume
        return false
    }
    batch[n] = gp
 
    if randomizeScheduler {
        for i := uint32(1); i <= n; i++ {
            j := fastrandn(i + 1)
            batch[i], batch[j] = batch[j], batch[i]
        }
    }
 
    // Link the goroutines.
    // 全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的g链接起来,
    // 减少后面对全局链表的锁住时间,从而降低锁冲突
    for i := uint32(0); i < n; i++  {
        batch[i].schedlink.set(batch[i+1])
    }
    var q gQueue
    q.head.set(batch[0])
    q.tail.set(batch[n])
 
    // Now put the batch on global queue.
    lock(&sched.lock)
    globrunqputbatch(&q, int32(n+1))
    unlock(&sched.lock)
    return true
}

runqputslow函数首先使用链表把从_p_的本地队列中取出的一半连同gp一起串联起来,然后在加锁成功之后通过globrunqputbatch函数把该链表链入全局运行队列。

接下来继续分析main goroutine因读取channel而发生的阻塞流程,读取channel是通过调用runtime.chanrecv1函数来完成的:

// entry points for <- c from compiled code
//go:nosplit
func chanrecv1(c *hchan, elem unsafe.Pointer) {
    chanrecv(c, elem, true)
}
 
// runtime/chan.go : 415
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    ......
    //省略部分的代码逻辑主要在判断读取操作是否可以立即完成,如果不能立即完成
    //就需要把g挂在channel l的读取队列上,然后调用goparkunlock函数阻塞此goroutine
    goparkunlock(&c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3)
    ......
}

chanrecv1直接调用chanrecv函数实现读取操作,chanrecv首先会判断channel是否有数据可读,如果有数据则直接读取并返回,但如果没有数据,则需要把当前goroutine挂入channel的读取队列之中并调用goparkunlock函数阻塞该goroutine:

// Puts the current goroutine into a waiting state and unlocks the lock.
// The goroutine can be made runnable again by calling goready(gp).
func goparkunlock(lock*mutex, reasonwaitReason, traceEvbyte, traceskipint) {
    gopark(parkunlock_c, unsafe.Pointer(lock), reason, traceEv, traceskip)
}
 
// runtime/proc.go : 276
// Puts the current goroutine into a waiting state and calls unlockf.
// If unlockf returns false, the goroutine is resumed.
// unlockf must not access this G's stack, as it may be moved between
// the call to gopark and the call to unlockf.
// Reason explains why the goroutine has been parked.
// It is displayed in stack traces and heap dumps.
// Reasons should be unique and descriptive.
// Do not re-use reasons, add new ones.
func gopark(unlockffunc(*g, unsafe.Pointer) bool, lockunsafe.Pointer, reason    waitReason, traceEvbyte, traceskipint) {
    ......
    // can't do anything that might move the G between Ms here.
    mcall(park_m) // 切换到g0栈执行park_m函数
}

goparkunlock函数直接调用gopark函数,gopark则调用mcall从当前main goroutine切换到g0去执行park_m函数:

// park continuation on g0.
func park_m(gp*g) {
    _g_ := getg()
 
    if trace.enabled {
        traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
    }
 
    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()  // 解除g和m之间的关系
    ......
    
    schedule()
}

park_m首先把当前goroutine的状态设置为_Gwaiting,然后调用dropg函数解除g和m之间的关系,最后通过调用schedule函数进入调度循环,因为main goroutine在读取channel被阻塞之前已经把创建好的g2(执行start函数的goroutine)放入了运行队列,所以在这里schedule会把g2调度起来运行,这里完成了一次从main goroutine到g2调度。

g2 goroutine的入口是start函数,下面我们就从该函数开始分析g2写channel的流程,看它如何唤醒正在等待着读取channel的main goroutine,编译器会把对channel的发送操作翻译成了对runtime.chansend1函数的调用:

func chansend1(c *hchan, elem unsafe.Pointer) {
    chansend(c, elem, true, getcallerpc())
}
 
// runtime/chan.go
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    ......
    if sg := c.recvq.dequeue(); sg != nil {
        // Found a waiting receiver. We pass the value we want to send
        // directly to the receiver, bypassing the channel buffer (if any).
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }
    ......
}
 
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
    ......
    goready(gp, skip+1)
}
 
func goready(gp *g, traceskip int) {
    systemstack(func() {
        ready(gp, traceskip, true)
    })
}

channel发送和读取的流程类似,如果能够立即发送则立即发送并返回,如果不能立即发送则需要阻塞,在我们这个场景中,因为main goroutine此时此刻正挂在channel的读取队列上等待数据,所以这里直接调用send函数发送给main goroutine,send函数则调用goready函数切换到g0栈并调用ready函数来唤醒sg对应的goroutine,即正在等待读channel的main goroutine。

// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
    ......
    // Mark runnable.
    _g_ := getg()
    ......
    // status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
    casgstatus(gp, _Gwaiting, _Grunnable)
    runqput(_g_.m.p.ptr(), gp, next) 
    if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
        wakep()
    }
    ......
}

ready函数首先把需要唤醒的goroutine的状态设置为_Grunnable,然后把其放入运行队列之中等待调度器的调度。

执行到这里main goroutine已经被放入了运行队列,但还未被调度起来运行,而g2 goroutine在向channel写完数据之后就从这里的ready函数返回并退出了,在g2的退出过程中将会在goexit0函数中调用schedule函数进入下一轮调度,从而把刚刚放入运行队列的main goroutine调度起来运行。

为了充分利用CPU,ready函数在唤醒goroutine之后会去判断是否需要启动新工作线程出来工作,判断规则是,如果当前有空闲的p而且没有工作线程正在尝试从各个工作线程的本地运行队列偷取goroutine的话(没有处于spinning状态的工作线程),那么就需要把空闲的p唤醒起来工作,唤醒空闲的p是由wakep函数完成的:

func wakep() {
    // be conservative about spinning threads
    if !atomic.Cas(&sched.nmspinning, 0, 1) {
        return
    }
    startm(nil, true)
}

如cas操作成功,则继续调用startm创建一个新的或唤醒一个处于睡眠状态的工作线程出来工作:

func startm(_p_ *p, spinning bool) {
    lock(&sched.lock)
    // 没有指定p的话需要从p的空闲队列中获取一个p
    if _p_ == nil { 
        // 从p的空闲队列中获取空闲p
        _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.
                // spinning为true表示进入这个函数之前已经对sched.nmspinning加了1,需要还原
                if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
                    throw("startm: negative nmspinning")
                }
            }
            return
        }
    }

    // 从m空闲队列中获取正处于睡眠之中的工作线程,所有处于睡眠状态的m都在此队列中
    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)
}

startm函数首先判断是否有空闲的p结构体对象,如果没有则直接返回,如果有则需要创建或唤醒一个工作线程出来与之绑定,从这里可以看出所谓的唤醒p,其实就是把空闲的p利用起来。

在确保有可以绑定的p对象之后,startm函数首先尝试从m的空闲队列中查找正处于休眠状态的工作线程,如果找到则通过notewakeup函数唤醒它,否则调用newm函数创建一个新的工作线程出来。

func notewakeup(n *note) {
    // 设置n.key = 1, 被唤醒的线程通过查看该值是否等于1来确定是被其它线程唤醒还是意外从睡眠中苏醒
    old := atomic.Xchg(key32(&n.key), 1) 
    if old != 0 {
        print("notewakeup - double wakeup (", old, ")\n")
        throw("notewakeup - double wakeup")
    }
    // 调用futexwakeup唤醒
    futexwakeup(key32(&n.key), 1)
}

notewakeup函数首先使用atomic.Xchg设置note.key值为1,这是为了使被唤醒的线程可以通过查看该值是否等于1来确定是被其它线程唤醒还是意外从睡眠中苏醒了过来,如果该值为1则表示是被唤醒的,可以继续工作了,但如果该值为0则表示是意外苏醒,需要再次进入睡眠。

把note.key的值设置为1后,notewakeup函数继续调用futexwakeup函数:

func futexwakeup(addr *uint32, cnt uint32) {
    // 调用futex函数唤醒工作线程
    ret := futex(unsafe.Pointer(addr), _FUTEX_WAKE_PRIVATE, cnt, nil, nil, 0)
    if ret >= 0 {
        return
    }
 
    // I don't know that futex wakeup can return
    // EAGAIN or EINTR, but if it does, it would be
    // safe to loop and call futex again.
    systemstack(func() {
        print("futexwakeup addr=", addr, " returned ", ret, "\n")
    })
 
    *(*int32)(unsafe.Pointer(uintptr(0x1006))) = 0x1006
}

对于Linux平台来说,工作线程通过note睡眠其实是通过futex系统调用睡眠在内核之中,所以唤醒处于睡眠状态的线程也需要通过futex系统调用进入内核来唤醒,所以这里的futexwakeup又继续调用包装了futex系统调用的futex函数来实现唤醒睡眠在内核中的工作线程:

TEXT runtime·futex(SB),NOSPLIT,$0
    MOVQ  addr+0(FP), DI  #这6条指令在为futex系统调用准备参数
    MOVL  op+8(FP), SI
    MOVL  val+12(FP), DX
    MOVQ  ts+16(FP), R10
    MOVQ  addr2+24(FP), R8
    MOVL  val3+32(FP), R9
    MOVL  $SYS_futex, AX  #futex系统调用编号放入AX寄存器
    SYSCALL  #系统调用,进入内核
    MOVL  AX, ret+40(FP)  #系统调用通过AX寄存器返回返回值,这里把返回值保存到内存之中
    RET

futex函数由汇编代码写成,前面的几条指令都在为futex系统调用准备参数,参数准备完成之后则通过SYSCALL指令进入操作系统内核完成线程的唤醒功能,内核在完成唤醒工作之后当前工作线程则从内核返回到futex函数继续执行SYSCALL指令之后的代码并按函数调用链原路返回,继续执行其它代码,而被唤醒的工作线程则由内核负责在适当的时候调度到CPU上运行。

回到startm函数,如没有正处于休眠状态的工作线程,则需要调用newm函数新建一个工作线程:

func newm(fn func(), _p_ *p) {
    mp := allocm(_p_, fn)
    mp.nextp.set(_p_)
    ......
    newm1(mp)
}

newm首先调用allocm函数从堆上分配一个m结构体对象,然后调用newm1函数:

func newm1(mp *m) {
      .......
      execLock.rlock()  // Prevent process clone.
      newosproc(mp)
      execLock.runlock()
}

newm1继续调用newosproc函数,newosproc的主要任务是调用clone函数创建一个系统线程,而新建的这个系统线程将从mstart函数开始运行:

//clone系统调用的Flags选项
cloneFlags = _CLONE_VM | /* share memory */  //指定父子线程共享进程地址空间
  _CLONE_FS | /* share cwd, etc */
  _CLONE_FILES | /* share fd table */
  _CLONE_SIGHAND | /* share sig handler table */
  _CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */
  _CLONE_THREAD /* revisit - okay for now */  //创建子线程而不是子进程

func newosproc(mp *m) {
    stk := unsafe.Pointer(mp.g0.stack.hi)                   
    ......
    ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0),         unsafe.Pointer(funcPC(mstart)))
    ......
}

clone函数是由汇编语言实现的,该函数使用clone系统调用完成创建系统线程的核心功能。我们分段来看:

// int32 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void));
TEXT runtime·clone(SB),NOSPLIT,$0
    MOVL  flags+0(FP), DI
    MOVQ  stk+8(FP), SI  
    MOVQ  $0, DX       
    MOVQ  $0, R10        

    // Copy mp, gp, fn off parent stack for use by child.
    // Careful:Linux system call clobbers CXand R11.
    MOVQ  mp+16(FP), R8
    MOVQ  gp+24(FP), R9
    MOVQ  fn+32(FP), R12

    MOVL  $SYS_clone, AX
    SYSCALL

clone函数首先用了4条指令为clone系统调用准备参数,该系统调用一共需要四个参数,根据Linux系统调用约定,这四个参数需要分别放入rdi, rsi,rdx和r10寄存器中,这里最重要的是第一个参数和第二个参数,分别用来指定内核创建线程时需要的选项和新线程应该使用的栈。因为即将被创建的线程与当前线程共享同一个进程地址空间,所以这里必须为子线程指定其使用的栈,否则父子线程会共享同一个栈从而造成混乱,从上面的newosproc函数可以看出,新线程使用的栈为m.g0.stack.lo~m.g0.stack.hi这段内存,而这段内存是newm函数在创建m结构体对象时从进程的堆上分配而来的。

准备好系统调用的参数之后,还有另外一件很重的事情需要做,那就是把clone函数的其它几个参数(mp, gp和线程入口函数)保存到寄存器中,之所以需要在系统调用之前保存这几个参数,原因在于这几个参数目前还位于父线程的栈之中,而一旦通过系统调用把子线程创建出来之后,子线程将会使用我们在clone系统调用时给它指定的栈,所以这里需要把这几个参数先保存到寄存器,等子线程从系统调用返回后直接在寄存器中获取这几个参数。这里要注意的是虽然这个几个参数值保存在了父线程的寄存器之中,但创建子线程时,操作系统内核会把父线程的所有寄存器帮我们复制一份给子线程,所以当子线程开始运行时就能拿到父线程保存在寄存器中的值,从而拿到这几个参数。这些准备工作完成之后代码调用syscall指令进入内核,由内核帮助我们创建系统线程。clone系统调用完成后实际上就多了一个操作系统线程,新创建的子线程和当前线程都得从系统调用返回然后继续执行后面的代码,那么从系统调用返回之后我们怎么知道哪个是父线程哪个是子线程,从而来决定它们的执行流程?使用过fork系统调用的读者应该知道,我们需要通过返回值来判断父子线程,系统调用的返回值如果是0则表示这是子线程,不为0则表示这个是父线程。用go代码来描述大概就是这个样子:

if clone(...) == 0 { // 子线程
    子线程代码
} else { // 父线程
    父线程代码
}

虽然这里只有一次clone调用,但它却返回了2次,一次返回到父线程,一次返回到子线程,然后2个线程各自执行自己的代码流程。

回到clone函数,下面代码的第一条指令就在判断系统调用的返回值,如果是子线程则跳转到后面的代码继续执行,如果是父线程,它创建子线程的任务已经完成,所以这里把返回值保存在栈上之后就直接执行ret指令返回到newosproc函数了。

CMPQ  AX, $0          # 判断clone系统调用的返回值
JEQ   3(PC)           # 跳转到子线程部分
MOVL  AX, ret+40(FP)  # 父线程需要执行的指令
RET                   # 父线程需要执行的指令

而对于子线程来说,还有很多初始化工作要做,下面是子线程需要继续执行的指令:

    # In child, on new stack.
    MOVQ  SI, SP  # 设置CPU栈顶寄存器指向子线程的栈顶

    # If g or m are nil, skip Go-related setup.
    CMPQ  R8, $0  # m,新创建的m结构体对象的地址,由父线程保存在R8寄存器中的值被复制到了子线程
    JEQ  nog
    CMPQ  R9, $0  # g,m.g0的地址,由父线程保存在R9寄存器中的值被复制到了子线程
    JEQ  nog

    # Initialize m->procid to Linux tid
    MOVL  $SYS_gettid, AX  # 通过gettid系统调用获取线程ID(tid)
    SYSCALL
    MOVQ  AX, m_procid(R8)  # m.procid = tid

    # Set FS to point at m->tls.
    # 新线程刚刚创建出来,还未设置线程本地存储,即m结构体对象还未与工作线程关联起来,
    # 下面的指令负责设置新线程的TLS,把m对象和工作线程关联起来
    LEAQ  m_tls(R8), DI  # 取m.tls字段的地址
    CALL  runtime·settls(SB)

    #In child, set up new stack
    get_tls(CX)
    MOVQ  R8, g_m(R9)  # g.m = m 
    MOVQ  R9, g(CX)  # tls.g = &m.g0
    CALL  runtime·stackcheck(SB)

nog:
    # Call fn
    CALL  R12  # 这里调用mstart函数
    ......

这段代码的第一条指令把CPU寄存器的栈顶指针设置为新线程的的栈顶,这条指令看起来是多余的,因为我们在clone系统调用时已经把栈信息告诉操作系统了,操作系统在把新线程调度起来运行时已经帮我们把CPU的rsp寄存器设置好了,这里应该没必要自己去设置。接下来的4条指令判断m和g是否为nil,如果是则直接去执行fn函数,对于我们这个流程来说,因为现在正在创建工作线程,所以m和g(其实是m.g0)都不为空,因而需要继续对m进行初始化。

对新创建出来的工作线程的初始化过程从上面代码片段的第6条指令开始,它首先通过系统调用获取到子线程的线程id,并赋值给m.procid,然后调用settls设置线程本地存储并通过把m.g0的地址放入线程本地存储之中,从而实现了m结构体对象与工作线程之间的关联,settls函数我们已经在第二章详细分析过,所以这里直接跳过。

新工作线程的初始化完成之后,便开始执行mstart函数,我们在第二章也见过该函数,主线程初始化完成之后也是调用的它。回忆一下,mstart函数首先会去设置m.g0的stackguard成员,然后调用mstart1()函数把当前工作线程的g0的调度信息保存在m.g0.sched成员之中,最后通过调用schedule函数进入调度循环。

你可能感兴趣的:(go语言调度器的一些学习笔记,golang,开发语言,后端)