Go的并发模型

Go实现了两种并发形式,第一种多线程共享内存(跟java或者c++多线程开发类似),另外一种是Go特有特殊的两级线程模型(CSPcommunicating sequential process)并发模型。
CSP并发模型是在1970年左右提出的概念,属于比较新的概念,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。
普通的线程并发模型,就是像Java、C++、或者Python,他们线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此,在很多时候,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。例如Java提供的包”java.util.concurrent”中的数据结构。Go中也实现了传统的线程并发模型。
Go的CSP并发模型,是通过goroutinechannel来实现的。
  • goroutine 是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。
  • channel是Go语言中各个并发结构体(goroutine)之前的通信机制。 通俗的讲,就是各个goroutine之间通信的”管道“,有点类似于Linux中的管道。
GO并发模型的实现原理
无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。
而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;
内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,
用户空间就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。
我们现在的计算机语言,可以狭义的认为是一种“软件”,它们中所谓的“线程”,往往是用户态的线程,和操作系统本身内核态的线程(简称KSE),还是有区别的。
用户级线程模型
内核空间的线程KSE ,一个KSE对应一个用户级进程模型A,进程模型A里面有可能有多个线程A1,A2,A3。KSE和平常所说的线程是1:M的关系,即一对多的关系。
内核级线程模型
这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。一个用户态的线程对应一个系统线程,它可以利用多核机制,但上下文切换需要消耗额外的资源。C++就是这种。
两级线程模型,这种模型是介于用户级线程模型和内核级线程模型之间的一种线程模型。
特征:
1 一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应;
2 这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程
3 自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。M个用户线程对应N个系统线程,缺点增加了调度器的实现难度
Go语言的线程模型就是一种特殊的两级线程模型(GPM调度模型),为了完成任务调度,GO Scheduler 使用3个主要实体M、P、G:
    • M指Machine,一个M直接关联了一个KSE线程。它是由OS管理的执行线程,其工作方式与标准的POSIX线程非常相似。相当于内核线程在 Go 进程中的映射,它与内核线程一一对应,代表真正执行计算的资源。在 M 的生命周期内,它只会与一个内核线程关联。
    • P(Processor/逻辑处理器), 对G来说,P逻辑处理器相当于CPU核,G只有绑定到P逻辑处理器才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。
    • //todo 查询新版本GOMAXPROCS变量和GOMAXPROCS()方法
    • G指的是Goroutine,代表一个goroutine。它包括堆栈,指令指针和其他对调度goroutine很重要的信息,就像它可能被阻塞的任何通道一样。其实本质上也是一种轻量级的线程。
三者关系如下图:
说明:
1 一个M会对应一个内核线程,一个M也会连接一个上下文P,
2 一个上下文P相当于一个“处理器”,一个上下文P连接一个或者多个Goroutine。
3 为了运行goroutine,线程必须保存上下文
以上是两个线程(内核线程)的情况:正在执行的Goroutine为蓝色,处于待执行状态的Goroutine为灰色,灰色的Goroutine形成了一个队列runqueues。
Go调度器模型
  • 每个创建的G(Goroutine)会放到Go调度器的全局运行队列(runqueues),调度器将队列中的goroutine分配到一个P逻辑处理器,并放入逻辑处理器(P)本地的队列中,等待被执行;
  • 每个逻辑处理器P需要绑定到M,M会启动一个操作系统线程;
  • 粗糙地说调度就是决定何时哪个goroutine获得执行资源、哪个goroutine应该停止执行让出资源、哪个goroutine应该被唤醒恢复执行等;
下面是一张Go语言的线程实现模型,
当遇到内核线程阻塞的时候,拥有上下文就可以直接放开其他线程,如果没有上下文,就无法拥抱其他线程了。
举个例子,一个线程不能同时执行代码和系统调用被阻塞,这时,此线程M需要放弃当前的上下文环境P,以便可以让其他的Goroutine被调度执行。
上图左图所示,M0中的G0执行了syscall(系统调用),然后就创建了一个M1,然后M0丢弃了P,等待syscall的返回值,M1接收了P,将继续执行Goroutine队列中的其他Goroutine。
当系统调用syscall结束后,M0会“偷”一个上下文,如果不成功,M0就把它的Gouroutine G0放到一个全局的runqueue中,将自己置于线程缓存中并进入休眠状态。
全局runqueue是各个P在运行完自己的本地的Goroutine runqueues后用来拉取新goroutine的地方。P也会周期性的检查这个全局runqueues上的goroutine,否则,全局runqueue上的goroutines可能得不到执行而饿死。
M 代表系统线程,P代表用户空间的逻辑处理器 G代表协程
  • M在绑定有效的P逻辑处理器后,进入调度循环;调度的机制大致是从全局队列、逻辑处理器P的本地队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个
  • P(Processor/逻辑处理器), 对G来说,P逻辑处理器相当于CPU核,G只有绑定到P逻辑处理器才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256
  • G(Goroutine) ,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。
系统调用阻塞
当正在运行的goroutine需要执行一个阻塞的系统调用,如打开/读写文件;线程和goroutine会从逻辑处理器P上分离,该线程会继续阻塞,等待系统调用返回;
此时,逻辑处理器失去了用来运行的线程,调度器(runtime)会创建一个新的线程,并将其绑定到这个逻辑处理器上;
之后,逻辑处理器P会从本地队列选取另一个goroutine来运行。一旦阻塞的系统调用执行完成并返回,对应的goroutine会放回本地运行队列,线程会保存好,以便之后继续使用。
goroutine是轻量级
栈是用来存储当前正在运行或挂起的函数的空间,操作系统会为每个线程分配固定大小(一般是2MB)的内存块做栈。2MB的固定空间,对于小小的goroutine是很大的浪费,对于复杂的任务来说又明显不够用。
  • 动态栈
goroutine的栈不是固定的,一开始以一个很小的栈空间(2KB)开启生命周期,栈的大小会根据需要动态伸缩。和操作系统线程的栈有同样作用,会保存当前正在运行或挂起的函数的本地变量。
  • 调度器的切换成本
线程会被操作系统内核调度到处理器上运行,每几毫秒会发生一次硬件计时器中断,当前线程需要让出CPU并将线程状态保存到寄存器中,CPU继续处理其他线程任务,在此线程下一次获得CPU执行时间,会从寄存器中恢复该线程上次的的状态并继续执行。线程在内核切换上下文是很慢的。
Go调度器是在其本身运行的用户层进行调度的,不需要进入内核的上下文切换,调度成本会低很多。
总结:
A GO的并发模型有两种1多线程,2 MPG模型
B M关联了KSE内核线程,P为逻辑处理器,G为goroutine
C MPG关系: M关联了一个内核线程,通过调度器的调度,可以连接1个或者多个G,相当于把一个内核线程切分成了了N个用户线程。 M和P是一对一的关系,通过P调度N个G(P和G是一对多关系),实现内核线程和G的多对多关系(M:N),通过这个方式,一个内核线程可以起N个Goroutine,同样硬件配置的机器可用的用户线程成几何级增长,并发性大幅提高
D 发生阻塞之后,线程和goroutine会从逻辑处理器P上分离,该线程会继续阻塞,等待系统调用返回。而P被绑定到新的M上,从而会从本地队列选取另一个goroutine来运行。而等阻塞完成后,对应的goroutine会放回本地运行队列,线程会保存好,以便之后继续使用。
E 每个P都有本地队列(LRQ local runnable queue),叫runqueue,如果自己的G都执行结束了,代表LRQ为空。此时P会从别的P窃一些G来运行,一次取其一半的量。这被称为work-stealing。(额读书人的事能叫偷么,只能称之为窃)
还有个概念叫GRQ 全局可运行队列,GRQ 存储全局的可运行 goroutine,这些 goroutine 还没有分配到具体的 P。
参考资料: