Go语言goroutine原理

1. 背景:为什么需要协程?

  最开始的计算机系统并没有什么进程(或线程)的概念。与现在的单片机系统类似,编写特定功能的应用程序,然后上电以后就开始运行。这样做的问题就是CPU的利用率会很低,长期被一个应用程序独占,后来为了对CPU进行分时复用,就慢慢有了操作系统,通过进程(或线程)来实现对CPU的复用。
  随着CPU多核心的发展趋势,进程(或线程)就可以用来提高程序的并发度,利用多核心来提高程序的并发能力。
  多进程(或线程)的程序性能为什么好,主要就是这两个原因,1.可以提高CPU的使用效率,当程序在等待比较慢的设备时让出CPU来干别的事情(比如常见的互联网web服务中的各种网络等待);2.利用CPU的多核性能,实现真正的并行。
  那么为什么在已经有了进程(或线程)来实现并发以后,还需要有协程呢?主要就是因为切换进程(或线程)的代价是非常大的,切换页表,切换内核堆栈,切换硬件的各种寄存器,执行调度等等,另外现代CPU有非常多的各级cache,切换进程(或线程)以后很有可能会导致cache失效,这都是切换的代价。
  基于这些代价,很多天才程序员开始考虑不利用进程(或线程)的切换来实现一些并发能力。比如像NGINX这样的应用程序,可以在一个进程里面处理多个请求,通过IO复用和注册回调的的方式,不阻塞进程,不做切换,一直占用CPU来计算,碰到需要阻塞的时候就先干别的,等到事件准备好了就执行回调函数。这种做法的缺点就是代码看起来不太好理解,不直观,对程序员的要求比较高。有没有办法让程序员正常的写着各种同步的方法,又不用付出多进程(或线程)带来的资源消耗,那么协程就出现了。对程序员来说,代码写的就像用到了多进程(或线程)来实现了并发,但是实际上操作系统并没有做进程(或线程)级别的切换。

2. 如何理解GMP

  很多编程语言现在都实现了协程或者类似的概念,包括C++,Java,Python,Golang,甚至C和PHP也有一些三方的库支持了类似的能力。当然目前Golang在这一方面名气还是最大的,毕竟在诞生之初就在主打这个概念。
  Golang大名鼎鼎的GMP模型就是实现协程的原理。Golang经过很多版本的迭代,确定了现在GMP模型,G表示goroutine,M表示真正的系统线程(每个M都意味着一个系统上真实线程),P(processer)代表的处理器(Golang虚拟出来的处理器概念,表示并发度,每个M想要真正运行都必须绑定P)。
  一句话总结:一个用Golang开发的应用程序可以在操作系统上创建M个线程,但是这M个线程中只有P个能够同时处于运行状态从而被操作系统调度来运行,所以M肯定是大于等于P的,另外M-P个线程处于休眠状态。可以创建无数的goroutine,它们会被通过某种神秘力量分配在M上来执行。

3. 核心源码

  go进程启动代码的入口在runtime/asm_amd64(和具体硬件相关).s文件里面。大部分寄存器操作细节可以不用理解,最核心的流程见中文注释。

TEXT runtime·rt0_go(SB),NOSPLIT,$0

    //省略
    CALL    runtime·args(SB)  // 复制参数
    CALL    runtime·osinit(SB) // 具体OS相关,比如os_darwin.go的 func osinit()
    CALL    runtime·schedinit(SB) // 初始化调度器

    // 创建goroutine,运行的地址是 runtime/proc.go文件里面的 func main()
    MOVQ    $runtime·mainPC(SB), AX
    CALL    runtime·newproc(SB)

    // 启动一个M
    CALL    runtime·mstart(SB)

3.1 P的生命周期

  P的创建是在上面的runtime·schedinit(SB)函数中调用func procresize(nprocs int32)时生成。

func procresize(nprocs int32) *p {
(...省略...)
    // initialize new P's
    for i := old; i < nprocs; i++ {
        pp := allp[i]
        if pp == nil {
            pp = new(p)
        }
        pp.init(i)
        atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
    }
(...省略...)
}

  从调用关系上看,除了在启动时调用外,GC的时候也会调用这个函数。我们可以认为初始化创建后一般不会再新增或减少P。


image.png

3.2 M的生命周期

  M的创建发生在runtime/proc.go文件的newm函数调用时,newm函数主要负责产生M结构体,再调用newm1函数通过newosproc函数来具体产生一个系统的线程。

func newm(fn func(), _p_ *p, id int64) {
    mp := allocm(_p_, fn, id)
    mp.doesPark = (_p_ != nil)
    mp.nextp.set(_p_)
    mp.sigmask = initSigmask

    (...省略...)

    newm1(mp)
}

func newm1(mp *m) {

    (...省略...)

    execLock.rlock() // Prevent process clone.
    newosproc(mp) // 根据具体系统调用产生系统线程
    execLock.runlock()
}

newm函数主要的调用方(可能新建M的场景)


image.png

3.3 G的生命周期

  G的创建由runtime/proc.go文件中的newproc函数产生。在应用开发中如果用户调用了go func(),编译器实际上就调用了此函数。此函数的核心逻辑就是新生成一个G结构体,然后将G放入当前的P的可执行队列中,等待调度器在调度到,然后开始执行。

func newproc(siz int32, fn *funcval) {
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)
    gp := getg()
    pc := getcallerpc()
    systemstack(func() {
        newg := newproc1(fn, argp, siz, gp, pc)

        _p_ := getg().m.p.ptr()
        runqput(_p_, newg, true) // 将新产生的goroutine放在当前P的可执行队列中

        if mainStarted {
            wakep()
        }
    })
}

func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
    _g_ := getg()
(...省略...)
    // 每一个goroutine在创建的时候都已经注册好执行完以后跳转的地址
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum 
    return newg
}

3.4 调度

  runtime/proc.go文件里面的schedule函数执行goroutine调度,每个M(系统线程)就是一直执行schedule函数,然后来驱动每个goroutine的调度运行。

func schedule() {
    _g_ := getg() // 获取当前的goroutine

(...省略...)
    var gp *g
    
    if gp == nil {
        // 从正在运行的goroutine对应的M中绑定的P中找到本地的goroutine
        gp, inheritTime = runqget(_g_.m.p.ptr())
    }
    
    if gp == nil {
        // 全局goroutine或者别的P中找可以运行的goroutine
        gp, inheritTime = findrunnable() // blocks until work is available
    }

(...省略...)
    execute(gp, inheritTime) // 执行被调度出来的goroutine
}

func execute(gp *g, inheritTime bool) {
    _g_ := getg()

    (...省略...)
    gogo(&gp.sched) // 跳转到goroutine的程序地址执行
}

  切换到goroutine具体的程序地址运行,运行完以后会重新跳到之前创建goroutine时注册的goexit地址。然后继续跳转到schedule,进行下一次的调度。找到下一个goroutine并进行执行。

TEXT runtime·gogo(SB), NOSPLIT, $16-8
    MOVQ    buf+0(FP), BX       // gobuf
    MOVQ    gobuf_g(BX), DX
    MOVQ    0(DX), CX       // make sure g != nil
    get_tls(CX)
    MOVQ    DX, g(CX)
    MOVQ    gobuf_sp(BX), SP    // restore SP
    MOVQ    gobuf_ret(BX), AX
    MOVQ    gobuf_ctxt(BX), DX
    MOVQ    gobuf_bp(BX), BP
    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)
    MOVQ    gobuf_pc(BX), BX
    JMP BX

func goexit0(gp *g) {
    _g_ := getg()
(...省略...)
    schedule()
}

4. 几个例子

4.1 G的数量小于P时,M的数量

func main() {
    runtime.GOMAXPROCS(2)

    for i := 0; i < 500; i++ {
        time.Sleep(time.Second)
        fmt.Println(runtime.NumGoroutine())
    }

    fmt.Println("finish")

  G=1,P=2,M为5。


image.png

  G=1,P=4,M为5。


image.png

4.2 G的数量大于P时,M的数量

func main() {
    runtime.GOMAXPROCS(4)

    for i := 0; i < 100000; i++ {
        go process()
    }

    for i := 0; i < 500; i++ {
        time.Sleep(time.Second)
        fmt.Println(runtime.NumGoroutine())
    }

    fmt.Println("finish")
}

  G=100001,P=4,M为7。CPU使用率为400%,说明占用了4个核心。


image.png

4.3 存在大量系统阻塞调用时,M的数量

  G从100101降低为100001,P=4,M为104一直没有变化,说明M在创建以后并不会删除,会一直处于idle状态。CPU使用率为400%,说明占用了4个核心。

func main() {
    runtime.GOMAXPROCS(4)

    for i := 0; i < 100000; i++ {
        go process()
    }

    for i := 0; i < 100; i++ {
        go lockfile()
    }

    for i := 0; i < 500; i++ {
        time.Sleep(time.Second)
        fmt.Println(runtime.NumGoroutine())
    }

    fmt.Println("finish")
}

func process() {
    for {
        time.Sleep(10 * time.Millisecond)
        i := 0
        i++
    }
}

func lockfile() {
    f, err := os.Open("a.txt")
    if err != nil {
        fmt.Println(err)
    }
    //err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
    err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX)
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println("lock success")
    }

    time.Sleep(1 * time.Second)
    syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}

  根据前面的分析M的创建时机是,在有可运行的goroutine时和可用的P资源前提下,如果没有空闲的M,就会创建。在产生系统调用时,会产生很多的M可以理解。
  至于为什么在1个goroutine和4个P时会产生5个M,应该是跟进程占用了一些系统资源文件,发生系统调用时产生,细节后面再深入研究下。

参考资料:
这篇文章的图画的特别好,https://learnku.com/articles/41728
这篇文章的源码分析比较详细,https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/#m,https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/mpg/

你可能感兴趣的:(Go语言goroutine原理)