11 8
Go!并发

Go语言本身是二级线程模型,它刻意模糊线程或协程的概念,通过三种基本对象(M、P、G)互相协作来实现用户空间管理和调度任务。

并发模型

go的并发模型最主要有三部分组成:

  • M: 内核线程,是主要的执行体

    • 被唤醒而进入工作状态的M会陷入调度循环,从各种可能的场所获取并执行G任务,只要当彻底找不到对象或因为任务过长,系统调用阻塞等原因被 剥夺P时,M才会进入休眠状态
  • P: 其作用类似CPU的核,用来控制可同时并发执行的任务数量(i.e. 每个工作线程M必须绑定一个有效的P才能允许执行任务),
    线程独享P,可在无锁状态下执行高效操作。

    • 由于P拥有可运行G和自由G,所以它可以为M提供执行资源
    • 尽管P/M构成执行组合体,但两者数量并非一一对应的,通常情况下,P的数量相对恒定,默认与CPU核数量相同。
  • G:一般指goroutinue

    • 并非执行体,仅保存并发任务状态。
    • 为M(执行体)提供内存空间:runtime在初始化M的时候会分配一个g0(默认8k栈内存的G对象属性)给M,g0也是goroutinue,它包含各种调度垃圾回收栈管理。 g0不是由go程序中的代码间接生成的,而是在运行时系统初始化M的时候创建并分配给M的(作为系统线程的默认堆栈空间)。
    • 除了g0之外,还存在runtime.g0,runtime.g0被执行引导程序,它会被静态分配的。
    • 除了上述的系统G,我们接触最多的是用户G,也就是我们写代码经常接触的goroutinue

go-model

M、P、G 的关系模型如上图

基本关系示意图

mgp

这里出现了一个很关键的东西 -- *调度器*。

调度器 负责两级线程模型的一部分调度任务(一轮调度、全力查找可运行G、启用或停止M、系统检测任务、变更P的最大数量),关于调度器,我们在接下来再去深入学习,我们先看下M

M无状态的,在创建之初会进行一番初始化工作(由G提供栈空间、信号处理方面的东西)然后加入到全局的M列表(runtime.allm),为P而准备。

与全局M列表对应的是空闲M列表, 在一个未被使用的M的时候,runtime会先尝试从该列表获取。

P有状态的, 而且在P的结构中,可运行G队列和自由G队列是最重要的两个成员

  • Pgcstop:gc之后的状态
  • Pidle: 完成可运行G队列的初始化和结束gc后的状态
  • Prunning
  • Psyscall
  • Pdead

G 也是有状态的,runtime在接收到一个go调用后,会先检查一个go函数以及它参数的合法性,然后从本地P的自由G队列和调度器的自由G队列获取可用G,如果真的没有,那就创建一个了。 和MP一样,G在runtim里也有一个全局队列 runtime.allg。新建的G(指针)会在第一时候加入到该队列中去,而且新加入的G无论是新或者旧都会进行一次初始化。

  • IDLE:新建时的状态
  • DEAD: 初始化前
  • RUNABLE:初始化后
  • RUNNING:调度执行
  • DEAD: 执行完毕
  • GFREE

我们上面也了解到调度的引导程序是由runtime.g0去做的,引导程序执行的目的是让main函数的G马上有机会运行。

一轮调度

go sched

这个图最关键的部分是“全力查找可运行的G”,为了增加G的利用率,go语言的找G之旅也是相当讲究的:

  1. 从本地P的可运行队列获取G
  2. 从调度器可运行队列获取G
  3. 从网络I/O轮询器处查找可运行的G
  4. 在条件许可下,从另外一个P的可运行G队列中“偷”一个
  5. 再次尝试从调度器可运行队列获取G
  6. 再次尝试从本地P的可运行队列获取G
  7. 从网络I/O轮询器处查找已经就绪的G

调度器学习

调度器初始化函数是schedinit函数

// The bootstrap sequence is:
//
//    call osinit
//    call schedinit
//    make & queue new G
//    call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
    // raceinit must be the first call to race detector.
    // In particular, it must be done before mallocinit below calls racemapshadow.
    _g_ := getg()
    if raceenabled {
        _g_.racectx = raceinit()
    }

    sched.maxmcount = 10000

    // Cache the framepointer experiment. This affects stack unwinding.
    framepointer_enabled = haveexperiment("framepointer")

    tracebackinit()
    moduledataverify()
    stackinit()
    itabsinit()
    mallocinit()
    mcommoninit(_g_.m)

    msigsave(_g_.m)
    initSigmask = _g_.m.sigmask

    goargs()
    goenvs()
    parsedebugvars()
    gcinit()

    sched.lastpoll = uint64(nanotime())
    procs := int(ncpu)
    if procs > _MaxGomaxprocs {
        procs = _MaxGomaxprocs
    }
    if n := atoi(gogetenv("GOMAXPROCS")); n > 0 {
        if n > _MaxGomaxprocs {
            n = _MaxGomaxprocs
        }
        procs = n
    }
    if procresize(int32(procs)) != nil {
        throw("unknown runnable goroutine during bootstrap")
    }

    if buildVersion == "" {
        // Condition should never trigger. This code just serves
        // to ensure runtime·buildVersion is kept in the resulting binary.
        buildVersion = "unknown"
    }
}

可从代码了解到在初始化后,引导过程才创建并允许main goroutine。不难理解M+P为g0提供了上下文环境,且调度器在初始化阶段,所有的P对象都是新建的。

startTheWorld会激活全部有本地任务的P对象。

编译器会将go func(…) 翻译成 newproc调用, 举个例子

package main

func add(x, y int) int {
    z := x + y
    return z
}

func main() {
    x := 0x100
    y := 0X100
    go add(x, y)
}

执行

$ go build -o test main.go

$ go tool objdump -s "main\.main" test 

结果输出:

main.go:8    0x2060    65488b0c25a0080000    GS MOVQ GS:0x8a0, CX
main.go:8    0x2069    483b6110        CMPQ 0x10(CX), SP
main.go:8    0x206d    7642            JBE 0x20b1
main.go:8    0x206f    4883ec30        SUBQ $0x30, SP
main.go:8    0x2073    48896c2428        MOVQ BP, 0x28(SP)
main.go:8    0x2078    488d6c2428        LEAQ 0x28(SP), BP
main.go:11    0x207d    48c744241000010000    MOVQ $0x100, 0x10(SP)
main.go:11    0x2086    48c744241800010000    MOVQ $0x100, 0x18(SP)
main.go:11    0x208f    c7042418000000        MOVL $0x18, 0(SP)
main.go:11    0x2096    488d05abab0600        LEAQ 0x6abab(IP), AX
main.go:11    0x209d    4889442408        MOVQ AX, 0x8(SP)
main.go:11    0x20a2    e8e99d0200        CALL runtime.newproc(SB)
main.go:12    0x20a7    488b6c2428        MOVQ 0x28(SP), BP
main.go:12    0x20ac    4883c430        ADDQ $0x30, SP
main.go:12    0x20b0    c3            RET
main.go:8    0x20b1    e83a720400        CALL runtime.morestack_noctxt(SB)
main.go:8    0x20b6    eba8            JMP main.main(SB)
...

可见编译器会从右往左入栈: x入栈-> y入栈-> 参数长度入栈-> add地址入栈, 其中 x+y+参数长度 会组成 funcval,可见funcval是变长结构。

func funcval struct {
    fn uintptr
}

从上面的例子我们得出结论:go语句会复制参数值

连续栈

所谓的连续栈就是调用堆栈所有的栈帧,分配在一个连续的内存空间里。当这个内存空间不足的时候,会分配2倍的内存块并 拷贝当前栈全部数据, 这样就可以避免分段栈链表结构在函数调用频繁时可引发的切分热点问题。