美文网首页
Go语言goroutine原理

Go语言goroutine原理

作者: 喻家山车神 | 来源:发表于2022-10-26 20:12 被阅读0次

    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<ABIInternal>(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/#mhttps://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/mpg/

    相关文章

      网友评论

          本文标题:Go语言goroutine原理

          本文链接:https://www.haomeiwen.com/subject/yzubxctx.html