1. 前言
Go是并发语言,而不是并行语言。 在讨论如何在Go中进行并发处理之前,我们首先必须了解什么是并发,以及它与并行性有什么不同。
- 并发性Concurrency:你一边听音乐,一边刷微博,一边聊QQ,一边用Markdown写作业
- 并行性parallelism:并行就是同时做很多事情。
2. 基本概念
2.1 进程 / 线程 / 协程
-
进程: 进程是一个程序在一个数据集中的一次动态执行过程,它是CPU资源分配和调度的独立单位。进程一般由程序、数据集、进程控制块三部分组成。
-
线程: 线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元。由线程ID、程序计数器、寄存器集合和堆栈共同组成。一个进程可以包含多个线程。
-
协程: 协程是一种用户态的轻量级线程,协程的调度完全由用户控制,人们通常将协程和子程序(函数),Go语言gorountine ,占用内存更小(几 kb) 。
2.2 线程 和 协程对比
与传统的系统级线程和进程相比:
- 协程的最大优势在于其"轻量级",可以轻松创建上百万个而不会导致系统资源衰竭。
- 协程的执行效率极高,子程序切换不是线程切换,而是由程序自身控制,没有线程切换的开销,线程数量越多协程的性能优势就越明显。
2.3 线程池
在高并发应用中频繁创建线程会造成不必要的开销,所以有了线程池。
-
worker线程执行任务中发生系统调用,则操作系统会将该线程置为阻塞状态,也意味着该线程在怠工,也意味着消费任务队列的worker线程变少了,也就是说线程池消费任务队列的能力变弱了。
-
如果任务中大部分任务都会进行系统调用,则会让这种状态恶化,大部分worker线程进入阻塞状态,从而任务队列中的任务产生堆积。
解决这个问题:思路就是重新审视线程池中线程的数量,增加线程池中线程数量可以一定程度上提高消费能力,但随着线程数量增多由于过多线程争抢CPU,消费能力会有上限,甚至出现消费能力下降。
3. Goroutine调度器
线程数过多,意味着操作系统会不断地切换线程,频繁的上下文切换就成了性能瓶颈。
Goroutine主要概念如下:
- G(Goroutine): 即Go协程,每个go关键字都会创建一个协程。
- M(Machine): 工作线程,在Go中称为Machine。
- P(Processor): 处理器(Go中定义的一个摡念,不是指CPU),包含运行Go代码的必要资源,也有调度goroutine的能力。
M必须拥有P才可以执行G中的代码,P持有一个G的队列,P可以调度G交由M执行。
- M是交给操作系统调度的线程:M持有一个P。
- P维护着一个包含G的队列(图中灰色部分):可以按照一定的策略将G调度到M中执行。
P个数默认情况下等同于CPU的核数,由于M必须持有一个P才可以运行Go代码,也即线程数一般等同于CPU的个数,以达到尽可能的使用多核CPU 而 不至于产生过多的线程切换开销。
4. Goroutine调度策略
4.1 队列轮询
-
P维护着一个包含G的队列:不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死
-
有一个全局的队列:每个P会周期性地查看全局队列中是否有待运行的G,并将其调度到M中执行。
全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G被饿死。
4.2 系统调用
P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池Go也提供一个M的池子,需要时从池子中获取,用完放回池子不够用时就再创建一个。
- M运行的某个G产生系统调用时问题
当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。
M1接替M0的工作,只要P不空闲就可以保证充分利用CPU。
当G0系统调用结束后,根据M0是否能获取到P,将会将G0做不同的处理:
- 如果有空闲的P,则获取一个P,继续执行G0。
- 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度,然后M0将进入缓存池睡眠。
4.3 工作窃取
- 多个P中维护的G队列有可能是不均衡问题
右边的P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。
此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半继续执行。
5. 协程和线程映射关系
协程 (co-routine) 绑定 线程 (thread) 执行,映射关系是什么?
5.1 1:1 关系
1 个协程绑定 1 个线程,这种最容易实现,协程的调度都由 CPU 完成了。
缺点:协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。
5.2 N:1 关系
N 个协程绑定 1 个线程,优点就是协程
,不会陷入到内核态,这种切换非常的轻量快速。
缺点:
- 不能使用到CPU的多核能力。
- 一旦某协程阻塞造成线程阻塞,导致其他协程都无法执行了,丧失了并发能力。
5.2 M:N 关系
M 个协程绑定 N个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。
协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后才执行下一个协程。