学习golang,必然绕不开并行与并发这样的概念,作为golang的一大卖点,轻量级的协程应该是我们要理解的,包括他的调度。
而且,虽然golang的goroutine轻量级,方便大量创建,但是在高度并发的情况下,也会出现一些问题。
Goroutine & Scheduler
首先,通常我们会把golang的goroutine简单的理解为coroutine(协程),但是实际上两者是有差别的,现在主流的线程模型分为3种:内核级线程模型,用户级线程模型,和两级线程模型(也称为混合线程模型)。
传统的协程属于用户级线程模型,而goroutine和他的go scheduler在底层实现上实际上属于两级线程模型。
所以两者实际是有区别的。
线程那些事儿
互联网时代以降,由于在线用户数量的爆炸,单台服务器处理的连接也水涨船高,迫使编程模式由从前的串行模式升级到并发模型,而几十年来,并发模型也是一代代地升级,有IO多路复用、多进程以及多线程,这几种模型都各有长短,现代复杂的高并发架构大多是几种模型协同使用,不同场景应用不同模型,扬长避短,发挥服务器的最大性能,而多线程,因为其轻量和易用,成为并发编程中使用频率最高的并发模型,而后衍生的协程等其他子产品,也都基于它,而我们今天要分析的 goroutine 也是基于线程,因此,我们先来聊聊线程的三大模型:
3种模型分别为:
内核级线程模型,用户级线程模型和两级线程模型,他们之间最大的区别就在于用户线程与内核调度实体(kernel Schedule Entry)的对应关系上,而所谓的KSE其实就是指可以被操作系统内核调度器调度的对象实体,其实就是内核级线程,是操作系统内核的最小调度单元,也就是我们写代码时常说的线程。
Kernel Schedule Entry
首先,说一下,什么是KSE?
linux内核中实际没有线程的概念,linux中的线程,实际是一种轻量级的进程,而进程与线程都是KSE。
用户级线程
用户级线程中,用户线程与KSE是N对1的模式,也就是说,所有的用户线程是在一个进程中与一个KSE动态绑定的,而调度则都是通过用户自己的调度器实现,比如说:python的gevent协程库就是通过这种方式来实现的。
而这种实现有什么好处呢?
所有的行为都在用户层面解决,CPU对于整个过程是无感的,避免了用户态与内核态来回切换导致的性能消耗。
而他的缺点也很明显:
其实这种方式没法做到真正意义上的并发:因为用户的自调度不存在cpu时钟中断和轮转调度,所以,如果一旦一个用户线程得到了阻塞调用,所有的用户线程都会被阻塞。
所以许多这样实现的协程库将阻塞的行为封装成非阻塞的行为从而避免这样的事情。
内核级线程
内核级线程中,用户线程与KSE是一一对应的。而且用户线程的调度是交给cpu调度的。
优势是实现简单,直接借助操作系统内核的线程以及调度器,所以CPU可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。
两级线程模型
两级线程模型中,用户线程与KSE是N对M的关系。
两级线程模型是根据前两个模型综合而来,既不是完全依赖用户自调度,也不是完全依赖cpu调度。
所有的用户线程都会动态绑定到一个KSE上,然后一旦这个KSE因为阻塞被移出cpu时,用户线程可以继续与其他的KSE绑定。
所以,两级线程模型的调度是一种中间态的调度。即:用户调度器实现从用户线程到KSE的调度,内核调度器实现从KSE到CPU的调度。
G-P-M模型
一般的,对于一个OS线程来说,都会有一个固定大小的内存块(2MB)来做栈储存上下文信息,2MB的大小就显得十分不灵活,对于简单的任务,2MB太浪费资源,但对于复杂的任务,2MB又太小了,于是goroutine自己实现了自己的线程。
每个goroutine都有自己的栈,并且采取了动态扩容的方法,初始仅分配2kb的大小,并且不断的动态扩容,同时也会有GC来对栈的内存进行收缩。
任何用户线程最终肯定都是要交由OS线程来执行的,goroutine(称为G)也不例外,但是G并不直接绑定OS线程运行,而是由Goroutine Scheduler中的 P - Logical Processor (逻辑处理器)来作为两者的『中介』,P可以看作是一个抽象的资源或者一个上下文,一个P绑定一个OS线程,在golang的实现里把OS线程抽象成一个数据结构:M,G实际上是由M通过P来进行调度运行的,但是在G的层面来看,P提供了G运行所需的一切资源和环境,因此在G看来P就是运行它的 “CPU”,由 G、P、M 这三种由Go抽象出来的实现,最终形成了Go调度器的基本结构:
- G: 表示Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。
- P: Processor,表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。
- M: Machine,OS线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。
G-P-M模型调度
Go调度器工作时会维护两种用来保存G的任务队列:一种是一个Global任务队列,一种是每个P维护的Local任务队列。
当通过go
关键字创建一个新的goroutine的时候,它会优先被放入P的本地队列。为了运行goroutine,M需要持有(绑定)一个P,接着M会启动一个OS线程,循环从P的本地队列里取出一个goroutine并执行。当然还有上文提及的 work-stealing
调度算法:当M执行完了当前P的Local队列里的所有G后,P也不会就这么在那躺尸啥都不干,它会先尝试从Global队列寻找G来执行,如果Global队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。
有疑问加站长微信联系(非本文作者)