在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:
- 三角形M代表os线程,由系统内核进行调度,M代表Machine
- 圆形G表示一个goroutine,包含了goroutine堆栈信息、指令指针以及状态等
- 四边形P代表调度中的上下文,也可以称之为处理器(processor),负责运行G。
可以从宏观的角度描绘M、P、G的协同关系:
对于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程序会在每个非内联函数的入口加上一小段代码进行判断。调度的源代码如下:
|
|
- 记录P的任务计数schedtick,P每执行一次G,这个schedtick就会进行递增
- 如果P的schedtick没有变化,说明此次和上次的检查P都在执行同一个G,如果执行时间超过了10ms,就为这个G添加一个需要被抢占的标志位
- G运行到非内联函数时,判断标志位,停止运行,并把自己插入到任务队列的末尾,等待下一次调度运行
- P从任务队列取出新的G运行
需要注意的是,如果G中没有非内联函数调用(纯计算循环,或者小函数被优化为了内联函数),那么是无法被停止的,这个可以通过下面代码进行验证:
|
|
把GOMAXPROCS设置为1,程序此时就只有一个P,需要执行两个G:一个什么都不做的死循环,一个每隔1秒输出一个数字。运行就会发现这个程序会直接卡住,说明P运行死循环的G后,因为里面没有非内联函数的调用,即使超过最大运行时间也无法停止,也就不能调度另一个G运行。
任务盗取(stealing work)
当P的局部G任务队列不平衡时,就可能出现一种情况:某些P的局部队列已经没有G任务,但是其他P的局部队列有等待调度的G任务。
这样就可能造成空闲P资源的浪费。为了提高执行效率,保证每个P都能发挥处理能力,goroutine调度器使用任务盗取的方法来补充没有G任务的P的局部队列:
- 先从全局G任务队列查找,一次性转移(全局队列G任务数/P个数)个任务
- 全局队列没有,则再从其他的P中盗取约一半的G任务
系统调用阻塞
因为P依托于一个os线程M运行,而os线程运行时不可能既运行代码,又同时阻塞在系统调用上,所以当G发生系统调用阻塞时,P就没有办法继续调度。调度器需要把这个G与当前P进行分离,以此恢复P的调度功能。
系统调用阻塞后,调度的流程:
- P运行G0任务时发生了系统调用阻塞(上图左侧)
- 将P从当前的M0上分离,M0接管被系统调用阻塞的G0任务,然后创建或者从线程池缓存中取得一个新的M1,将P绑定到M1上,继续执行其他的G任务(上图右侧)
- 当系统调用返回时,需要继续执行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事件机制来获取可以进行读写的连接。具体的调度流程如下:
- 无论何时,在Go程序中打开或者接受一个连接,其对应的文件描述符都会设置为非阻塞模式
- 当一个G要对这个连接进行I/O操作时,如果该连接还没有准备好进行I/O,读写函数就会立即返回一个EAGAIN,表示I/O未就绪。这时Go程序就会把这个文件描述符加入到netpoller中,并将自己阻塞后移出调度队列。
- 一旦netpoller监控到该文件描述符已经准备好进行I/O操作,就会检查是否有G阻塞在这个文件描述符上。如果有,则通知G该I/O操作已经准备好,调度器会重新调度G,此时G再进行I/O操作,就可以成功返回。