调度器:双队列与bitmap如何实现常数时间选进程)
聊Linux内核调度无论如何绕不开O(1)调度器。Linux 2.6内核把它作为默认调度器之后进程调度从每次“翻遍整支队伍选人”变成了“直接走到最高优先级队列面前点名”选进程的开销从O(n)降到了O(1)。这篇文章想把这个经典调度器掰开揉碎讲清楚它为什么要用active/expired双队列结构bitmap位图怎么加速找进程进程切换在context_switch里到底发生了什么以及它后来为什么又被CFS取代。适合谁读呢如果你正在啃《深入理解Linux内核》、看2.6早期内核源码或者准备面试被问到“O(1)调度器为什么是O(1)”这篇就是给你准备的。即使你现在生产环境用的是CFS理解O(1)调度队列依然是理解现代调度器的地基——很多数据结构思路per-CPU runqueue、bitmap加速、轮转公平性至今都在延续。1. 为什么需要O(1)调度器O(n)调度器的痛点1.1 O(n)调度器每次调度都要“翻遍全班名单”回到Linux 2.4时代调度器还是老一套系统里只有一个全局运行队列所有可运行进程都在这个队列里排队。每次schedule()要选下一个进程时调度器得从头到尾把队列里的进程遍历一遍根据优先级、剩余时间片、交互性打分最后挑一个“综合得分最高”的出来运行。这个模式有个很直观的问题——进程越多选人越慢。假设队列里躺着100个进程每一次调度都要做100次比较如果是1000个进程就是1000次比较。调度本身就是内核里最频繁的操作之一每秒可能会执行几十上百次乘上进程数量CPU时间就被白白烧掉了。这就是O(n)复杂度的代价。更致命的是SMP场景。全局只有一个运行队列所有CPU都得争抢这把全局锁CPU越多锁争用越严重性能不升反降。还有交互式进程的判定2.4调度器用一个忽高忽低的bonus值去猜进程是IO密集还是CPU密集猜准了皆大欢喜猜错了桌面操作就一顿一顿地卡。你敲一个键可能要等几十毫秒才有反应这在当时是很常见的体验。所以2.6内核换了思路调度决策必须在常数时间内完成无论系统里有多少进程时间开销都要一样同时每个CPU要有自己独立的运行队列不要再挤一把锁。1.2 O(1)调度器的设计目标选人速度与进程数量解耦Ingo Molnár在2.6.0时代写的这个O(1)调度器核心目标就一句话调度开销要固定不能随着进程数量增长。 为了实现这个目标他引入了两个很巧妙的设计。第一个是per-CPU运行队列每个CPU都有一份自己的runqueue结构调度自己的那批进程锁竞争瞬间小了很多。第二个就是active和expired双队列轮转机制。每个队列内部按优先级分成140个链表再用一个bitmap位图记录哪些优先级上有人排队。找下一个进程时只需要在位图上找最高优先级的那个非空队列这个操作是常数时间和进程数量完全无关。我习惯用一个生活化的比喻来理解这个结构想象银行里有140个窗口每个窗口对应一个优先级所有来办业务的人按优先级站在对应的窗口前排队。银行经理手里拿着一张记录表哪位客户来了就在对应窗口那栏打个勾。现在要叫号经理不需要挨个窗口数人只要看一眼表上哪个勾的位置最靠前走过去直接叫队首的人就行。不管大厅里有多少人这个流程都是固定的几步。2. O(1)调度队列的数据结构拆解2.1 per-CPU runqueue每个CPU一套“调度台账”O(1)调度器的地基是struct runqueue每个CPU维护一个这样的实例。在2.6早期源码中它定义在kernel/sched.c里关键字段包括struct runqueue { spinlock_t lock; // 本CPU队列的自旋锁保护队列数据 unsigned long nr_running; // 当前可运行进程数量 struct prio_array *active; // 指向当前活动队列数组 struct prio_array *expired; // 指向时间片耗尽队列数组 struct prio_array arrays[2]; // 实际的两个prio_array数组 int prev_cpu_load[NR_CPUS]; // 各CPU负载历史用于负载均衡 // ... 还有clock、timestamp等调度统计字段 };注意lock字段它是per-CPU的每个CPU只锁自己的队列所以SMP下两个CPU可以同时调度各自的进程互不干扰。这比2.4的全局大锁强太多了。active和expired是两个指针分别指向arrays[0]和arrays[1]中的一个。为什么要用指针而不是固定死谁是谁因为当active队列里的进程都跑完时间片后内核只需要交换两个指针active立刻变成expiredexpired变成active整个过程就是一次指针赋值又是O(1)。这种“指针交换代替数据搬移”的思路在调度器里反复出现后面讲轮转机制时会再展开。2.2 prio_array与bitmap用位图给140个优先级“站台”runqueue里真正装进程的是struct prio_array它的定义大概是这样的#define MAX_PRIO 140 #define BITMAP_SIZE ((MAX_PRIO 31) / 32) struct prio_array { unsigned int nr_active; // 队列中活跃进程总数 unsigned long bitmap[BITMAP_SIZE]; // 位图哪个优先级不为空 struct list_head queue[MAX_PRIO]; // 140个双向链表头 };140个优先级从哪来的0到99是实时进程SCHED_FIFO和SCHED_RR数值越小优先级越高100到139是普通进程由nice值映射而来。每个优先级对应一个双向链表头所有相同优先级的进程都在同一个链表里排队。bitmap是提速的关键。它用5个unsigned long32位机上是32×5160位覆盖140个优先级绰绰有余来记录哪些优先级队列非空。入队一个进程时list_add_tail(p-run_list, array-queue p-prio); __set_bit(p-prio, array-bitmap); array-nr_active;出队时如果该链表空了就清掉对应位if (list_empty(array-queue p-prio)) __clear_bit(p-prio, array-bitmap);选下一个进程时内核用sched_find_first_bit在bitmap里找第一个被置位的位这就是最高非空优先级。CPU有专门的指令比如x86的bsf来加速这种位扫描不管进程总数是3个还是3000个这一步都是固定几条指令的事。我看到有些讲解把这里的扫描说成“遍历140个优先级”这其实不对。如果真的从0到139线性扫描那严格说也是O(140)的常量时间在进程数量上仍然是O(1)但位图方案更彻底——直接用硬件指令定位连140次循环都不用。这也是调度器作者追求极致性能的体现。2.3 时间片与优先级nice值怎么换算成调度权重普通进程的静态优先级由nice值决定公式是static_prio MAX_USER_RT_PRIO nice 20MAX_USER_RT_PRIO是100所以nice为0时静态优先级是120nice为-20时是100nice为19时是139。数值越小优先级越高这也是为什么很多老手会说“负的nice值其实是‘加急’”。时间片的计算和静态优先级直接挂钩静态优先级数值越小优先级越高能拿到的时间片越长。大致范围在10毫秒到200毫秒之间。高优先级进程时间片长、低优先级进程时间片短配合双队列轮转自然实现了“重要的人多干活、不重要的人少占CPU”的意图。但是一个进程不可能永远按静态优先级跑否则交互式进程比如文本编辑器、终端很容易被CPU密集型进程饿死。所以O(1)调度器引入了一个动态优先级的概念根据进程最近的睡眠时间sleep_avg来调整实际生效的优先级。sleep_avg越大说明进程经常睡眠、经常被唤醒大概率是IO密集或交互类型调度器就给它一个bonus提高动态优先级让它更容易被选中。这就是O(1)调度器改善交互延迟的核心手段。它不再是单纯地“优先级高的先跑”而是动态追踪进程行为让频繁阻塞的交互进程即使静态优先级不高也能获得接近实时的响应。这套启发式在当时的硬件条件下确实效果不错但代价是逻辑复杂、参数难调后来CFS的简洁设计反而成了优势。3. 进程切换的完整流程3.1 哪些时机触发了调度进程不会平白无故被切走也不存在一个“调度线程”在那不停地挑人。调度发生在特定时机大体可以归纳为五类。主动让出。进程自己调用schedule()最常见的就是等待IO时调用sleep_on或wait_event或者通过sched_yield()主动放弃CPU。这类切换是进程自己发起的内核态可以立刻执行上下文切换。时间片耗尽。时钟中断到来时scheduler_tick()检查当前进程的时间片跑完了就给它打上TIF_NEED_RESCHED标志。注意这里不是立刻切换而是“下单排队”等中断返回前检查标志再切。进程唤醒。某个进程被wake_up()唤醒并放入运行队列后如果它的动态优先级比当前运行进程高调度器可能直接触发抢占。这类抢占在2.6内核里叫用户态抢占或内核态抢占由CONFIG_PREEMPT控制。阻塞系统调用。进程执行read()、write()、nanosleep()这类系统调用时如果被阻塞内核会同步调用schedule()把CPU让给别的进程。更高优先级实时进程出现。比如一个SCHED_FIFO实时进程被唤醒它会直接抢占当前进程这是硬性规则。很多新手以为“时间片用完”就等于“立刻切换”其实不对。为了减少上下文切换次数内核用的是延迟抢占机制中断里只设置标志位等中断返回到用户态前才真正检查并调度。如果中断时进程运行在内核态且内核没有开启抢占那要等它回到用户态才切。这个细节能解释很多“为什么我的进程没有马上被切换”的疑惑。3.2 schedule()调用链路从tick中断到“换人”一次典型的调度从时钟中断开始。时钟中断处理函数里会调用scheduler_tick()它负责当前进程时间片的扣减时钟中断 - scheduler_tick() - task_tick() - 时间片减1 ↓ 时间片耗尽 ↓ 设置TIF_NEED_RESCHED进程进入expired队列真正执行切换的函数是schedule()。简化后的核心流程大致如下以2.6早期风格描述static void __sched schedule(void) { struct runqueue *rq this_rq(); struct prio_array *array; struct list_head *queue; struct task_struct *next; int idx; // 如果当前进程时间片已耗尽把它移动到expired队列 if (unlikely(current-time_slice 0)) deactivate_task(current, rq); array rq-active; if (unlikely(array-nr_active 0)) { // active队列已清空交换active和expired rq-active rq-expired; rq-expired array; array rq-active; } // 从bitmap中找最高优先级队列 idx sched_find_first_bit(array-bitmap); queue array-queue idx; next list_entry(queue-next, struct task_struct, run_list); // 从active队列中摘下next准备切换 dequeue_task(next, array); context_switch(rq, current, next); }看到没选人就是三行代码查bitmap、拿链表头、取第一个进程。这就是O(1)的精髓——不管系统里躺着多少进程这个“选人”过程永远是一样的步骤。interrupt返回前的ret_from_syscall路径会检查TIF_NEED_RESCHED如果置位就调用schedule()走一遍上述流程。这里插一句老话真正决定调度器性能的不只是算法复杂度还有函数调用链路的开销。O(1)调度器能把调度主路径压得这么短和它精心设计的快速路径是分不开的。3.3 context_switch切换背后的“硬核魔术”选好next进程后内核要执行context_switch()。这一步包含两件大事切换地址空间和切换内核态上下文。切换地址空间用的是switch_mm()。每个进程有自己独立的页表也就是独立的虚拟地址空间。切换时要把next进程的页表基地址写进CR3寄存器x86架构让CPU认新的地址空间。如果next和prev共享地址空间比如两个线程这个步骤可以跳过这也是线程切换比进程切换便宜的原因之一。真正复杂的是switch_to()。它要保存prev进程的CPU寄存器现场通用寄存器、标志寄存器、栈指针然后恢复next进程之前保存的现场。在x86 32位上这段汇编的核心逻辑可以理解为pushfl // 当前标志寄存器压栈 pushl %ebp // 当前ebp压栈 movl %esp, prev-thread.sp // 保存prev的内核栈指针 movl next-thread.sp, %esp // 恢复next的内核栈指针 jmp __switch_to // 切换TSS、载入next的现场关键在于内核栈指针的保存和恢复。每个进程都有自己的内核栈这个栈里保存着它被切走时的完整调用现场。当调度器切回某个进程时它做的其实是“恢复那个进程上次被切走时的栈顶位置”然后CPU接着从栈上弹回寄存器值继续执行原来的代码路径。这就是为什么同一个进程代码会感觉不到自己被切走过——下次执行时它看到的局部变量、函数栈还是老样子。有朋友问过我用户态的寄存器呢答案是用户态寄存器保存在进程每次陷入内核时压入内核栈的pt_regs结构里恢复现场时一并弹回。了解这个流程后你会明白为什么说“进程切换的本质是内核栈的切换”这句话一点不夸张。4. 核心调度流程O(1)队列怎么选人4.1 常数时间选人的原理选人过程已经在上面的schedule()里展露过了我再单独拎出来强调一遍为什么它是常数时间。idx sched_find_first_bit(array-bitmap); queue array-queue idx; next list_entry(queue-next, struct task_struct, run_list);第一步查bitmap本质是执行一条位扫描指令比如x86的bsf查找第一个置位位的索引。这条指令在硬件层面就是几拍的事不管位图里有多少位是1。第二步是从queue数组里取对应的链表头这是O(1)的内存访问。第三步是从双向链表里取第一个进程节点仍然是固定操作。这三步合起来的时间和runqueue里挂了多少进程没有任何关系。哪怕有个极端场景1000个可运行进程挤在同一个优先级链表里取队首依然是O(1)哪怕140个优先级全有人排队找最高优先级依然是查一次位图就完事。这就是“O(1)”这个名字的来源调度开销恒定不随运行队列长度增长。有一个细节值得注意如果某个优先级链表里有多个进程它们在同一优先级内是轮流运行的。进程从链表尾部入队调度时从队首取出运行完一个时间片后被放到expired队列的对应优先级链表尾部天然形成了一个round-robin轮转。所以O(1)调度器在高优先级插队的同时同一优先级内又能保证相对公平这个设计相当精巧。4.2 active与expired双队列轮转一个“双桶”模型双队列机制是整个O(1)调度器最经典的设计之一。每个CPU维护两个prio_arrayactive里装的是“本轮还有时间片”的进程expired里装的是“本轮时间片耗尽”的进程。新进程或刚醒来的进程一律进active进程时间片用完就被踢进expired并重新计算下一个时间片长度。那active队列什么时候会空当所有可运行进程都至少被调度过一次、且时间片都跑完时active就空了。这时内核做个指针交换if (unlikely(array-nr_active 0)) { rq-active rq-expired; rq-expired array; array rq-active; }然后expired数组摇身一变成为新的active新一轮调度开始。这个机制保证了每个进程在一个调度周期内都有机会运行——高优先级进程时间片多、跑得频繁低优先级进程时间片短、靠后出场但不会被饿死。我打一个比方active和expired就像是食堂的两个打饭窗口。窗口A接待这轮能吃饭的同学窗口B接待已经吃完准备下一场的孩子。A窗口空了就把两个窗口的牌子换一下B变成A继续放人进来。进程永远在这两个队列之间流动调度器不需要把进程数据搬来搬去改个指针就完成一轮轮转。4.3 SMP负载均衡不能让一个CPU忙死、其他CPU闲呆O(1)调度器是per-CPU结构的每个CPU各管各的runqueue。但如果一个CPU上的进程特别多、另一个CPU几乎空闲就会出现严重的负载不均。所以调度器还有一个配套机制定期负载均衡。内核里有个load_balance()函数会在调度时机比如某个CPU的runqueue变空时被触发或者由scheduler_tick()周期性检查。它会看看其他CPU的运行队列负载找出“太忙”的CPU然后把上面的部分进程“迁移”到当前空闲CPU上来。迁移的时候要特别小心进程可能正在某个CPU上运行强行迁移要处理运行状态、缓存亲和性等一堆琐碎问题。负载均衡是O(1)调度器里最复杂的部分之一也是实际生产环境中影响性能的关键。早期实现里负载均衡不够激进多核CPU上经常出现“一个核打满、其他核围观”的现象。后来内核社区花了好几个版本迭代才把负载均衡算法打磨得比较靠谱。如果你在面试里能聊出这一层面试官一般都会高看一眼。5. O(1)调度器的缺陷与CFS的接替5.1 交互进程判断的“玄学”O(1)调度器虽然解决了选人的复杂度问题但它的交互式进程判定逻辑越来越复杂。核心是sleep_avg这个指标进程睡眠越久sleep_avg越大动态优先级bonus越高。问题是这个指标受睡眠时长、唤醒频率、系统负载影响极大同一个进程在不同场景下可能被判定成完全不同的类型。举个实际例子一个后台服务进程它每隔几秒处理一次任务然后sleepsleep_avg会积累得很高调度器会把它当成“交互进程”给高优先级。这本身问题不大。但如果系统里同时有很多这样的进程它们的sleep_avg都很大反而挤压了真正的交互进程比如桌面UI的响应空间。更麻烦的是一旦某个进程的sleep_avg被错误积累变成“永不降级”的高优先级任务其他进程就会被长期压制。内核社区在2.6版本周期里一直在修各种奇奇怪怪的调度异常很多都和sleep_avg的边界情况有关。此外动态优先级和静态优先级混在一起还带来了优先级反转问题。低优先级进程如果sleep_avg很高动态优先级可能超过高优先级进程导致实时进程也被“误伤”。这些问题迫使内核社区反思用一个启发式规则去“猜”进程类型真的靠谱吗5.2 从O(1)到CFS公平性取代复杂度Linux 2.6.23内核合并了CFSCompletely Fair Scheduler完全公平调度器同样出自Ingo Molnár之手。CFS彻底抛弃了active/expired双队列和sleep_avg启发式改用了一种全新的模型一棵以vruntime为键的红黑树。什么叫vruntime简单说就是进程实际运行时间的虚拟化表示。一个进程每运行一个tickvruntime就增长睡眠的进程vruntime不动。CFS每次调度时直接从红黑树左端取出vruntime最小的进程来运行因为它的“欠账”最多。这背后是一套非常优雅的“理想公平CPU”模型如果CPU可以无限并行所有进程应该同时匀速前进vruntime就是衡量谁落后了、谁超前了的标尺。CFS和O(1)的对比很有意思对比项O(1)调度器CFS调度器选进程复杂度O(1)固定几条指令O(log n)红黑树查找最小值进程公平性依赖时间片和优先级队列轮转按vruntime持续逼近理想公平交互式优化sleep_avg启发式复杂难调睡眠进程vruntime不增长天然受惠优先级支持静态优先级nice动态bonusnice直接映射到虚拟时间比例数据结构prio_array bitmap红黑树维护成本参数多、边界情况多核心概念简单几乎无此问题从时间复杂度上看CFS的O(log n)比O(1)“慢”但这个log n是进程数的对数而且红黑树操作本身非常快。真正让CFS胜出的是它的公平性模型和简洁性——它不需要去猜进程是不是交互式的睡眠久的进程vruntime自然小自然会被优先调度交互响应就自动好了。一个简单的原则覆盖了所有场景这就是设计的智慧。我在实际阅读中觉得O(1)调度器和CFS的对比特别像两种工作方式的对比一种是经验丰富的“老专家”靠复杂的直觉和经验给每个人打分排期另一种是制定一个清晰的规则让每个人按规则自我调整。短期看老专家的经验可能更精准但长期看规则驱动的系统更可靠、更好维护、边界问题更少。6. 学习O(1)调度器的实操建议与问题排查6.1 源码版本与学习方法速记想亲手看O(1)调度器的代码建议选Linux 2.6.11或2.6.18这种稳定版本重点看两个文件kernel/sched.c调度器主逻辑runqueue、schedule()、scheduler_tick()都在这里include/linux/sched.htask_struct和调度相关的宏定义看源码时我建议准备一张大纸把runqueue、prio_array、bitmap、task_struct的关系画成框图。看代码的路径可以按这个顺序走一遍先找runqueue结构体再看prio_array然后看schedule()函数接着看enqueue_task和dequeue_task怎么操作bitmap最后再看timer tick里怎么调用scheduler_tick。这个顺序是从“数据结构”到“主流程”再到“操作细节”比直接一头扎进schedule()里有效率得多。有条件的话可以在QEMU里跑一个2.6内核加printk观察调度过程。不过说实话用printk调试调度器会刷屏刷到你怀疑人生更好的方式是用kgdb断点调试或者干脆在schedule()入口加一个静态计数器统计一段时间内的调度次数直观感受调度的频率。6.2 在真实系统上观察调度行为现代Linux发行版基本都是CFS但观察调度的思路依然适用。你可以用perf工具记录调度事件perf sched record -- sleep 5 perf sched latency第一条命令会记录5秒内所有进程的调度行为第二条命令统计每个进程的调度延迟。输出里能看到每个进程被唤醒到真正上CPU之间的等待时间这个数据比任何理论分析都直观。内核里还有一个调度调试接口很多老内核都有/proc/sched_debug和/proc/schedstat里面能看到每个CPU的运行队列情况、每个进程的vruntime。现代系统上可以直接看cat /proc/sched_debug如果你在虚拟机上跑旧内核甚至可以找到2.6源码里的sched_domain代码看看负载均衡的层次化设计。这些动手观察比看十篇博客都管用。6.3 常见问题与排查速查表我把读源码和面试过程中容易混淆的问题整理成了一张表问题答案要点140个优先级是怎么分配的0~99是实时进程100~139是普通进程nice映射到静态优先级为什么active空了要交换指针而不是清空重排指针交换是O(1)清空重排是O(n)完全没必要bitmap用了多少位32位机上5个unsigned long共160位有效位140同一个优先级的进程怎么排双向链表尾部入队、头部取走天然round-robin时间片用完立即切换吗不立即切中断里设置TIF_NEED_RESCHED返回前检查再切动态优先级比静态优先级高会怎样O(1)里可能让低静态优先级进程表现得很活跃这也是它被批评的点为什么2.6.23后看不到O(1)了被CFS取代但CFS的cfs_rq结构延续了per-CPU队列思路实时进程会被CFS影响吗不会实时调度类SCHED_FIFO/RR始终优先CFS只是普通进程的调度器如果你在做性能排查时发现某个进程响应诡异可以先用perf sched看延迟分布再结合这些调度器的历史知识推断是不是调度策略、优先级设置或cgroup限制在起作用。很多“系统卡顿”问题追根溯源都是进程被长时间排队、抢占不公平导致的。6.4 一个“心法”级别的提醒从O(1)里学到的设计思路读完O(1)调度器除了技术细节我更想跟你分享一个通用的设计心法性能优化很多时候不是把操作变快而是把操作次数变少或者把重复操作转移。举几个例子active和expired指针交换避免了大数组清零bitmap把遍历进程简化为硬件位扫描per-CPU runqueue减少了锁竞争延迟抢占把切换动作从高频率的中断上下文挪到了可控的内核路径上。这些设计中的每一个都在说同一件事高频路径上能省的步骤一定省掉能并行的逻辑尽量并行能延迟的动作绝不提前做。这套思路放到任何系统设计里都适用。比如你做数据库连接池、做缓存淘汰、做消息队列核心都是“如何在最热路径上用最少的操作完成最关键的事”。读懂O(1)调度器不只是读懂一段历史代码更是读懂一代内核工程师在极端性能约束下的思维方式。7. 几条调试心得给真正动手的人个人觉得读调度器代码最忌一步到位。我第一次啃2.6的sched.c时盯着schedule()函数看了两个小时越看越绕。后来换了方法先手动画一遍runqueue和prio_array的内存关系图再模拟一个进程从创建到入队、被调度、时间片耗尽、进入expired、active清空、交换指针的完整生命周期一下子就通了。如果你也想上手建议自己写一个小内核模块通过for_each_process遍历所有进程读task_struct里的prio、time_slice字段打印出来对比一下。虽然现代内核的字段名不同了但那种“亲手摸到内核数据结构”的感觉比看多少文档都强。再往后可以自己试着改改nice值观察它对调度行为的影响用perf sched记录前后对比数据做一张自己的实验报告。O(1)调度器虽然已是历史但它藏在代码里的工程智慧并没过时。理解它你会更清楚地看到Linux调度器是怎么一步步走到今天的也会对“设计一个又快又公平的系统”这件事有更具体的体感。反正我读过它之后再看CFS的红黑树、再看 EEVDF 这类新调度模型都能快速抓住本质——调度器不管怎么换永远在“怎么选下一个进程”和“怎么保证公平”这两个根本问题上做文章。