这样主要介绍了下更进一步的调度,结合现代操作系统场景下,如何解决调度程序面临的更多的挑战-并发?
主要挑战
并发与一致性
多CPU场景下,调度程序首先需要面临的挑战就是并发与一致性的挑战。文章里简单介绍了下CPU的缓存,然后带出CPU缓存在调度时会出现的问题。
现代CPU基本都有3级的高速缓存,高速缓存位于CPU和内存Memory之间,高速缓存的速度一般是内存的几十倍(文中写到内存为几十到几百纳秒,高速缓存一般为几纳秒),高速缓存设计和存在的目的是因为大部分程序在运行时都具备局部性。
举个例子
- 当你访问一个数组时,你大概率是在迭代访问,所以高速缓存会顺带加载临近的元素到高速缓存中加快下次访问速度
- 当你的代码执行时,将下面的指令(可以理解为下面几行代码)加载到高速缓存中,可以大大提升执行速度
高速缓存在多处理器场景下就会存在问题,因为高速缓存与内存间并不存在强一致性。例如当你修改一个值以后,这个修改会先体现在高速缓存中(以便后续读取更快),然后通过其他策略定时或者在淘汰时再写回内存。这样在多CPU场景下就会出现这样一个问题:A进程在CPU1使用了一个变量并修改,此后调度程序将A调度到CPU2上执行,此时CPU2的高速缓存中没有A在CPU1上修改的数据,那么此时A可能会读到错误的值并继续错误的操作。
这类问题叫做缓存一致性问题,处理方法也不复杂。一般设计是所有缓存统一通过一个总线bus来访问内存,每个高速缓存通过总线来检测自己缓存中的数据是否有更新,如果有更新则将其失效或者更新(从内存中重新获取新的值)。这里只是简单描述了总线处理的概念,细节更加复杂(比如回写时内存的可见性)。
扩展一下,Java的著名NIO框架Netty中就使用了大量固定Long值来占据对象的头,来避免伪共享问题,之前有一篇博客提到过Netty学习:伪共享,但是在此书深入了解原理后,理解会更加透彻。本质就是为了保证缓存一致性的更新或者失效策略的代价在高并发下远大于使用高速缓存的收益,所以通过这种方式去变相禁用掉高速缓存(让高速缓存能加载的值全部是填充值,以此让他们全部相同,常变的值在剩下位置,使他们避免使用高速缓存)
同步性
这里文中只是简单的描述了下,也比较好理解,当进程的线程在多个CPU上同时执行,或者在多个CPU上共同访问一个数据结构时,会产生并发问题,解决的方式就是通过lock,代价就是性能
亲和性
亲和性指的是同一个进程肯定一直在同一个CPU上执行,性能会好得多。因为其中寄存器的状态或许可以无需改变,高速缓存中的数据或许还没有被完全淘汰。所以在设计调度程序的时候,如何一定程度上保障进程的亲和性,也是一个挑战
基本思路
基本的设计思路有2个类型,一个是单队列,一个是多队列
单队列
单队列即使用一个单个的队列(或者类似数据结构)。来控制所有CPU上任务的调度,优点是实现较为简单,缺点如下:
- 因为在并发的场景下必须使用到锁来保证调度队列的同步性,CPU越多,锁的频率和争抢就越频繁,所以单队列的扩展性很差。
- 单队列如果要考虑到亲和性的话,普通的轮询(针对CPU轮询去分配任务)算法肯定是无法保证亲和性的,但是如果要设计一些经常迁移进程到不同CPU去执行来保证亲和性的话。这个设计可以是非常复杂甚至难以实现的
多队列
多队列的实现思路也很简单,对每个CPU维护一个队列,相当于每个CPU有一个单队列,这样就解决了因为同步性的问题带来的不好扩展的问题。虽然增多了维护成本和在多队列上的CPU时间损耗,但是这个方案所能带来的扩展性十分值得去接受这些损耗。
还有一个究极老大难问题依旧是我们的亲和性,多队列场景下,亲和性很难保证,一个简单的想法是我们加一个调度在多队列之上来分配任务保证每个队列中的任务平均。但是这样又和单队列有什么本质区别呢?我们又带来了同步的消耗。所以一个比较推荐的做法是每个队列在其执行调度期间,检查如果自己队列的负载较低的话,就去其他队列中找那些负载较高的,迁移几个任务来自己这边执行,英文名称比较形象:work stealing
Linux
基于亲和性、扩展性的取舍,Linux也没有一统江山的多CPU调度程序,Linux目前有3种调度程序O(1),the Completely Fair Scheduler (CFS), and the BF Scheduler (BFS),其中O(1)和CFS使用多队列,BFS使用单队列。
Summary
这章主要介绍了多CPU设计调度程序会面临的一些关键挑战和问题,以及最基本的2种设计思路,实际细节文中推荐一篇论文去读:Towards Transparent CPU Scheduling。我打算整本书看完后再去补,因为论文有200多页PDF,这本书800多页,先去读论文有点影响这本书的学习进度。