进程、线程与协程
线程或者进程切换会带来大量的系统开销和上下文切换成本,导致严重的系统开销。
在之前的文章中,我们提到过切换进程的开销:
- 切换虚拟地址空间(切换页表、页目录以指向新的地址空间)
- 切换内核栈
- 切换硬件上下文
虽然线程切换不需要执行第一步(因为线程共享堆内存),但是线程和进程切换仍然存在通病:
- 丢失寄存器中的内容
- CPU Cache失效(现代cpu速度提升很大一部原因是因为Cache的引入)
- TLB快表失效
而协程的调度 不需要内核参与而是完全由用户态程序来决定,因此协程对于系统而言是无感知的。所以,协程由用户态控制就不存在抢占式调度那样强制的CPU控制权的转移,而是由协程自身主动转移CPU控制权。协程的切换,仅仅需要改变寄存器的数值,cpu便会从需要切换的协程指定位置继续运行。
简单来说,进程和线程需要使用时间片轮转,切换至内核态做切换;而协程直接只用用户态做主动的协作。
协程的优点:
- 无须线程上下文切换的开销;
- 无须原子操作锁定及同步的开销,原子操作就死一个最小的操作;
- 方便切换控制流,简化编程模型;
- 高并发性、高扩展性、低成本;
缺点:
- 无法利用多核资源:协程的本质就是单线程,它不能同时将单个CPU的多核用上,协程需要配合进程才能运行在多核CPU上,
- 可能造成饥饿:用户态协程不够智能怎么办?不知道何时让出控制权也不知道何时恢复执行。
Go协程:Goroutines
当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU转让出去,让其他 goroutine 能被调度并执行;goroutinegoroutine可以在不同的线程上切换; runtime实现了work-stealing算法,实现协程的切换,M个协程可以运行在N个线程上;Go1.14之后实现了协程的抢占式调度。
能够实现上面的几点,这和Go的调度器是分不开的,要了解Go协程,必须先理解Go调度器运行原理。
Go协程调度器
Go目前使用的调度器是2012年重新设计的,因为之前的调度器性能存在问题,所以使用4年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?
废弃的调度器需要知晓下面两个符号:
GM
M想要去执行G或者放回G,都必须去访问全局的GO协程队列。当M有多个的时候,那么去队列里拿取G的时候就需要有同步/互斥操作来做保护了。如下图琐事:
所以被废弃的调度器有如下几个缺点:
- 创建、销毁、调度G都需要M获取锁,增加了锁竞争
- M创建并转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M’。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
PGMP
- 全局队列存放着等待运行的G。而P的本地队列同全局队列类似,也是存放着等待运行的G,不过存放容量最大为256个,如果队列满了,那么会把本地队列中一半的G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
- M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列 偷一半(stealing-work) 放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
Go调度器的设计策略
- 复用线程
避免频繁的创建、销毁线程,而是对线程的复用。
- hand off机制
当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P。
简单来说就是当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
- work stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
- 抢占机制
在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。
- 全局G队列
在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。
- M自旋
自旋本质是在运行,线程在运行却没有执行G,就变成了浪费CPU。因为创建和销毁CPU也会浪费时间,我们希望当有新goroutine创建时,立刻能有M运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程,多余的没事做线程会让他们休眠。
在理解上述策略之后,Go func()调度流程如下:
为什么Java坚持多线程而不选择协程?
- 协程也具备不确定性
多线程Debug的困难在于运行时多个线程的抢占式调度,我们可以观察到即使是只有一个CPU核心,多个线程也会互相随机插入(Inter-weaving),这就造成了结果的随机性和不确定性。
而 “最早”的协程 会有一个主动让出的机制,有主动出让的机制。但现在的协程底层还是多线程,比如go的协程在运行的时候,一般来说go使用的并发协程数目也等于CPU的核心数,所以在多核环境下,多线程的问题(比如race condition、dead lock等等),协程一样会有。
协程:线程 比例 | 优点 | 缺点 |
---|---|---|
1:1 | 利用多核 | 上下文切换比较慢 |
N:1 | 上下文切换快 | 无法充分利用多核,容易发生饥饿(不主动出让) |
N:M | 充分利用多核,上下文切换快 | 调度难度大 |
- 线程切换开销并没有那么大
线程的切换实际上只会发生在那些“活跃”的线程上。对于类似于Web的场景,大量的线程实际上因为IO(发请求/读DB)而挂起,根本不会参与OS的线程切换。现实当中一个最大200线程的服务器可能同一时刻的“活跃线程”总数只有数十而已。其开销没有想象的那么大。为了避免过大的线程切换开销,真正要防范的是同时有大量“活跃线程”。
协程和Channel的使用建议
- 目前使用协程并不只是在一个CPU上跑,实际上还是有线程切换的成本。所以需要go协程中的事件开销比较大,至少大于协程调度的开销(实际上还有可能是线程调度)。
- 使用channel,最好选择一个合适的缓冲区的大小,可以减少协程阻塞,降低执行时间,尽量不要单词通过管道传递少量的数据。