前言
我们计算机上面跑的每个任务,都是操作系统层面的资源分配,从启动进程到创建线程,在核数固定的情况下,多线程并发地执行。Go协程是一个比系统线程更细粒度的资源,轻量级和易切换。
这几天看了一些相关的文章,这次尝试从操作系统到Go协程,简单聊聊它们是如何关联上的以及我个人的理解。
基本概念
操作系统(OS)
操作系统负责着底层硬件的调度,它分配CPU,内存,磁盘的资源,并且替我们分配不同线程在不同CPU核的执行,保证各个程序如预期的指令进行执行。我们提交的每个程序,最终都会转换成一系列给操作系统识别的指令。
线程(Thread)
上述经过转换的一系列指令,操作系统会通过线程来帮我们执行,本质上线程是a path of execution
,一段可执行的程序路径。
线程有下面三个状态:
- 运行中:正在执行任务,理想状态是所有线程都处于这个状态。
- 就绪:可以随时加入运行,从就绪到运行的状态切换,叫做上下文切换,它需要一定的代价。
- 等待就绪:需要等待资源分配或者IO(网络/设备)阻塞中,需要经过就绪才能运行,这是在用户角度最不想看到的,它成为了程序大部分的性能瓶颈。
系统调度器
调度器肩负着巨大的使命,主要在于调度CPU与线程关系,保证不会有CPU闲下来,想象一下CPU就是仓鼠笼子里面的仓鼠,调度器一旦发现有仓鼠(CPU)在打瞌睡,就推动它们到轮子(线程)上跑,一刻也不能停。毕竟CPU的运算能力是很强悍的,一毫秒的空闲都是巨大的浪费。
往细处说,仓鼠只有4只(四核),哪个轮子(线程)优先跑,取决于轮子(线程)的优先级。调度器肩负着运筹帷幄的使命,既要减少催促仓鼠跑动的延迟,同时还要保证不能有任务一直得不到执行,线程如果一直得不到仓鼠处理被称作“饥饿现象”。
任务类型
不同的任务对系统资源有不同的要求
-
I/O频繁切换:
未雨绸缪,IO指的是Input/Output,输入输出等待。这种等待资源输入/输出的job,主要瓶颈在资源是否分配到位,比如系统调用/文件IO等,其线程切换的时间远远小于IO设备/网络延迟,主要短板在于等待I/O,而不是线程上下文切换,因此可以分配较多线程,在“粮草”还没送到的这段时间,执行作战前的准备。 -
计算密集型:
这种任务主要耗CPU,比如用一个线程计算圆周率π的第N位。如果分配大量的线程给它,系统既要保证各个线程对计算状态的共享,先不考虑会发生脏读/脏写操作,还需要频繁进行线程切换,这会造成大量时间浪费,线程每次重新唤醒都要在程序计数器(PC)寻找下一个执行指令的位置,在核数固定的情况下,分配过多线程会有事倍功半的效果。If your program is focused on CPU-Bound work, then context switches are going to be a performance nightmare. Since the Thead always has work to do, the context switch is stopping that work from progressing.
当然也有例外的情况,比如Map-Reduce,指的是先拆分后聚合。当你的计算任务可以拆分成很多模块,在各个模块不同执行顺序不会影响最终结果的情况下,尝试“逐个击破”,最终再汇聚的情况,如果能合理分配任务,多线程肯定是优于单线程的。
后面尝试通过列举几个Go的简单程序,通过单/多协程处理,比对两者在不同情景的性能差异情况。
Map-Reduce:
这个例子比较简单,我们目标是累加一个等差数列
var PIXEL_ARRAY []int
//初始化一个等差数列,后面尝试将等差数列传到单协程/多协程函数进行累加
func init() {
for i := 1; i <= 100000; i++ {
PIXEL_ARRAY = append(PIXEL_ARRAY, i)
}
}
后面我们用单协程/多协程版本,区分开两个实现方式
//单协程暴力版本
func SumWithSingle(arr []int) int32 {
var sum int32
//遍历累加,相当暴力!
for i <