Golang goroutine

作者: swapmem | 来源:发表于2018-10-06 20:48 被阅读19次

    goroutine 是 Golang的最大卖点之一,它让并发编程变的十分简单,仅仅使用 go关键字就能快速的创建goroutine。与其他语言设计并发程序相比,这极大的减少了程序员的心智负担。

    goroutine的特点

    • 轻量级

    goroutine是用户态"线程",开销非常小,最新golang版本默认为goroutine分配的初始栈大小为2k,同时会根据运行状况动态扩展或收缩。一个有2G内存的机器,理论上可以容纳一百万 goroutine。

    • 协作式调度

    golang的runtime采用协作式调度,goroutine的运行原则上不能被抢占,除非goroutine主动让出CPU,否则goroutine会运行到结束,所以context switch 开销基本可以忽略。

    • 高效的线程模型

    golang为了充分发挥多核机器的优势,采用了M:N线程模型,即M个内核线程,每个内核线程可以为N个goroutine提供运行环境,最大限度的发挥了多核机器的能力。

    几个关键的数据结构

    • g

    g代表一个goroutine实例,在golang源码src/runtime/runtime2.go 中,可以看到g的详细定义。和普通的线程一样,g主要包含:可伸缩的运行栈,goroutine切换时的上下文环境(gobuf),程序计数器,基地址,可执行代码等。

    type g struct {
            stack      stack   // offset known to runtime/cgo
            sched     gobuf
            goid        int64
            gopc       uintptr // pc of go statement that created this goroutine
            startpc    uintptr // pc of goroutine function
            ... ...
    }
    
    • m

    m代表一个内核线程,是goroutine真正的执行环境。一般会有一个内核线程池,当goroutine因为等待网络数据或者读取文件等阻塞时,goroutine会绑定在这个m上,等到阻塞操作的完成后重新绑定到一个p上继续运行。若暂时找不到可用的p,那么这个goroutine会放到全局的 run queue 中。

    type m struct {
        g0      *g     // goroutine with scheduling stack
        mstartfn      func()
        curg          *g       // current running goroutine
     .... ..
    }
    
    
    • p

    早起版本的golang实现不包含p这一结构,p表示一个逻辑处理器,p的数量一般为机器的CPU核心数,每个p下面挂载有等待被调度的goroutine. 每个 goroutine想要运行需要首先获得p才能被调度。p数量决定了系统的最大并发度。

    type p struct {
        lock mutex
    
        id          int32
        status      uint32 // one of pidle/prunning/...
    
        mcache      *mcache
        racectx     uintptr
    
        // Queue of runnable goroutines. Accessed without lock.
        runqhead uint32
        runqtail uint32
        runq     [256]guintptr
    
        runnext guintptr
    
        // Available G's (status == Gdead)
        gfree    *g
        gfreecnt int32
    
      ... ...
    }
    
    

    g, m, p 的关系入下图所示

    G,M,P关系图(图片来自网络)

    上图左半部分,M1为空闲线程,M0线程下面有一个P和它绑定,P下面有一个正在运行的G0,还有其他等待运行的G。在某个时候,G0中发生了系统调用,P与M0解绑,寻找空闲的线程M1,绑定到上面继续执行P下的其他G,M0与G0陷入系统调用,入上图有半部分所示。

    为何需要抢占式调度

    goroutine里面的代码执行没有确定的时间,如果一个goroutine长期占有p运行,甚至一个死循环,那么p下面的其他g就无法得到调度,这种情况是我们不希望看到的。幸好,系统监控线程 sysmon可以判断这种情况,它可以打断当前goroutine的执行,使P下的其他G得到调度。

    sysmon主要完成如下工作:

    • 释放闲置超过5分钟的span物理内存;
    • 如果超过2分钟没有垃圾回收,强制执行;
    • 将长时间未处理的netpoll结果添加到任务队列;
    • 向长时间运行的G任务发出抢占调度;
    • 收回因syscall长时间阻塞的P;

    因此,我们不应该在goroutine里面设计长时间运行的任务。这种抢占机制在一定程度上保证了同一P下G的公平调度。

    work stealing 算法

    当p下面没有可供调度的goroutine时,他会从global run queue或者其他p下的goroutine中“偷” 一部分goroutine来运行,这样最大限度的利用多核。这在一定程度上保证了在各个CPU核上的负载均衡。

    如何处理阻塞的系统调用

    对于普通的文件IO操作一旦阻塞,那么m就会进入sleep状态,IO完成之后才会被唤醒。这种情况下,p将与m分离,选择其他空闲的m继续执行。如果没有空闲的m,那么就会新创建一个m。可想而知,如果有大量的这样的文件IO操作,大量的m将会被创建出来,这时候操作系统对m的调度开销就不能忽视了。

    针对网络IO,golang使用netpoller做出了特别的优化,这样goroutine里面发起网络IO也不会导致m被阻塞,从而不会引起创建大量的内核线程m。

    goroutine发生调度的时机

    goroutine在获得m时一般不能一直运行到完毕,它们往往可能要等待其他资源才能执行完成,比如说一个http请求收到服务器响应这个goroutine才算完成了他的任务。在等待服务器响应的这一段时间它不会占用CPU时间 ,调度器会调度其他goroutine继续执行。goroutine遇到下面的情况下可能会产生重新调度

    • 阻塞 I/O
    • select操作
    • 阻塞在channel
    • 等待锁
    • 主动调用 runtime.Gosched()

    参考链接

    相关文章

      网友评论

        本文标题:Golang goroutine

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