作者简介
郭健,一名普通的内核工程师,以钻研Linux内核代码为乐,热衷于技术分享,和朋友一起创建了蜗窝科技的网站,希望能汇集有同样想法的技术人,以蜗牛的心态探讨技术。
(小编画外音:郭大侠是我最佩服的大侠,他为人低调,技术精湛又虚怀若谷,实为我辈Linuxer之楷模。他的http://www.wowotech.net/网站,有很多精彩的原创文章,已经使得百千万读者获益。侠之大者,为国为民。)
稿件征集
欢迎您给Linuxer投稿,赢得人民邮电异步社区任意在售技术图书。您随便挑,详情:Linuxer-"Linux开发者自己的媒体"第四月稿件录取和赠书名单
走过路过,不要错过Linuxer哦,点击二维码关注Linuxer!
一、前言
随着内核版本的演进,其源代码的膨胀速度也在递增,这让Linux的学习曲线变得越来越陡峭了。这对初识内核的同学而言当然不是什么好事情,满腔热情很容易被当头浇灭。我有一个循序渐进的方法,那就是先不要看最新的内核,首先找到一个古老版本的内核(一般都会比较简单),将其吃透,然后一点点的迭代,理解每个版本变更背后的缘由和目的,最终推进到最新内核版本。
本文就是从2.4时代的任务调度器开始,详细描述其实现并慢慢向前递进。当然,为了更好的理解Linux调度器设计和实现,我们在第二章给出了一些通用的概念。之后,我们会在第四章讲述O(1)调度器如何改进并提升调度器性能。真正有划时代意义的是CFS调度器,在2.6.23版本的内核中并入主线。它的设计思想是那么的眩目,即便是目前最新的内核中,完全公平的设计思想仍然没有太大变化,这些我们会在第六章描述。第五章是关于公平调度思想的引入,通过这一章可以了解Con Kolivas的RSDL调度器,它是开启公平调度的先锋,通过这一章的铺垫,我们可以更顺畅的理解CFS。最后一章是对全文的总结。
二、任务调度器概述
为了不引起混乱,我们一开始先澄清几个概念。进程调度器是传统的说法,但是实际上进程是资源管理的单位,线程才是调度的单位,但是线程调度器的说法让我觉得很不舒服,因此最终采用进程调度器或者任务调度器的说法。为了节省字,本文有些地方也直接简称调度器,此外,除非特别说明,本文中的“进程”指的是task struct代表的那个实体,毕竟这是一篇讲调度器的文档。
任务调度器是操作系统一个很重要的部件,它的主要功能就是把系统中的task调度到各个CPU上去执行满足如下的性能需求:
1、对于time-sharing的进程,调度器必须是公平的
2、快速的进程响应时间
3、系统的throughput要高
4、功耗要小
当然,不同的任务有不同的需求,因此我们需要对任务进行分类:一种是普通进程,另外一种是实时进程。对于实时进程,毫无疑问快速响应的需求是最重要的,而对于普通进程,我们需要兼顾前三点的需求。相信你也发现了,这些需求是互相冲突的,对于这些time-sharing的普通进程如何平衡设计呢?这里需要进一步将普通进程细分为交互式进程(interactive processs)和批处理进程(batch process)。交互式进程需要和用户进行交流,因此对调度延迟比较敏感,而批处理进程属于那种在后台默默干活的,因此它更注重throughput的需求。当然,无论如何,分享时间片的普通进程还是需要兼顾公平,不能有人大鱼大肉,有人连汤都喝不上。功耗的需求其实一直以来都没有特别被调度器重视,当然在linux大量在手持设备上应用之后,调度器不得不面对这个问题了,当然限于篇幅,本文就不展开了。
为了达到这些设计目标,调度器必须要考虑某些调度因素,比如说“优先级”、“时间片”等。很多RTOS的调度器都是priority-based的,官大一级压死人,调度器总是选择优先级最高的那个进程执行。而在Linux内核中,优先级就是实时进程调度的主要考虑因素。而对于普通进程,如何细分时间片则是调度器的核心思考点。过大的时间片会严重损伤系统的响应延迟,让用户明显能够感知到延迟,卡顿,从而影响用户体验。较小的时间片虽然有助于减少调度延迟,但是频繁的切换对系统的throughput会造成严重的影响。因为这时候大部分的CPU时间用于进程切换,而忘记了它本来的功能其实就是推动任务的执行。
由于Linux是一个通用操作系统,它的目标是星辰大海,既能运行在嵌入式平台上,又能在服务器领域中获得很好的性能表现,此外在桌面应用场景中,也不能让用户有较差的用户体验。因此,Linux任务调度器的设计是一个极具挑战性的工作,需要在各种有冲突的需求中维持平衡。还好,经过几十年内核黑客孜孜不倦的努力,Linux内核正在向着最终目标迈进。
三、2.4时代的O(n)调度器
网上有很多的linux内核考古队,挖掘非常古老内核的设计和实现。虽然我对进程调度器历史感兴趣,但是我只对“近代史”感兴趣,因此,让我们从2.4时代开始吧,具体的内核版本我选择的是2.4.18版本,该版本的调度器相关软件结构可以参考下面的图片:
本章所有的描述都是基于上面的软件结构图。
1、进程描述符
struct task_struct { volatile long need_resched; long counter; long nice; unsigned long policy; int processor; unsigned long cpus_runnable, cpus_allowed; struct list_head run_list; unsigned long rt_priority; ...... }; |
对于2.4内核,进程切换有两种,一种是当进程由于需要等待某种资源而无法继续执行下去,这时候只能是主动将自己挂起(调用schedule函数),引发一次任务调度过程。另外一种是进程欢快执行,但是由于各种调度事件的发生(例如时间片用完)而被迫让出CPU,被其他进程抢占。这时候的调度并不是立刻发送,而是延迟执行,具体的方法是设定当前进程的need_resched等于1,然后静静的等待最近一个调度点的来临,当调度点到来的时候,内核会调用schedule函数,抢占当前task的执行。
nice成员就是普通进程的静态优先级,通过NICE_TO_TICKS宏可以将一个进程的静态优先级映射成缺省时间片,保存在counter成员中。因此在一次调度周期开始的时候,counter其实就是该进程分配的CPU时间额度(对于睡眠的进程还有些奖励,后面会描述),以tick为单位,并且在每个tick到来的时候减一,直到耗尽其时间片,然后等待下一个调度周期从头再来。
Policy是调度策略,2.4内核主要支持三种调度策略,SCHED_OTHER是普通进程,SCHED_RR和SCHED_FIFO是实时进程。SCHED_RR和SCHED_FIFO的调度策略在rt_priority不同的时候,都是谁的优先级高谁先执行,唯一的不同是相同优先级的处理:SCHED_RR采用时间片轮转,而SCHED_FIFO采用的策略是先到先得,先占有CPU的进程会持续执行,直到退出或者阻塞的时候才会让出CPU。也只有这时候,其他同优先级的实时进程才有机会执行。如果进程是实时进程,那么rt_priority表示该进程的静态优先级。这个成员对普通进程是无效的,可以设定为0。除了上面描述的三种调度策略,policy成员也可以设定SCHED_YIELD的标记,当然它和调度策略无关,主要处理sched_yield系统调用的。
Processor、cpus_runnable和cpus_allowed这三个成员都是和CPU相关。Processor说明了该进程正在执行(或者上次执行)的逻辑CPU号;cpus_allowed是该task允许在那些CPU上执行的掩码;cpus_runnable是为了计算一个指定的进程是否适合调度到指定的CPU上去执行而引入的,如果该进程没有被任何CPU执行,那么所有的bit被设定为1,如果进程正在被某个CPU执行,那么正在执行的CPUbit设定为1,其他设定为0。具体如何使用cpus_runnable可以参考can_schedule函数。
run_list成员是链接入各种链表的节点,下一小节会描述内核如何组织task,这里不再赘述。
2、如何组织task
Linux2.4版本的进程调度器使用了非常简陋的方法来管理可运行状态的进程。调度器模块定义了一个runqueue_head的链表头变量,无论进程是普通进程还是实时进程,只要进程状态变成可运行状态的时候,它会被挂入这个全局runqueue链表中。随着系统的运行,runqueue链表中的进程会不断的插入或者移除。例如当fork进程的时候,新鲜出炉的子进程会挂入这个runqueue。当阻塞或者退出的时候,进程会从这个runqueue中删除。但是无论如何变迁,调度器始终只是关注这个全局runqueue链表中的task,并把最适合的那个任务丢到CPU上去执行。由于整个系统中的所有CPU共享一个runqueue,为了解决同步问题,调度器模块定义了一个自旋锁来保护对这个全局runqueue的并发访问
除了这个runqueue队列,系统还有一个囊括所有task(不管其进程状态为何)的链表,链表头定义为init_task