Golang goroutine

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的调度是随机的,没有固定的顺序,即使设置 runtime.GOMAXPROCS(1)。看一个实例程序。

package main

import (
    "fmt"
    "time"
    "runtime"
)

func foo(n int) {
    fmt.Println(n)
}

func main() {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 10000; i++ {
        go foo(i)
    }
    
    time.Sleep(2 * time.Second)
}

上述代码不会从 0 开始顺序打印到 10000。即使设置 runtime.GOMAXPROCS(1),我们也看到 goroutine的调度是随机的。

goroutine发生调度的时机

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

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

参考链接

推荐阅读更多精彩内容

  • 并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题;Go语言作为一个出道以来就自带...
    驻马听雪阅读 2,175评论 3 27
  • 1. C/C++ 与 Go语言的“价值观”对照 C的价值观摘录 相信程序员:提供指针和指针运算,让C程序员天马行空...
    ywhu阅读 5,567评论 0 13
  • 理解并发和并行并发:同时管理多件事情。并行:同时做多件事情。表示同时发生了多件事情,通过时间片切换,哪怕只有单一的...
    Chuck_Hu阅读 5,040评论 5 44
  • 概要 本文从几个角度入手,描述和学习调度器原理 讲解调度器的基本概念 go语言的作者实现的C的协程库 libtas...
    zengfan阅读 4,927评论 0 21
  • 商务会议,当天中午到,下午会议。会后会餐。后一个人去古城,拍夜景。
    行摄在路上阅读 100评论 0 0