在Go语言中,我们通过goroutine来实现并发编程,每一个并发执行的单元叫做一个goroutine。可以将goroutine类比为os线程,因为大多数情况下,这两者的表现还是比较类似的。但是相比于os线程依靠系统内核调度,goroutine是Go程序运行时自己进行调度的,而且更加轻量,调度代价更低。

os线程调度的不足

  • os线程相当于是os进程的一个轻量级的抽象。os线程拥有自己的线程id、信号掩码,还能指定运行cpu,拥有自己的线程栈等。但是,这些相对于goroutine来说还是太重了,尤其是线程栈的大小。每一个os线程都有一个固定大小的线程栈(在我机器上默认8M),对于go程序来说,创建成百上千个goroutine是非常普遍的。如果每个goroutine都需要这么大的空间,那么系统内存很快就会被耗尽。而且os线程栈是固定大小的,对于深层次的递归调用来说不够灵活,容易出现“爆栈”的现象。

  • 很难获取系统内核调度os线程的调度计划,这可能造成一些性能问题,比如垃圾回收时可能会需要所有线程进行同步。

  • os线程被系统内核调度时,需要一个硬件定时器来中断处理器,然后挂起当前线程并保存当前寄存器内容,从线程列表中获取下一个调度线程,最后从内存中恢复要调度线程的寄存器内容,并开始执行新线程。这个过程需要内核进行参与调度,代价高。

用户线程模型

一般来说,有三种用户级的线程模型方案:

  • N:1模型

    多个用户级线程对应一个os线程,用户级线程由用户程序自行管理,不需要内核参与。因为上下文的切换都在一个线程中,所以代价很低,但是并不能利用多核优势。

  • 1:1模型

    一个用户级线程对应一个os线程,内核对每个线程进行调度。可以利用多核优势,但是上下文的切换代价高,线程数量过多可能会造成前面提到的性能问题。

  • M:N模型

    多个用户级线程可以对应一个os线程,一个用户级线程也可以对应多个os线程。结合了N:1和1:1模型的优点,上下文切换很快,同时也可以利用多核优势。缺点就是实现较复杂。

Goroutine实现

goroutine调度器分为三个部分:M、P、G:

MPG

  • 三角形M代表os线程,由系统内核进行调度,M代表Machine
  • 圆形G表示一个goroutine,包含了goroutine堆栈信息、指令指针以及状态等
  • 四边形P代表调度中的上下文,也可以称之为处理器(processor),负责运行G。

可以从宏观的角度描绘M、P、G的协同关系:

MPG模型

对于G来说,P相当于运行它的CPU,但是要运行P,只有将P绑定到一个os线程M,系统才能真正的调度运行P。可以说,P是M和G协同调度的纽带,通过P实现了M:N的线程模型。

P的数量通过GOMAXPROCS环境变量或者运行时函数GOMAXPROCS()设置。每一个P都有自己局部的G任务队列,这样的话就避免了P内部G任务调度的条件竞争。同时还存在一个全局的G任务队列,当局部队列消耗完后P会从全局队列获取任务,同时P也会周期性的从全局队列获取G任务,防止全局G任务队列没有消费。

任务抢占调度

os线程是内核通过时间片进行中断调度的,但是P运行在用户程序中,需要自己实现调度算法。如果P一直执行一个G,那么其他的G可能就永远不会被调度运行。为了解决这个问题,Go程序在启动的时候,会专门启动一个名为sysmon的M(os线程),该M直接运行,用于监控和管理Go程序的运行时状态,其中一个任务就是对长时间运行的G进行抢占调度。是否需要调度的依据是G的一个抢占标志位,Go程序会在每个非内联函数的入口加上一小段代码进行判断。调度的源代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

func retake(now int64) uint32 {
    // 省略
    // Preempt G if it's running for too long.
    t := int64(_p_.schedtick)
    if int64(pd.schedtick) != t {
        pd.schedtick = uint32(t)
        pd.schedwhen = now
        continue
    }
    if pd.schedwhen+forcePreemptNS > now {
        continue
    }
    preemptone(_p_)
    // 省略
}
  1. 记录P的任务计数schedtick,P每执行一次G,这个schedtick就会进行递增
  2. 如果P的schedtick没有变化,说明此次和上次的检查P都在执行同一个G,如果执行时间超过了10ms,就为这个G添加一个需要被抢占的标志位
  3. G运行到非内联函数时,判断标志位,停止运行,并把自己插入到任务队列的末尾,等待下一次调度运行
  4. P从任务队列取出新的G运行

需要注意的是,如果G中没有非内联函数调用(纯计算循环,或者小函数被优化为了内联函数),那么是无法被停止的,这个可以通过下面代码进行验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
    runtime.GOMAXPROCS(1)
    ch := make(chan int)
    go func() {
        for {

        }
    }()
    go func() {
        for i := 0; ; i++ {
            fmt.Println(i)
            time.Sleep(time.Second)
        }
    }()
    <-ch
}

把GOMAXPROCS设置为1,程序此时就只有一个P,需要执行两个G:一个什么都不做的死循环,一个每隔1秒输出一个数字。运行就会发现这个程序会直接卡住,说明P运行死循环的G后,因为里面没有非内联函数的调用,即使超过最大运行时间也无法停止,也就不能调度另一个G运行。

任务盗取(stealing work)

当P的局部G任务队列不平衡时,就可能出现一种情况:某些P的局部队列已经没有G任务,但是其他P的局部队列有等待调度的G任务。

这样就可能造成空闲P资源的浪费。为了提高执行效率,保证每个P都能发挥处理能力,goroutine调度器使用任务盗取的方法来补充没有G任务的P的局部队列:

  1. 先从全局G任务队列查找,一次性转移(全局队列G任务数/P个数)个任务
  2. 全局队列没有,则再从其他的P中盗取约一半的G任务

系统调用阻塞

因为P依托于一个os线程M运行,而os线程运行时不可能既运行代码,又同时阻塞在系统调用上,所以当G发生系统调用阻塞时,P就没有办法继续调度。调度器需要把这个G与当前P进行分离,以此恢复P的调度功能。

系统调用阻塞

系统调用阻塞后,调度的流程:

  1. P运行G0任务时发生了系统调用阻塞(上图左侧)
  2. 将P从当前的M0上分离,M0接管被系统调用阻塞的G0任务,然后创建或者从线程池缓存中取得一个新的M1,将P绑定到M1上,继续执行其他的G任务(上图右侧)
  3. 当系统调用返回时,需要继续执行G0,但是因为此时M0没有P,所以需要从其他的M上窃取一个P来执行G0。如果窃取P失败,就会把G0插入全局G任务队列,然后把自己休眠并放入线程池缓存中。

这个调度流程也很好的解释了,即使把GOMAXPROCS设置为1,Go程序也是运行在多线程上的。

网络I/O阻塞

对于阻塞于网络I/O的G的调度方式和阻塞于系统调用有很大区别。一般,每次接受一个连接,我们都会启动一个goroutine去处理该连接。但是网络I/O和一般的系统调用不同,可能很久都不会有数据传输。如果为每一个阻塞于网络I/O的G指定一个M接管,当连接数增多后,每一个阻塞连接G都对应一个os线程M,会造成很大的性能问题,这肯定是不合理的。

所以,网络I/O的底层文件描述符是设置为非阻塞的,没有数据读取则会立刻返回。但是,Go在语言层面提供的I/O操作都是阻塞的。在系统层面,Go会把该I/O对应的文件描述符设置为非阻塞,但是在语言层面,当该文件描述符还不能被操作时会阻塞该goroutine。这样的抽象,对于程序员来讲,I/O操作是表现为阻塞的,即使底层的文件描述符为非阻塞。这也让程序员更好的用同步的方式写出异步代码,而不用面对回调和promise-future。

对于这些非阻塞的文件描述符,Go使用一个称为netpoller的机制来进行调度管理。netpoller存在于一个独立的线程中,接收期望进行网络I/O的文件描述符,然后使用epoll/kqueue/IoCompletionPort(视平台而定)这些系统提供的I/O事件机制来获取可以进行读写的连接。具体的调度流程如下:

  1. 无论何时,在Go程序中打开或者接受一个连接,其对应的文件描述符都会设置为非阻塞模式
  2. 当一个G要对这个连接进行I/O操作时,如果该连接还没有准备好进行I/O,读写函数就会立即返回一个EAGAIN,表示I/O未就绪。这时Go程序就会把这个文件描述符加入到netpoller中,并将自己阻塞后移出调度队列。
  3. 一旦netpoller监控到该文件描述符已经准备好进行I/O操作,就会检查是否有G阻塞在这个文件描述符上。如果有,则通知G该I/O操作已经准备好,调度器会重新调度G,此时G再进行I/O操作,就可以成功返回。

参考