Go线程与调度模型
目前Go运行时的实现默认并不会并行执行代码,它只为用户层代码提供单一的处理核心。任意数量的协程都可能在系统调用中被阻塞,而在任意时刻默认只有一个会执行用户层代码。若你希望CPU并行执行,就必须告诉运行时你希望同时有多少协程能执行代码。有两种途径可意识形态,要么在运行你的工作时将GOMAXPROCS环境变量设为你要使用的核心数,要么导入runtime包并调用runtime.GOMAXPROCS(NCPU)。runtime.NumCPU()的值可能很有用,它会返回当前机器的逻辑CPU核心数。当然,随着调度算法和运行时的改进,将来会不再需要这种方法。
系统栈
每个系统级线程都会有一个固定大小的栈(一般默认可能是2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。
Go的栈是动态分配大小的,随着存储数据的数量而增长和收缩。每个新建的Goroutine只有大约4KB的栈。每个栈只有4KB,那么在一个1GB的RAM上,我们就可以有256万个Goroutine了,相对于Java中每个线程的1MB,这是巨大的提升。Golang实现了自己的调度器,允许众多的Goroutines运行在相同的OS线程上。就算Go会运行与内核相同的上下文切换,但是它能够避免切换至ring-0以运行内核,然后再切换回来,这样就会节省大量的时间。
调度模型
在Go中存在两级调度:
- 一级是操作系统的调度系统,该调度系统调度逻辑处理器占用CPU时间片运行;
- 一级是Go的运行时调度系统,该调度系统调度某个Goroutine在逻辑处理上运行。
使用Go语句创建一个Goroutine后,创建的Goroutine会被放入Go运行时调度器的全局运行队列中,然后Go运行时调度器会把全局队列中的Goroutine分配给不同的逻辑处理器(P),分配的Goroutine会被放到逻辑处理器(P)的本地队列中,当本地队列中某个Goroutine就绪后待分配到时间片后就可以在逻辑处理器上运行了。
从G-M到G-P-M
在Go 1.0发布的时候,它的调度器其实G-M模型,也就是没有P的,调度过程全由G和M完成,这个模型暴露出一些问题:
- 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作,比如:创建、重新调度等都要上锁;
- goroutine传递问题:M经常在M之间传递可运行的goroutine,这导致调度延迟增大以及额外的性能损耗;
- 每个M做内存缓存,导致内存占用过高,数据局部性较差;
- 由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗。
这些问题导致Go1.0虽然号称原生支持并发,却在并发性能上一直饱受诟病,然后,重新设计和实现了Go调度器(在原有的G-M模型中引入了P)并且实现了一个叫做work-stealing的调度算法:
- 每个P维护一个G的本地队列;
- 当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中;
- 当一个G在M里执行结束后,P会从队列中把该G取出;如果此时P的队列为空,即没有其他G可以执行,M就随机选择另外一个P,从其可执行的G队列中取走一半。
G-P-M调度模型
Go线程模型属于多对多线程模型,在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。Go中使用使用Go语句创建的Goroutine可以认为是轻量级的用户线程,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循环;M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。
在Go中每个逻辑处理器(P)会绑定到某一个内核线程上,每个逻辑处理器(P)内有一个本地队列,用来存放Go运行时分配的goroutine。多对多线程模型中是操作系统调度线程在物理CPU上运行,在Go中则是Go的运行时调度Goroutine在逻辑处理器(P)上运行。
调度过程
需要注意的是为了避免某些Goroutine出现饥饿现象,被分配到某一个逻辑处理器(P)上的多个Goroutine是分时在该逻辑处理器运行的,而不是独占运行直到结束,比如每个Goroutine从开始到运行结束需要10分钟,那么当前逻辑处理器下的goroutine1,goroutine2,goroutine3,并不是顺序执行,而是交叉并发运行的。
Goroutine内部实现与在多个操作系统线程(OS线程)之间复用的协程(coroutines)一样。如果一个Goroutine阻塞OS线程,例如等待输入,则该OS线程对应的逻辑处理器P中的其他Goroutine将迁移到其他OS线程,以便它们可以继续运行:
如上图左侧假设goroutine1在执行文件文件读取操作,则goroutine1会导致内核线程1阻塞,这时候Go运行时调度器会把goroutine1所在的逻辑处理器1迁移到其他的内核线程上(这里是内核线程2上),这时候逻辑处理器1上的goroutine2和goroutine3就不会受goroutine1的影响了。等goroutine1文件读取操作完成后goroutine1又会被Go运行时调度系统重新放入到逻辑处理器1的本地队列。
默认情况下,Go默认是给每个可用的物理处理器都分配一个逻辑处理器(P),如果你需要修改逻辑处理器(P)个数可以使用runtime包的runtime.GOMAXPROCS函数设置。至于goroutine(G)的数量则是由用户程序自己来确定,理论只要内存够大,可以无限制创建。
M(内核线程)
P(执行一个Go代码片段所必需的资源)
G(Go代码片段)
Links