Goroutine并发模型
# 一、Go语言的并发模型
Go
语言相比Java
等一个很大的优势就是可以方便地编写并发程序.Go
语言内置了goroutine
机制,使用goroutine
可以快速地开发并发程序,更好的利用多核处理器资源.
# 二、线程模型
在现代操作系统中,线程是处理器调用和分配的基本单位,进程则作为资源拥有的基本单位.每个进程是由私有的虚拟地址空间、代码、数据和其他各种系统资源组成.线程是进程内部的一个执行单元.每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的.用户根据需要在应用程序中创建其他线程,多个线程并发地运行于同一个进程中.
**线程:**无论语言层面到何种并发模型,到了操作系统层面,一定是以线程的形态存在的.而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU
资源、I/O
资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间上就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过"系统调用"、"库函数"或"Shell脚本"来调用内核空间提供的资源.
现在的计算机语言,可以狭义的认为是一种"软件",它们所谓的"线程",往往是用户态的线程,和操作系统本身内核态的线程(简称KSE),还是有区别的.
Go
并发编程模型在底层是由操作系统提供的线程库支撑的,因此还是得从线程实现模型说起.
线程可以视为进程中的控制流.一个进程至少会包含一个线程,因为其中至少会有一个控制流持续运行.因而,一个进程的第一个线程会随着这个进程的启动而创建,这个线程称为该进程的主线程.当然,一个进程也可以包含多个线程.这些线程都是由当前进程中已存在的线程创建出来的创建的方法就是调用系统调用,更确切的说是调用pthread create
函数.拥有多个线程的进程可以并发执行多个任务,并且即使某个或某些任务被阻塞,也不会影响其他任务正常执行,这可以大大改善程序的响应时效件和吞吐量.另一方面,线程可能独立于进程存在.它的生命周期不可能逾越其所属进程的生命周期.
线程的实现模型主要有3个,分别是: 用户级线程模型、内核级线程模型和两级线程模型.它们之间最大的差异就在于线程与内核调度实体(Kernel Scheduling Entity,简称 KSE
)之间的对应关系上.顾名思义,内核调度实体就是可以被内核的调度器调度的对象.在很多文献和书中,它也称为内核级线程,是操作系统内核的最小调度单元.
# 内核级线程模型
用户线程与KSE
是一对一关系(1:1
).大部分线程语言的线程库(如linux
的pthread
,Java
的java.lang.Thread
,C++
的std:thread
等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个不同的KSE
静态关联,因此其调度完全由OS
调度器来做.这种方式实现简单,直接借助OS
提供的线程能力,并且不同哟用户线程之间一般也不会相互影响.但其创建,销毁以及多个线程之间的上下文件切换等操作都是直接由OS
层面亲自来做,在需要使用大量线程的场景下对OS
的性能影响会很大.
每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程.
优点: 在多核处理器的硬件支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强.
缺点: 每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能.
# 用户级线程模型
用户线程与KSE
是多对1关系(M:1
),这种线程的创建,销毁以及多个线程之间的协调等操作都是由用户自己实现的线程库来负责,对OS
内核透明,一个进程中所有创建的线程都与同一个KSE
在运行时动态关联.现在有许多语言实现的协程
基本上都属于这种方式.这种实现方式相比内核级别线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的数量与上下文切换所花费的代价也会小得多.但该模型会有个致命的缺点,如果在某个用户线程上调用阻塞式系统调用(如果用阻塞方式read
网络IO
),那么一旦KSE
因阻塞被内核调度出CPU
的话,剩下的所有对应用的用户线程全都会变为阻塞状态(整个进程挂起).所以这些语言的携程库
会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在KSE
上运行,从而避免了内核调度器由于KSE
阻塞而做上下文切换,这样整个进程也不会被阻塞了.
优点: 这种模型的好处是线程上下文件切换都发生在用户空间,避免的模态切换(mode switch
),从而对于性能有积极的影响.
缺点: 所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,在多处理器环境下这是不能被接受的,本质上,用户线程只解决了并发问题,但是没有解决并行问题.如果线程因为I/O
操作陷入了内核态,内核态阻塞等待I/O
数据,则所有的线程都会被阻塞,用户空间有额可以使用非阻塞而I/O
,但是不能避免性能及复杂度问题.
# 两极线程模型
用户线程与KSE
是多对多关系(M:N
),这种实现综合了前两种模型的优点,为一个进程中创建多个KSE
,并且线程可以与不同的KSE
在运行时进行动态关联,当某个KSE
由于其上工作的线程的阻塞操作被内核调度出CPU
时,当前与其关联的其余用户线程可以重新与其他KSE
建立关联关系.当然这种动态关联机制的实现很复杂,也需要用户自己去实现,这算是它的一个缺点.Go
语言中的并发就是使用的这种方式,Go
为了实现该模型自己实现了一个运行调度器来负责Go
中的线程
与KSE
的动态关联.此模型有时也别称为混合线程模型,即用户调度器实现用户线程到KSE的"调度",内核调度器实现KSE到CPU上的调度器.
# 二、Go
并发调度G-P-M
模型
在操作系统提供的内核线程之上,Go
搭建了一个特有的两级线程模型.goroutine
机制实现了M:N
的线程模型,goroutine
机制是协程(coroutine
)的一种实现·golang
内置的调度器,可以让多核CPU
中每个CPU
执行一个协程.
# 调度器是如何工作的
- 新建一个"线程"(
Go
语言中称为Goroutine
)
// 用go关键字加上一个函数(这里用了匿名函数)
// 调用就做到了在一个新的"线程"并发执行任务
go func() {
// do something in one new goroutine
}()
2
3
4
5
- 功能上等价于
Java8
的代码
new java.lang.Thread(() -> {
//do something in one new thread
}).tart();
2
3
理解goroutine
机制的原理,关键是理解Go
语言scheduler
的实现
Go
语言中支撑整个scheduler
实现的 主要有4个虫咬结构,分别是M、G、P、Sched
,前三个定义在runtime.h
中,Sched
定义在proc.c
中
Sched
结构是调度器,它维护有存储M
和G
的队列以及调度器的一些状态信息等.M
结构是Machine
,系统线程,它由操作系统管理的,goroutine
就是跑在M
之上的;M
是一个很大的结构,里面维护小对象内存cache(mcache)
、当前执行的goroutine
、随机数发生器等等非常多的信息.P
结构是Processor
,处理器,它的主要用途就是用来执行goroutine
的,它维护了一个goroutine
队列,即runqueue
.Processor
是从N:l
调度到M:N
调度的重要部分.G
是goroutine
实现的核心结构,它包含了栈,指令指针,以及其他对掉地goroutine
很重要的信息,例如其阻塞的channel
.
Processor
的数量是在启动时被设置为环境变量GOMAXPROCS
的值,或者通过运行时调用函数GOMAXPROCS()
进行设置.Processor
数量固定意味着任意时刻只有GOMAXPROCS
这个线程在运行go
代码.
分别用三角形、矩形和圆形表示Machine、Processor
和Goroutine
在单核处理器的场景下,所有goroutine
运行在同一个M
系统线程中,每一个M
系统线程维护一个Processor
,任何时刻,一个Processor
中只有一个goroutine
,其他gorutine
在runqueue
中等待.一个goroutine
运行自己的时间片后,让出上下文,回到runqueue
中.多核处理器的场景下,为了运行goroutines
每个M
系统线程会持有一个Processor
正常情况下,scheduler
会按照上面的流程进行调度,但是线程会发生阻塞等情况,看一下goroutine
对线程阻塞等的处理.
# 线程阻塞
当正在运行的goroutine
阻塞的时候,例如进行系统调用,会再创建一个系统线程(M1
),当前的M
线程放弃了它的Processor
,P
转到新的线程中去运行.
# runqueue
执行完成
当其中一个Processor
的runqueue
为空,没有goroutine
可以调度.它会从另外一个上下文偷取一半的goroutine
如图中的
G
,P
和M
都是Go
语言运行时系统(其中包括内存分配器,并发调度器,垃圾收集器等组件,可以想象为Java
中的JVM
)抽象出来概念和数据结构对象:G: Goroutine
的简称,上面用go
关键字加函数调用的代码就是创建了一个G
对象,是对一个要并发执行的任务的封装,也可以称作用户态线程.属于用户级资源,对OS
透明,具备轻量级,可以大量创建,上下文件切换成本低等特点.M: Machine
的简称,在linux
平台上帝clone
系统调用创建的,其与用linux pthread
库创建出来的线程本质上是一样的,都是利用系统调用创建出来的OS
线程实体.M
的作用就是执行G
中包装的并发任务.Go
l运行时系统中的调度器的主要职责就是将G
公平合理的安排到多个M
上去执行.其属于OS
资源,可创建的数量也受限了OS
,通常情况下G
的数量都多余活跃的M
的.P: Processor
的简称,逻辑处理器,主要作用是管理G
对象(每个P
都有一个G
队列),并为G
在M
上的运行提供本地化资源.
从两级线程模型来看,似乎并不需要P
的参与,有G
和M
就可以了,那为什么要加入P
这个东东呢?其实Go
语言运行时系统早期(Go1.0
)的实现中并没有P
的概念,Go
中的调度器直接将G
分配到合适的M
上运行.但是这样带来了很多问题,例如,不同的G
在不同的M
上并发运行时可能都需要向系统申请资源(如堆内存),由于资源是全局的,将会由于资源竞争造成很多系统性能损耗,为了解决类似的问题,后面的Go(Go1.1)
运行时系统加入了P
,让P
去管理G
对象,M
要想运行G
必须先与一个P
绑定,然后才能运行该P
管理的G
.这样带来的好处是,可以在P
对象中预先申请一些系统资源(本地资源),G
需要的时候向自己的本地P
申请(无锁保护),如果不够用或没有再向全局申请,而且从全局拿到的时候会多拿一部分,以供后骂你高效的使用.就像现在人们去政府办事情一样,先去本地政府看能搞定不,如果搞不定再去中央,从而提供办事效率.而且由于P
解耦了G
和M
对象,这样即使M
由于被其上正在运行的G
阻塞住,其余与该M
关联的G
也可以随着P
一起迁移到别的活跃的M
上继续运行,从而让G
总能即使找到M
并运行自己,从而提高系统的并发能力.Go
运行时系统通过构造G-P-M
对象模型实现了一套用户态的并发调度系统,可以自己管理和调度自己的并发任务,所以可以说**Go
语言原生支持并发.自己实现的调度器负责将并发任务分配到不同的内核线程上运行,然后内核调度器接着内核线程在CPU
上的执行与调度.
可以看到Go
的并发用起来非常简单,用了一个语法糖将内部复杂的实现结结实实的包装了起来.
其内部可以用如图来概述:
Go
运行时完整的调度系统是很复杂,很难用如上所述的内容描述清楚,这里是只能从宏观上概述一下.
// Goroutine1
func task1() {
go task2()
go task3()
}
2
3
4
5
加入哟一个G(Goroutine1)
已经通过P
被安排到了一个M
上正在执行,在Goroutine1
执行的过程中有创建两个G
,这两个G
会被马上放入与Goroutine1
相同的P
的本地G
任务队列中,排队等待与该P
绑定的M
的执行,这是最基本的结构,很好理解.关键问题是a.如何在一个多核心系统上尽量合理分配G
到多个M
上运行,充分利用多核,提高并发能力?如果在一个Goroutine
中通过go
关键字创建了大量G
,这些G
虽然暂时会被放在同一个队列,如果这时候还有空闲P
(系统内P
的数量默认等于CPU
核心数),Go
运行时系统始终能保证至少有一个(通常也只有一个)活跃的M
与空闲P
绑定去各种G
队列去寻找可以运行的G
任务,该中M
称为自旋的M.一般寻找顺序为: 自己绑定的P
的队列,全局队列,然后其他P
队列.如果自己P
队列.如果自己P
队列找到就拿出来开始运行,否则去全局队列看看,由于全局队列还是没有,就开始玩狠的了,直接从其他P
队列输出任务了(偷一半任务回来).这样就保证了在还有可运行的G
任务的情况下,总有与CPU
核心数相等的M+P
组合在执行G
任务或在执行G
的路上(寻找G
任务).**b.如果某个M在执行G的过程中被G中的系统调用阻塞了,怎么办?**在这种情况下,这个M
将会被内核调度器调度出CPU
并处于阻塞状态,与该M
关联的其他G
就没有办法继续执行了,但Go
运行时系统的一个监控线程(sysmon
线程)能探测到这样的M
,并把与该M
绑定的P
剥离,寻找其他空闲或新新建M
接管P
,然后继续运行其中的G
,大致过程如下图所示.然后等到该M
从阻塞状态恢复,需要重新找一个空间P
来继续执行原来的G
,如果这时系统正好没有空间P
,就把原来的G
放到全局队列当中,等待其他M+P
组合发觉并执行.
c.如果某一个G在M运行时间过长,有没有办法做抢占式调度,让该M上的其他G获得一定的运行时间,以保证调度系统的公平性?linux
的内核调度器主要是基于时间片和优先级做调度的.对于相同优先级的线程,内核调度器会尽量保证每个线程都能获得一定的执行时间.为了防止有些线程"饿死"的情况,内核调度器会发起抢占式调度将长期运行的线程中断并让出CPU
资源,让其他线程获得机会.当然在Go
的运行时调度器中也有类似的抢占机制,但并不能保证抢占能成功,因为Go
运行时系统并没有内核调度器的中断能力,它只能通过向运行时间过长的G
中设置抢占flag
的方法温柔的让运行的G
自己主动让出M
的执行权.说到这里就不得不提一下Goroutine
在运行过程中可以动态扩展自己线程的能力,可以从初始的2KB
大小扩展到最大1G
(64bit
系统上),因此字啊每次调用函数之前需要先计算该函数调用需要的栈空间大小,然后按需扩展(超过最大值讲导致运行时异常).Go
抢占式调度的机制就是利用在判断要不要扩栈的时候顺表查看一下自己的抢占flag
,决定是否继续执行,还是让出自己.运行时系统的监控线程会计时并设置抢占flag
到运行时间过长的G
,然后G
在由函数调用的时候会检查该抢占flag
,如果已设置就将自己放入全局队列,这样M
上关联的其他G
就有机会执行了.但如果正在执行的G
是个很耗时的操作且没有任何函数调用(如只是for
循环中的计算操作),即使抢占flag
已经被设置,该G
还是将一直霸占着当前M
直到执行完自己的任务.
参考:
- 01
- AWS NAT-NetWork-Firwalld配置(一)04-09
- 02
- AWS NAT-NetWork-Firwalld配置(二)04-09
- 03
- kubernetes部署minio对象存储01-18