Golang源码学习:调度逻辑(二)main goroutine的创建

接上一篇继续分析一下runtime.newproc方法。

函数签名

newproc函数的签名为 newproc(siz int32, fn *funcval)

siz是传入的参数大小(不是个数); fn对应的是函数,但并不是函数指针,funcval.fn才是真正指向函数代码的指针。

// go/src/runtime/runtime2.go
type funcval struct {
    fn uintptr // 真正指向函数代码的指针
}

关键字go

在golang中编译器会把类似 go foo() 编译成调用 runtime.newproc 方法。

准备一段代码:

package main

import (
    "fmt"
    "time"
)

func main() {
    go printAdd(3, 7)
    time.Sleep(time.Second)
}

func printAdd(a, b int) {
    fmt.Println(a + b)
}

开始调试:

关于golang栈结构的分析可以参考 Golang源码学习:使用gdb调试探究Golang函数调用栈结构

root@xiamin:~/study# dlv debug test.go
Type 'help' for list of commands.
(dlv) b main.main
Breakpoint 1 set at 0x4ada0f for main.main() ./test.go:8
(dlv) c
> main.main() ./test.go:8 (hits goroutine(1):1 total:1) (PC: 0x4ada0f)
     3: import (
     4:     "fmt"
     5:     "time"
     6: )
     7:
=>   8:  func main() {
     9:     go printAdd(3, 7)
    10:     time.Sleep(time.Second)
    11: }
    12:
    13: func printAdd(a, b int) {

// 这里执行几次si,得到下面。

(dlv) disass
TEXT main.main(SB) /root/study/test.go
    test.go:8       0x4ada00    64488b0c25f8ffffff  mov rcx, qword ptr fs:[0xfffffff8]
    test.go:8       0x4ada09    483b6110        cmp rsp, qword ptr [rcx+0x10]
    test.go:8       0x4ada0d    764f            jbe 0x4ada5e
    test.go:8       0x4ada0f*   4883ec28        sub rsp, 0x28
    test.go:8       0x4ada13    48896c2420      mov qword ptr [rsp+0x20], rbp
    test.go:8       0x4ada18    488d6c2420      lea rbp, ptr [rsp+0x20]

        // 在main的栈帧中设置newproc的参数siz,16字节
    test.go:9       0x4ada1d    c7042410000000      mov dword ptr [rsp], 0x10
        // 计算printAdd函数对应的funcval结构体的地址放入rax
    test.go:9       0x4ada24    488d057d5e0300      lea rax, ptr [rip+0x35e7d]
        // 在main的栈帧中设置newproc的参数fn
    test.go:9       0x4ada2b    4889442408      mov qword ptr [rsp+0x8], rax
        // printAdd的参数a
    test.go:9       0x4ada30    48c744241003000000  mov qword ptr [rsp+0x10], 0x3
        // printAdd的参数b
    test.go:9       0x4ada39    48c744241807000000  mov qword ptr [rsp+0x18], 0x7
        // 调用 runtime.newproc
=>   test.go:9       0x4ada42    e80902f9ff      call $runtime.newproc

    test.go:10      0x4ada47    48c7042400ca9a3b    mov qword ptr [rsp], 0x3b9aca00
    test.go:10      0x4ada4f    e86c4afaff      call $time.Sleep
    test.go:11      0x4ada54    488b6c2420      mov rbp, qword ptr [rsp+0x20]
    test.go:11      0x4ada59    4883c428        add rsp, 0x28
    test.go:11      0x4ada5d    c3          ret
    test.go:8       0x4ada5e    e88d47fbff      call $runtime.morestack_noctxt
    :1  0x4ada63    eb9b            jmp $main.main

我们来验证一下fn参数:

(dlv) regs
    ......
    Rax = 0x00000000004e38a8    // 存储的是 printAdd 对应的 runtime.funcval 地址。
    ......
(dlv) p *(*runtime.funcval)(0x00000000004e38a8)
runtime.funcval {fn: 4905584}   // 4905584是十进制,转换成十六进制是 0x4ada70。
(dlv) p &printAdd
(*)(0x4ada70)           // 函数指针与上面的 funcval.fn 相符。

此段仅用来分析go关键字的实现。与下面的 main goroutine无直接关联。

main goroutine的创建

以下注释的场景均为初始化时。

runtime·rt0_go 中调用 runtime.newproc 相关代码:

TEXT runtime·rt0_go(SB),NOSPLIT,$0
        ......
        // 调用runtime·newproc创建goroutine,指向函数为runtime·main
    MOVQ    $runtime·mainPC(SB), AX // runtime·mainPC就是runtime·main
    PUSHQ   AX          // newproc的第二个参数fn,也就是goroutine要执行的函数。
    PUSHQ   $0          // newproc的第一个参数siz,表示要传入runtime·main中参数的大小,此处为0。
    // 创建 main goroutine。非main goroutine也是此方法创建。
    CALL    runtime·newproc(SB) 
    POPQ    AX
    POPQ    AX
        ......
DATA    runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL   runtime·mainPC(SB),RODATA,$8

runtime.newproc

func newproc(siz int32, fn *funcval) {
        // 获取fn函数的参数起始地址,可参考上例中的printAdd,sys.PtrSize的值是8。
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)   
        // 获取一个g(m0.g0)
    gp := getg()
        // 调用者的pc,也就是执行完此函数返回调用者时的下一条指令地址,本例中是 POPQ AX
    pc := getcallerpc() 
    systemstack(func() {
        newproc1(fn, argp, siz, gp, pc)
    })
}

runtime.newproc1

func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {
    _g_ := getg()   // 当前g。g0
        ......
    acquirem() // 禁止抢占
    siz := narg
    siz = (siz + 7) &^ 7    // 使siz为8的整数倍。&^为双目运算符,将运算符左边数据相异的保留,相同位清零。
        ......
    _p_ := _g_.m.p.ptr()    // 当前关联的p。allp[0]
    newg := gfget(_p_)  // 获取一个g,下有分析。
    if newg == nil {
        newg = malg(_StackMin)          // 分配一个新g
        casgstatus(newg, _Gidle, _Gdead)    // 更改状态
        allgadd(newg)               // 加入到allgs切片中
    }
    ......
        // 调整newg的栈顶指针
    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), argp, uintptr(narg)) // 将参数从调用newproc的函数栈帧中copy到新的g栈帧中。
                ......
    }

        // newg.sched存储的是调度相关的信息,调度器要将这些信息装载到cpu中才能运行goroutine。
    memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))    // 将newg.sched结构体清零
    newg.sched.sp = sp  // 栈顶
    newg.stktopsp = sp
        // 此处只是暂时借用pc属性存储 runtime.goexit + 1 位置的地址。在gostartcallfn会用到。
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum  // +PCQuantum so that previous instruction is in same function
    newg.sched.g = guintptr(unsafe.Pointer(newg))   // 存储newg指针
    gostartcallfn(&newg.sched, fn)          // 将函数与g关联起来。下有分析。
    ......
    casgstatus(newg, _Gdead, _Grunnable)        // 更改状态
    ......
    runqput(_p_, newg, true)            // 存储到运行队列中。

         // 初始化时不会执行,mainStarted 在 runtime.main 中设置为 true
    if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
        wakep()
    }
    releasem(_g_.m)
}

总结一下初始化时newproc1做的工作:

  • 调用gfget获取newg,如果为nil,调用malg分配一个,然后加入到全局变量allgs中。
  • 从调用newproc的函数栈帧中copy参数到newg栈帧中。
  • 设置newg.sched属性,调用gostartcallfn,将newg和函数关联。
  • 更改状态为_Grunnable,存储到p.runq中(p.runq长度是256,满了会被拿出一些放在sched.runq中)。

概括讲就是:获取g->复制参数->设置调度属性->放入队列等调度。

下面来分析以下gfget、gostartcallfn。

runtime.gfget

整体逻辑为:在p.gFree为空,sched.gFree中不空时,从后者向前者最多转移32个。然后从前者的头部返回一个。如果没有分配栈帧,就分配。

func gfget(_p_ *p) *g {
retry:
        // 如果p.gFree为空,但sched.gFree中不为空,则从其中最多获取32个
    if _p_.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
        lock(&sched.gFree.lock)
        // Move a batch of free Gs to the P.
        for _p_.gFree.n < 32 {
            // Prefer Gs with stacks.
            gp := sched.gFree.stack.pop()
            if gp == nil {
                gp = sched.gFree.noStack.pop()
                if gp == nil {
                    break
                }
            }
            sched.gFree.n--
            _p_.gFree.push(gp)
            _p_.gFree.n++
        }
        unlock(&sched.gFree.lock)
        goto retry
    }
    gp := _p_.gFree.pop()   // 从列表头部获取一个g
    if gp == nil {
        return nil
    }
    _p_.gFree.n--
    if gp.stack.lo == 0 {   // 没有栈就分配栈
        // Stack was deallocated in gfput. Allocate a new one.
        systemstack(func() {
            gp.stack = stackalloc(_FixedStack)
        })
        gp.stackguard0 = gp.stack.lo + _StackGuard
    } else {
        ......
    }
    return gp
}

runtime.gostartcallfn

func gostartcallfn(gobuf *gobuf, fv *funcval) {
    var fn unsafe.Pointer
        // fn是真正指向函数的指针
    if fv != nil {
        fn = unsafe.Pointer(fv.fn)
    } else {
        fn = unsafe.Pointer(funcPC(nilfunc))
    }
    gostartcall(gobuf, fn, unsafe.Pointer(fv))
}

runtime.gostartcall

gostartcall主要做了两件事:

  • 将 fn 伪造成是被 goexit 调用的
  • 将 buf.pc 赋值为真正的函数指针
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   // 为返回地址预留空间
        // buf.pc 存储的是 funcPC(goexit) + sys.PCQuantum 
        // 将其存储到返回地址是为了伪造成 fn 是被 goexit 调用的,在 fn 执行完后返回 goexit执行,做一些清理工作。
    *(*uintptr)(unsafe.Pointer(sp)) = buf.pc
    buf.sp = sp     // 重新赋值
    buf.pc = uintptr(fn)    // 赋值为函数指针
    buf.ctxt = ctxt
}

欢迎关注我们的微信公众号,每天学习Go知识