《从零入门Linux系统篇(二十一):进程篇·五——CPU到底是怎么“切换进程”的?从上下文切换到O(1)调度器》
这篇文章,我们要把Linux内核的底裤再往下扒一层。不聊浮在表面的概念,这回直接钻进去,拆解进程切换时上下文到底是怎么被保护、又是怎么在硬件层面被精准恢复的。紧接着,把O(1)调度算法那套经典的双阵列架构和位图加速机制掰开揉碎,看清它凭什么能让调度快到极致。准备好了吗?我们这就直击操作系统底层的运行本质。
目录
2.4.1 TSS(Task State Segment)与task_struct
一、进程切换与CPU寄存器——理解时间片背后的硬件机制
1.1 死循环进程如何运行?——深入理解时间片
在多任务操作系统里,我们经常碰到、甚至亲手写过死循环程序。一个不加任何限制的死循环进程,会不会死死霸住CPU,把整个系统拖到瘫痪,让其他进程全都动弹不得?
答案很干脆:绝对不会。
1.1.1 现代操作系统如何实现公平调度
一个进程被加载到CPU上开始跑的时候,它可没有无限期独占的特权。现代操作系统(比如 Linux)普遍采用时间片(Time Slice)轮转的调度机制,说白了,就是给每个进程发一张“限时体验券”。
-
单次运行受限:一个进程跑得再欢,一个时间片用完,就必须刹车停下。这张体验券过期了,没得商量。
-
重回队列排队:被没收CPU使用权的进程,会被操作系统拽回运行队列的末尾,乖乖排队,等下一次再轮到它。前面那么多人排着,插队?门都没有。
-
保护系统响应:靠着这种高频率的轮转切换,哪怕某个进程内部是个死循环,操作系统也能保证其他进程分得到CPU资源,绝不会让别的进程被活活卡死。
1.2 CPU寄存器与进程临时状态
进程在CPU上运行的时候,要干的事可不少,算术运算、逻辑判断、内存访问,一个都少不了。这一忙起来,就牵扯出操作系统里一个极其核心的概念:寄存器。
1.2.1 寄存器的诞生背景与核心作用
我们的PCB被调度上CPU之后,CPU的控制器就得按照这个进程的执行状态,去取它对应的代码和数据。问题来了,这些从内存里抓出来的数据,以及运算过程中不断产生的新数据,该往哪儿放?
临时存储的刚需:这些数据变化得极其频繁,而且速度要求高得离谱。内存虽然容量大,但读写速度跟CPU比起来简直像骑自行车追高铁,根本喂不饱CPU的节奏。所以,必须给 CPU 配一个极速的临时存储场所。于是,寄存器就诞生了。
寄存器的定义:寄存器就是CPU内部自带的一小块极速存储空间,专门用来存放正在运行的进程的临时数据,变量值、指令地址、状态标志等等,全在这里暂住。它离CPU最近,读写速度最快,是整个冯诺依曼体系里最顶尖的存储单元。
1.2.2 常见核心寄存器分类
寄存器这个大家族,根据硬件架构的不同(x86、ARM各有各的家底),成员数量和具体型号会有些出入。但不管哪家,几类核心角色是跑不掉的:
-
程序计数器(PC/EIP):存着CPU下一条要执行的指令的内存地址。它就像一本书的书签,CPU每次翻页都靠它指路。执行完一条,它就自动指到下一条,进程的指令流就靠它稳步推进。
-
栈指针寄存器(EBP/ESP):管理函数调用栈的边界和空间。函数调用的压栈、退栈,局部变量的分配,全都围绕这两个指针展开。没有它们,函数调用链瞬间崩盘。
-
通用寄存器(EAX, EBX, ECX, EDX / RAX等):干杂活的主力军。运算过程中的操作数、临时结果,全都塞在它们几个手里。CPU做加减乘除的时候,数据基本都在这几个“口袋”里来回倒腾。
-
段寄存器(CS, DS, ES, SS, FS, GS):在寻址时负责指示不同的内存段。虽然现代操作系统大多采用平坦内存模型,段寄存器的重要性远不如早年,但它们仍然在扮演基础配置的角色,尤其FS、GS在访问线程局部存储时还是不可或缺的。
-
标志寄存器(EFLAGS):记录当前运算的状态信息。溢出没溢出,结果是正是负,有没有进位——这些微妙的状态全浓缩在它的一个个二进制位里。条件跳转指令正是靠读取这些标志位来决定下一步往哪走。
-
控制寄存器(CR0 ~ CR4):CPU的“总开关”。决定CPU工作模式、控制虚拟内存分页机制等核心行为。这部分通常只有内核才能动,普通进程碰都碰不到。
1.3 核心认知——区分“寄存器空间”与“寄存器内容”
在进入“进程上下文切换”这个硬核话题之前,有一个认知误区必须提前拆掉,否则后面越听越晕。
一句话先撂在这:寄存器本身,和寄存器里装的数据,完全是两码事。
1.3.1 寄存器——保存临时状态的高速存储空间
寄存器本身,是固化在CPU芯片内部的硬件电路。它不是软件模拟出来的东西,是实实在在的物理元器件。
关键点在于它的物理唯一性:对于一颗单核CPU来说,内部的寄存器硬件有且只有一份。不管系统里跑着一百个进程还是一千个进程,大家共用的是同一套寄存器硬件。
打个比方:寄存器就像办公桌上一个固定的公用文件盒。盒子只有一个,谁坐在桌前办公,谁就往里面放自己的文件。张三来了放张三的,李四来了放李四的。盒子没变过,变的是盒子里不同时刻装着谁的东西。
这个“盒子”和“盒子里的文件”的区分,正是后面理解上下文切换的关键。很多人第一次学进程切换时卡壳,就是栽在把这两者混为一谈。记住:盒子是硬件,只有一份;文件是数据,各有各的。
1.3.2 寄存器中的数据——程序运行时的关键状态
寄存器里装的那些数值,可从来不会安分。它们随着当前运行的进程,每时每刻都在剧烈翻腾。
数据的多份与流动:盒子(寄存器)虽然只有一个,但每个进程跑到某一行代码时,往这个盒子里塞进去的临时数据,却是各不相同的。张三的账本、李四的清单,来回换着放。
看一段简单的代码就明白了:
int a = 10; // 这一刻,寄存器里被塞进了一个 10
a = 20; // 下一秒,同一个位置被刷新成了 20
b = a; // CPU 又从寄存器里把 20 读出来,交给 b
当进程A的时间片耗完被切走,进程B接手CPU时,B会毫不客气地覆盖掉这个“文件盒”里的全部内容。于是,一个关键问题浮出水面:在进程切换的那一瞬间,怎么才能安全地保住这个唯一“文件盒”里属于各个进程的临时数据,并在它们回来时原样恢复?这就是我们下一部分要硬核拆解的,上下文切换(Context Switch)机制。
二、进程上下文与切换机制——CPU如何在多个进程之间切换
2.1 什么是进程上下文?
一个进程在分时系统里跑着跑着,时间片一到,就得被赶下台,乖乖让出 CPU。过一会儿轮转回来,再重新登台。这一下一上之间,操作系统必须做到一件事:让进程觉得它压根没离开过,接着上次的断点继续干活,中间不能有半点儿割裂。这就是“无缝衔接”的要求。
但要做到无缝,哪有那么简单。光记住它执行到哪一行是远远不够的。操作系统需要把进程执行时依赖的全部环境完整地打包记录,寄存器里滚烫的临时数据、函数调用栈的状态、打开的文件描述符、下一条指令的地址……这一整套家当,凑在一起,就是所谓的进程上下文。从逻辑上看,它描述的就是“一个进程在某个瞬间,完完整整地处于什么状态”。
2.1.1 进程上下文的核心组成
一个完整的进程上下文,拆开来看可以分为三层。这三层加起来,就是进程的全部“家当”。
-
用户级上下文:这是进程在用户空间里活动的整片天地,代码段、数据段、用户栈,还有它跟别人共享的内存区域。说白了,就是进程能直接看到的那个虚拟世界。
-
寄存器级上下文(硬件上下文):CPU 内部各个寄存器在某一瞬间的具体数值。PC 指向哪里、EFLAGS 记录了什么状态、ESP 栈顶在哪、通用寄存器里滚着哪些数,这一层直接决定了 CPU 此时此刻正在干什么。它是最“烫手”的部分,也是切换时最先抢救的对象。
-
系统级上下文:操作系统为了管理这个进程,在内核里维护的一大堆数据。进程控制块(task_struct)、页表、打开的文件描述符表、内核栈,还有信号处理状态。这些东西用户看不到,但缺一个,进程就活不下去。
进程上下文存在的意义,就是保证一件事:当进程被重新调度上台时,不光 CPU 的寄存器状态能完整恢复,连内存映射、文件访问权限、内核资源等等,也全部能退回到它被打断前的那一瞬间。不是“接着跑”这么简单,而是“原地满血复活”。
2.2 进程切换的“当兵留籍”故事
2.2.1 上下文切换为什么必须保存现场?
进程切换到底是怎么一回事?理论讲了不少,我们换一个更接地气的说法——讲个故事,你马上就通透了。这个故事里,有四个角色登场:
| 故事角色 | 对应概念 | 角色定位 |
|---|---|---|
| 学校 | CPU | 提供学习和运行的唯一场所。 |
| 导员(辅导员) | 调度器 | 决定谁进学校、谁离开、什么时候走。 |
| 你 | 进程 | 在学校里接受培养、不停切换的主体。 |
| 学籍/成绩单 | 硬件上下文数据 | 你在学校某一时刻的全部表现和状态记录,对应寄存器里的内容。 |
这个“学籍档案”是关键。学校只有一个,学生却有很多。某个学生离开了,他在校期间的一切表现、考了多少分、上了什么课、交了什么作业,全都要记在学籍档案里。等下次他再进校门,档案一调出来,直接从上次断掉的地方继续念,不会因为中间换了人、换了桌,就忘了自己是谁、学到哪了。
进程切换,内核干的正是同一件事:把离场的进程的“学籍档案”存好,把上场的进程的“档案”原样恢复。 这个存与取的过程,就是我们常说的“上下文切换”。接下来,我们就钻进内核,看看这套“档案管理”到底是怎么在寄存器层面落地的。
2.2.2 一次完整的进程切换流程
假设你大学读到大二,突然一拍脑袋,响应国家号召去当兵了。这个“当兵”的过程,拆开看就是一次活生生的进程切换。
-
去当兵(进程被剥夺CPU):你人走了,学校的位置自然就空出来了。你不能占着座位不干活,得暂时离开。
-
保留学籍(保存上下文):离开前,导员可没让你拍拍屁股就走。他把你大一、大二所有的期末成绩、学分、课程记录,一样一样打包塞进档案袋,存进学校教务系统。这个过程,就对应内核把CPU寄存器里的临时数据,PC、栈指针、通用寄存器,全部保存到进程的PCB里。
-
退役复学(重新获得CPU):两年后你退伍回来,想继续读大三。这时候你不能从大一重新读起吧?
-
恢复学籍(恢复上下文):导员从教务系统里调出你当年的档案,把成绩单重新同步到现在的教学系统里,告诉你:“你接着大三上,前面的学分都认。”你才能无缝衔接,而不是从头再来。这正是内核把之前保存的数据从PCB里取出来,重新填回CPU寄存器的过程。
核心结论一句话:一次硬件上下文的保存与恢复,就对应着一次完整的进程切换。 离场时存档,进场时读档。CPU只有一个,但靠着这套“存档读档”机制,成百上千个进程就能轮番上台,谁也不耽误谁。
2.3 硬件上下文的保存与恢复
工程实现上,进程 A 和进程 B 的轮转,靠的就是一整套高频的“存档与读档”动作。我们直接钻进寄存器层面,看看这套机制是怎么一步步走完的。
2.3.1 进程A与进程B的交替运行机制
进程A跑满一个时间片:此时CPU的寄存器们已经被A的临时数据塞得满满当当,EIP指向第100行,EAX里滚着数字10。这些数据只属于A,是它此刻的全部“家当”。时间片一到,操作系统立刻介入。它把这些硬件上下文数据一项不落地抄成一份清单,存进A专属的存储区域。然后,A带着这份归档数据回到运行队列末尾,乖乖排队等下一轮。
进程B接棒执行:B进场,毫不客气地覆盖CPU寄存器,开始在自己的时间片里忙碌。它跑完后,操作系统照样把B的上下文也抄一份清单存好,B同样回队列排队。
进程A再次被调度:轮转一圈,又轮到A了。这一次,操作系统把A之前存好的那份清单重新取出来,把里面的数据一项项写回CPU对应的寄存器里。EIP重新变回第100行,EAX重新变回10。于是,A稳稳地从上次被切断的位置继续向下跑,像是从未离开过一样。如此循环往复,存档、读档、再存档、再读档,两个进程就在这套机制里交替推进,谁也丢不下自己的进度。
这,就是硬件上下文切换的全部秘密。CPU只有一个,但每个进程都以为自己是唯一的主角。而这出戏的导演,就是那个默默存档读档的操作系统。

2.4 从内核源码理解上下文保存位置
进程被切下CPU时,那堆滚烫的寄存器数据,到底被完整地保存到了哪个角落?
2.4.1 TSS(Task State Segment)与task_struct
在早期Linux内核的设计里,硬件上下文的保存是交给一个专门的硬件数据结构去做的,TSS(任务状态段)。这块区域由CPU原生支持,专门用来盛放一个任务被切换时的全部现场信息,相当于硬件层面给每个进程预留了一个“档案保险柜”。

// 早期 Linux 内核中关于任务状态段的结构定义
struct tss_struct {
long back_link;
long esp0, ss0;
long esp1, ss1;
long esp2, ss2;
long cr3;
long eip; // 保存进程断点指令位置的寄存器数据
long eflags; // 保存标志位
long eax, ecx, edx, ebx; // 保存通用寄存器数据
long esp, ebp;
long esi, edi;
long es, cs, ss, ds;
// ...
};
“钥匙与保险箱”:在初代内核中,每个进程的task_struct内部要么直接塞进一个tss_struct,要么持有一把能通往TSS的“钥匙”(指针或段选择子)。想读取或写入硬件上下文,就靠这把钥匙去开对应的保险箱。
现代内核的演进:到新版Linux内核,为了追求更高的切换效率和跨平台的可移植性,这种强依赖硬件TSS的机制被逐步剥离和优化。内核不再把所有现场都往TSS里塞,而是直接把上下文存放在内核栈(Kernel Stack)或task_struct 的特定字段(如thread_struct)里,用轻量级的汇编指令手动完成保存与恢复。这样既摆脱了硬件束缚,又让切换速度快到极致。
2.4.2 内核中的current指针
在Linux内核源码里(比如sched.h),藏着一个出场率高到离谱的指针:
extern struct task_struct *current; // 指向当前进程结构体的指针
这个current指针,永远指向此刻正霸占着CPU的那一个进程的PCB。它就像调度器安插在台前的眼线,系统里谁正在“表演”,它一清二楚。当调度器决定切换进程时,根本不用去满世界找“现在该把谁的寄存器存起来”,答案就写在current里。它指向的那个进程,就是上下文保存的源头。把当前寄存器里滚烫的数据,从这位“在台者”身上打包抽离出来,存进它自己的档案袋,整个保存动作就有了明确的起点和终点。
所以,current不只是一个普通的指针变量。它是内核调度逻辑的中枢锚点,切进程、存现场、找归属,全靠它一指定乾坤。

2.5 衍生思考——全新进程与已调度进程有什么区别?
在整个切换生命周期里,调度器要应付两种出身截然不同的进程。它们的处境不同,切换时的处理路径也完全不同。
-
已经调度过的进程:这类进程之前上过CPU,中途被打断过。它的断点位置、寄存器数据,早就安安稳稳地存在自己的PCB或内核栈里。等下一次轮转到它,调度器只需要走一条熟门熟路的老流程——恢复上下文,把存档读出来填回寄存器,就能让它从上次断掉的地方继续跑。就像老员工复职,档案一调,直接上岗。
-
全新进程:刚被fork出来的新面孔,人生第一次排进运行队列,从来没摸过CPU的边。它的寄存器里该有什么?它该从哪一行指令开始跑?没有历史档案可以恢复,一切都要从零开始初始化。
2.5.1 两类进程的现场分别是什么?
要让调度器接手全新进程时不手忙脚乱,操作系统在PCB的设计上就早早埋好了伏笔:
struct task_struct {
/* ... 各种硬核的进程信息 ... */
long state; // -1 不可运行,0 可运行,>0 已停止
int is_running; // 状态标记位:这个进程是否已经上过 CPU
// ...
};
首次调度的“伪装术”:对于一个刚从fork诞生的全新进程,操作系统会在创建它时,在它的内核栈里手动伪造一份初始上下文环境。EIP被拨到进程的入口函数main上,通用寄存器清零或赋上初值,总之,把一个“新兵蛋子”打扮得像一个“被切下过台的老兵”。
统一调度逻辑:这份伪造的上下文,妙就妙在它跟真正被切换过的老进程的存档格式完全一致。于是,全新进程第一次登台时,调度器根本不用为它单开一条特殊通道,它直接装作自己是个“刚被切回来的老进程”,顺着同一条“恢复上下文”的代码路径,就顺顺当当地跑起来了。
新瓶装旧酒,调度器根本不用区分谁是新手谁是老手。 这种“伪造现场、复用流程”的设计,让内核的调度逻辑保持了极致的统一和简洁。
2.6 补充:分时操作系统与实时操作系统
我们前面聊的时间片轮转切换,主要服务于日常最常见的分时操作系统(Time-sharing OS),比如Linux、Windows、macOS。这类系统的核心目标是“公平”和“高吞吐”,让每个用户、每个进程都产生一种错觉:这台电脑是我一个人的。
但换到某些性命攸关的领域,比如智能驾驶的刹车控制、航天飞机的制导、工业流水线的精密控制,分时系统就玩不转了。这些场景必须请出另一位主角:实时操作系统(RTOS, Real-Time OS)。
实时性的本质,不是时间片的公平轮转,而是任务响应的确定性和硬性死线。设想一下,自动驾驶系统突然识别到前方障碍物,需要紧急制动,这时候哪怕当前进程的时间片还没耗尽,系统也必须在微秒级内强行完成上下文切换。排队?等下一轮?绝对不行,人命关天,没得商量。
最后提一句:很多通用操作系统其实也带着实时性的部分代码,但在非实时应用场景里基本派不上用场,所以编译内核时通常会被注释掉,眼不见心不烦。我们日常接触的开发,几乎不会踩到这块地。
三、O(1)调度机制——高效查找下一个运行进程

上面这张图,就是调度队列的简化示意图。一眼扫过去,可能觉得线线框框有点多,但别急着犯怵。接下来我们就一层一层把它剥开,看看这套结构到底是怎么用O(1)的复杂度,把进程调度玩得又稳又快的。
3.1 进程调度的核心目标与优先级数组
系统里同时有大量就绪进程时,调度器面临的问题其实很纯粹:下一秒,CPU 到底该翻谁的牌子?
如果所有进程不分轻重,全挤在一条长队里,调度器每次都得从头扫到尾才能挑出下一个幸运儿。进程少还好说,进程一多,这扫描开销直接爆炸。于是 Linux 掏出优先级这杆尺子,把“谁先谁后”这件事做了精细化管理。
在底层实现里,内核没有用一条无序的乱糟糟链表去硬扛,而是摆出了一个优先级数组。具体长这样:内核维护一个指针数组,比如task_struct *queue[140],数组的下标就是优先级,从0 一路排到139。每个数组元素,都是一个链表的头指针,下面挂着所有处于这个优先级的就绪进程。
你可以把它想象成一栋140层的大楼,每层住着相同“优先等级”的住户。楼层越高,优先级越高;调度器要找人,直接坐电梯上到最高的有人楼层,从那个楼层的住户名单里抽一个就行。不用从一楼挨家挨户敲门,效率自然飞起。
这套“分层挂链”的设计,正是后面O(1)调度算法能够快如闪电的物理基础。

3.1.1 优先级与调度队列的映射
优先级数组有140层,但进程不是一股脑全塞进去的。内核把进程分成两大类,分别映射到不同的楼层区间。
实时进程:优先级范围0~99,数值越小,优先级越高。这部分进程的优先级直接原样映射到多级就绪队列的0~99下标位置。实时进程不玩虚的,优先级是多少,就住哪一层,简单直接。
普通进程:优先级范围100~139,由经典的nice值动态计算而来。普通进程的 nice 值范围是 -20 到19,内核用一个极简公式把它转换成100~139的数字优先级:
Priority = Nice + 120
比如nice为-20,算出来就是100,对应普通进程里的最高优先级;nice为19,算出来就是139,对应最低优先级。计算出的数值,同样直接对应就绪队列中 100~139的数组下标。
这样一来,实时进程和普通进程被天然分隔在两个区间里,0~99归实时,100~139归普通。调度器挑人时,永远优先从实时进程的高楼层往下找,普通进程只能在后面候着。两类进程各占各的地盘,井水不犯河水。

3.2 查找下一个进程——从O(N)遍历到位图优化
优先级队列是搭好了,140层大楼,每层住着同一优先级的进程。但真正考验调度器的地方来了:怎么在眨眼之间,找到第一个“有人住”的楼层?
如果一层一层从下往上扫,那等于把140层楼梯爬一遍。进程少还好,进程多起来,每次调度都爬一遍楼,这开销谁都受不了。这就是O(N)遍历的窘境,调度器的性能瓶颈,全卡在这个“找楼层”上。于是,Linux的O(1)调度器必须使出一个狠招:用一张位图把“哪层有人、哪层没人”浓缩成一串二进制位。查找时不再逐层扫描,而是一次性定位到最高的非空楼层。从“爬楼梯”到“坐电梯直达”,这一步跨越,就是O(1)调度算法最经典的设计精髓。
3.2.1 传统顺序遍历的效率困境
假设我们手里有一个简化版的优先级队列数组:
int queue[100];
int index; // 用来指向当前查找位置

如果按最朴素的思路来,怎么从这个100个槽位的数组里,找出第一个非空的队列?大部分人的第一反应就是:写一个for循环,从下标0开始,一个接一个往后找。
这个办法不是不行,问题是它太慢了。100个槽位,最坏情况要扫99次才能命中目标。那如果队列长度不是100,而是1000、10000呢?每次调度都要把整个数组从头到尾捋一遍,这种O(N)的线性查找,放在操作系统内核里,是绝对不能容忍的。
原因很直接:进程调度是系统里最频繁的动作之一,一秒钟可能发生成百上千次。如果每次挑下一个进程都要经历一次漫长的遍历,光“找”这一步就足以把整个系统的性能拖垮。调度器的时间应该花在“切进程”上,而不是浪费在“找进程”上。

3.2.2 位图技术——快速定位最高优先级队列
为了打破O(N)的魔咒,把查找效率拉到O(1)的极致,Linux内核请出了一位神队友,位图(Bitmap)。


位图的映射原理:系统用一段连续的比特位,来一一映射对应的队列是否为空。比如,用一个长整型数组long bitmap[5];。在32位系统里,一个long占32个bit,5个就是160个 bit,轻轻松松覆盖140个优先级队列,还有富余。
0与1的状态:第0号队列里有进程在排队?好,位图的第0个bit就置成1。队列是空的?对应bit就老老实实躺成0。于是,140层大楼哪层有人、哪层没人,全被压缩进了一串二进制信号里,像极了一栋大楼的“楼层占位指示灯”。
硬件级指令查找:现在问题来了,怎么从这串bit里,瞬间揪出那个值为1的位置?这时候,现代CPU的硬件指令就派上用场了。x86架构提供了bsf(Bit Scan Forward)和bsr(Bit Scan Reverse)这类指令,它们可以在一个CPU时钟周期内,直接算出一个整数里最低位或最高位的1到底在哪一位。操作系统借助这条指令,一眨眼的功夫就能锁定哪个优先级队列里躺着进程,根本不用循环,不用遍历,真真正正的O(1)查找。
3.3 活跃队列与过期队列
活跃队列和过期队列,本质上就是两组优先级数组,统一收在运行队列(runqueue)这个结构体里。哪怕查找效率的问题解决了,传统单队列模型还藏着一个更阴险的坑:只要高优先级的进程源源不断地冒出来,低优先级的那帮兄弟就会一直排不上号,最后活活饿死在队列里。这可不是危言耸听,进程饥饿一旦发生,系统公平性就成了一纸空谈。
为了解决这个“高优先级永占 CPU,低优先级永不翻身”的困局,经典O(1)调度算法在runqueue里同时塞进了两个队列集合,活跃队列和过期队列。

3.3.1 活跃队列(Active Array)
活跃队列里躺着的,都是当前时间片还没用完的进程。它们是本轮调度周期内的“在座选手”。
调度器每次都会从活跃队列中优先级最高的那条链表里,拎出一个进程送上CPU。等这个进程跑完自己的时间片,它在这一轮的“参赛资格”就用光了,必须从活跃队列里退场。
3.3.2 过期队列(Expired Array)
活跃队列里的某个进程一旦耗尽时间片,调度器就会为它重新计算下一次的优先级和时间片,然后把它塞进过期队列里。它在那里静静等待,直到本轮调度结束,活跃队列和过期队列角色互换,新一轮角逐再次开始。
3.3.3 指针交换——快速完成队列切换
随着时间推移,活跃队列里的进程一个接一个地耗尽时间片,慢慢被搬到过期队列里。活跃队列会越来越空,最终彻底清零,内核用一个叫nr_active的计数器盯着这件事,一旦它归零,就触发那个最精妙的动作。这个动作简单到近乎“偷懒”:调度器不需要搬动任何一个进程,不需要复制任何一份数据,它只做一件事,把活跃队列和过期队列的指针互换一下。
一瞬间,曾经的过期队列原地升职,变成了新的活跃队列;而那个已经被掏空的旧活跃队列,自动降级成新的过期队列,等着承接下一轮被换下来的进程。
两个指针一交换,等于完成了一整个调度周期的交接。没有数据搬运,没有内存拷贝,只有指针的轻盈一转。这就是O(1)调度算法把“效率”二字刻进骨子里的体现。
3.3.4 O(1)调度机制完整梳理
我们把整个流程从头到尾串一遍,看看一次调度到下一次调度,到底发生了什么。
上一个进程的时间片耗尽,CPU 被暂时让出。此时,调度器会先看一眼 nr_active——它记录着当前活跃队列里还有多少进程。
-
如果nr_active > 0:说明活跃队列里还有人在等。调度器直接调用位图查找,瞬间锁定优先级最高的那个非空队列,从队头摘下下一个进程的PCB,把它挂到current指针上。然后current把这个新进程送上CPU,一切继续推进。
-
如果 nr_active == 0:说明活跃队列已经空了。这时候不用慌,调度器执行指针交换,让过期队列顶上来当新的活跃队列,新一轮调度重新开张。
一句话总结:活跃队列负责当下,过期队列负责蓄势,指针一交换,未来变现在。 整套机制没有一丝冗余,每一步都踩着O(1)的节奏,快、准、稳。
3.4 调度机制中的几个关键问题
3.4.1 新进程加入后如何参与调度?
一个新进程刚被fork出来,正排着队等上场,调度器该把它塞到哪个队列里?这个问题看似不起眼,处理不好却会影响整个系统的调度节奏。
早期做法:把新进程直接挂到过期队列的末尾。这样它就先处于一种“宏观就绪”的状态,它确实准备好了,但得等当前这一轮活跃队列的进程全部跑完,等到下一轮指针交换,才有机会真正上 CPU。这种方式保守、公平,不会干扰正在执行的这一轮调度。
不过Linux实际实现并没有把“活跃”和“过期”分得那么泾渭分明。说到底,它们只是运行队列里两个可以互换的指针,底层的队列结构是同一套。所谓“过期队列”,不过是“下一轮的活跃队列”罢了。
现代做法:现在的调度器更灵活,直接支持进程抢占。新进程到来时,不再老老实实去过期队列排队,而是按照它的优先级,当场插入到活跃队列的合适位置。如果新进程优先级够高,它甚至可以插到队前,把活跃队列里原本排在后面的进程往后挤一挤,抢占它们接下来的时间片。这样的好处是响应更快,紧急任务不必等上一整轮。
除了这两种思路,内核世界里还演化出了更多复杂的设计方案,比如用多级反馈队列逐步调整进程的优先级、对交互式进程给予额外照顾等等。这些属于进阶话题,感兴趣可以顺着“调度器设计”这条线继续往下挖。但记住一个核心原则就好:新进程的去向,取决于调度器想在“公平”和“响应速度”之间做怎样的取舍。
3.4.2 调度队列中的其他关键元素
除了活跃队列和过期队列,调度器手里还攥着几个低调但关键的数据,它们不直接决定谁先跑,却默默影响整个系统的负载平衡和繁忙程度。

cpu_load——CPU负载因子:这个指标主要服务于多CPU并发的场景。当一个新的进程呱呱坠地,调度器不会一拍脑袋随便塞给某个CPU。它会先瞄一眼各个CPU的cpu_load,看看谁正忙得不可开交,谁又比较清闲。然后,新进程会被安排到负载最轻的那颗CPU上,尽量让每颗核心都雨露均沾,避免出现“一颗忙到冒烟,几颗闲到发慌”的尴尬局面。你可以把它理解为每颗CPU的“忙碌水位计”。水位低的,先接新客人。
nr_switches——切换次数:这个字段记录的是CPU已经发生了多少次进程切换。它是一面镜子,直接反映出这颗 CPU 到底有多忙。切换次数越高,说明CPU越频繁地在不同进程之间来回倒腾,工作强度自然不低。
如果把cpu_load比作CPU的“当前压力值”,那nr_switches就是它的“累计工作量”。一个看瞬间,一个看历史,两者配合,调度器才能既顾好眼前,也心里有数。
3.4.3 进程优先级与调度算法之间的关系
现在回到我们之前留下的那个问题:为什么修改优先级,不能直接在 PRI 上动手,非要绕一道 NI 值?
(下面的 A 队列默认指活跃队列,D 队列默认指过期队列。)
如果允许直接修改PRI,调度器立刻就会陷入两难。假设一个进程正排在活跃队列(A队列)里,你突然把它的PRI从80改成了60,它在队列里的位置是不是要马上跟着变?如果变,那到底应该插回 A 队列的对应位置,还是直接扔到D队列去重新排队?无论选哪个,都别扭。如果不重新调整位置呢?更荒唐:进程的实际优先级已经是60了,人却还赖在80的位置上,队列名次和它的真实身份对不上号,调度逻辑当场就乱了。
于是,NI值被设计成了那个“缓冲层”。我们修改优先级,本质上只是改了一下NI值,不会立刻影响进程当前在队列里的位置。这个进程会继续安安稳稳地跑完这一轮,等它被换下来、要链入过期队列(D队列)的时候,调度器才根据更新后的NI值,重新计算它的新优先级,并把它安插到D队列中正确的位置上。从下一轮开始,它才真正以新的优先级参与调度。
NI 值把“想改”和“真改”拆成了两个阶段,先记账,后生效。 队列顺序不乱,调度逻辑不崩,优先级调整变得柔和又有序。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注三连支持。你的每一个反馈,都是我继续硬核输出的最大动力。我们下篇见。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)