一,进程切换与CPU寄存器原理
1.1死循环进程如何运行----深入理解时间片
在多任务操作系统中,我们会遇到死循环程序,一个不加限制的死循环进程,并不会死死卡住CPU从而让系统瘫痪,我们始终认为理所应当,但是这个到底是如何实现的,且看下文
1.1.1现代操作系统的公平调度
当我们的进程被加载到CPU并开始运行,它并不能无限独占CPU资源,现代操作系统采用时间片的轮转调度机制:
单次运行受限:一个进程在跑的时候,跑完一个制定的时间片就必须停下来
重回队列排队:被剥夺CPU使用权的进程会被操作系统重新放入运行队列的末尾进行排队,等待下一次轮到它
保护系统响应:依靠这种高频的轮转切换,即使某个进程内部是死循环,操作系统也能保证其他进程活动CPU资源,绝不会让其他进程卡死
1.2CPU寄存器与临时数据
当进程在CPU上运行时,它需要执行大量的运算以及内存访问,这就引出了操作系统中的寄存器
1.2.1寄存器诞生背景与核心作用
我们的PCB被加载进CPU后,CPU的控制器需要根据进程的执行状态,去取得该进程所指向的代码和数据
临时存储的刚需:CPU从内存中取出来后,或者在运算过程中产生的各种高频变化的数据必须有一个极度高速的临时存储场所
寄存器的定义:寄存器就是CPU内部临时存储空间,专门用来存储正在运行的进程的临时数据(变量值,指令地址,状态标志等)
1.2.2常见寄存器分类
在不同硬件架构(如x86,ARM)以及不同的系统,其寄存器的个数和具体类型会有不同,但通常会包含以下几类核心寄存器:
程序计数器(PC / EIP):存放 CPU 即将执行的下一条指令的内存地址。
栈指针寄存器(EBP / ESP):维护管理函数调用栈的边界与空间。
通用寄存器(如 EAX, EBX, ECX, EDX / RAX…):用于暂存各类运算过程中的操作数和临时结果。
段寄存器(CS, DS, ES, SS, FG, GS):在寻址时用于指示不同的内存段。
标志寄存器(EFLAGS):记录当前运算的状态信息(如是否溢出、是否为负数、进位等)。
控制寄存器(CR0 ~ CR4):决定 CPU 的工作模式以及控制虚拟内存分页等控制核心。
1.3核心结论,区分“空间”与“内容”
为了彻底理解后续的“进程上下文切换”,我们需要理解一个误区
寄存器≠寄存器里面的数据
1.3.1寄存器(盒子/空间)
寄存器本身是固化在CPU内部的硬件电路
物理空间的唯一性:对于一个单核CPU而言,其内部的寄存器硬件只有一个
如果要类比的话,它就像是办公桌上的一个固定的公用文件盒
1.3.2寄存器里面的数据(内容/答案)
寄存器内部填充的数值,是随着当前运行的进程不断发生剧烈变化的
数据的多份与流动:虽然寄存器只有一份,但是每一个进程在运行到某一行代码时,它往盒子里面塞进去的账单数据是变化的,多份的
int a = 10; // 此时寄存器中临时存入 10 a = 20; // 寄存器中的数据被刷新覆盖为 20 b = a; // 从寄存器中读取 20 赋给 b当进程A的时间片到了被切走,进程B进来运行时,B就会覆盖这个文件盒里面的内容,因此,在进程切换时,如何安全地保护和恢复这一个文件盒里面的多份临时数据,就是我们下一部分要探讨的机制
二,进程上下文切换机制
2.1什么是进程上下文(Process Context)?
当一个进程在分时操作系统中运行,暂停,再恢复时,操作系统必须确保它能无缝衔接,这就需要引入进程上下文概念,从逻辑层面来看,它描述的是一个进程执行时所需的整个环境、
2.1.1进程上下文的核心组成
一个完整的进程上下文主要由以下三个层次构成
用户级上下文:包含进程虚拟地址空间环境,如代码段,数据段,用户栈以及共享内存等
寄存器级上下文(硬件上下文):即CPU内部各个寄存器(PC,EFLAGS,ESP,通用寄存器等)在某一瞬间的特定数值,这部分直接决定了CPU当前的运行状态
系统级上下文:操作系统为了管理该进程而维护的各种内核数据,包括进程控制块(PCB,task_struct),页表,打开的文件描述符表,内核栈以及信号处理状态等
作用:进程上下文的存在,保证了进程在被重新调度时,不仅CPU状态(寄存器)能完全恢复,连同内存映射,文件访问权限和内核资源等都能退回到中断发生前的那一瞬间
2.2进程切换的“当兵留籍”类比
2.2.1角色映射表
学校 → CPU(提供学习和运行的场所)
导员(辅导员) → 调度器(决定谁来、谁走、什么时候走)
你 → 进程(在学校里接受培养的主体)
学籍 / 成绩单 → 硬件上下文数据(你在学校某一时刻的所有表现和状态记录,即寄存器里的内容)
2.2.2一次完整的切换流程
假设你在大学读到大二,突然想响应国家号召去当兵:
1.去当兵(进程被剥夺 CPU):你必须暂时离开学校。
2.保留学籍(保存上下文):离开前,导员把你大一和大二的所有期末成绩、学分等信息打包塞进档案袋,存入学校的教务系统。这就是把 CPU 寄存器里面的临时数据保存起来的过程。
3.退役复学(重新获得 CPU):两年后你当兵回来了,想要继续读大三。
4.恢复学籍(恢复上下文):导员从档案袋里调出你当年的成绩单,重新同步到当前的教学系统中,你才能无缝衔接大三的课程,而不是从大一重头读起。这就是把保存的数据重新恢复到 CPU 寄存器里的过程。
2.3硬件上下文的具体保存与恢复实现
在具体的工程实现中,多进程的轮转正是通过高频的“清音留存”来实现的
2.3.1进程A与进程B的交替运行机制
进程A运行满一个时间片:
此时CPU寄存器(如EIP = 100, EAX = 10)存满了A的临时数据
操作系统接入,将这些硬件的上下文保存到一份清单到A的专属存储区域,随后A带着它的数据回到运行队列中排队
进程B开始执行:B 进驻 CPU,覆盖硬件寄存器,并在其时间片内运行。B 运行满一个时间片后,同样把自己的硬件上下文保存一份清单,回队列排队
进程 A 再次被调度:操作系统把 A 刚才记录在清单中的数据重新写入/恢复到 CPU 对应的寄存器当中。此时,CPU 的EIP重新变回100行,EAX重新变回10。A 就能完美地从历史位置和状态继续向下运行,由此周而复始。
2.4内核源码思考:上下文存在哪里?
2.4.1任务状态段(TSS ,Task State Segment)与task_struct
在早期Linux内核设计中,硬件上下文是依赖特定的硬件数据结构TSS(任务状态)来进行管理的
// 早期 Linux 内核中关于任务状态段的结构定义 struct tss_struct { long back_link; long esp0, ss0; long esp1, ss1; long esp2, ss2; long cr3; long eip; // 报错 A 进程断点指令位置的寄存器数据 long eflags; // 保存标志位 long eax, ecx, edx, ebx; // 保存通用寄存器数据 long esp, ebp; long esi, edi; long es, cs, ss, ds; // ... };在初代内核中,每一个进程的PCB内部包含了一个struct tss_struct,或者包含了一个可以获取该硬件上下文的“钥匙”,通过它去TSS中获取或写入对应硬件上下文内容
在现代Linux内核中,为了追求更高的切换效率和架构平台的可移植性,这种强依赖硬件的TSS机制已经被剥离和优化了,转而在内核栈或 task_struct内的特定字段(如thread_struct)中通过轻量级汇编指令进行手动保存
2.4.2内核中的current指针
在Linux内核源码中(如sched.h)中,有一个非常高频使用的全局/宏变量
extern struct task_struct *current; // 当前进程结构指针变量这个指针永远指向当前正占用CPU运行的的那个进程PCB,当调度器决定切换进程时,就是以current指针指向的进程作为源头,把当前寄存器里的值打包抽离出来的
2.5衍生思考:全新进程和已调度过的进程
在整个切换生命周期中,调度器需要面对两种形态的进程
1.已经调度过的进程:之前运行过,其中途断电和寄存器数据已经保存在PCB或内核栈中,再次调度时,直接进行恢复上下文流程
2.全新进程:刚被fork出来,从来没有在CPU上面运行过
2.5.1如何区分?
为了让调度器不至于接手一个新进程时手忙脚乱,操作系统在设计PCB时通常会引入一个状态标记:
struct task_struct { /* ... 各种硬编码的进程信息 ... */ long state; // -1 unrunnable, 0 runnable, >0 stopped int is_running; // 状态标记位:是否属于已经运行过的进程 // ... };首次调度的伪装:对于一个全新进程,操作系统fork在创建时,会在内核栈为其手动伪装一个初始上下文环境,这样,一个全新进程在第一次被调度时,就能假装是一个老进程,可以通过统一的恢复上下文顺利跑起来
2.3补充:分时操作系统和实时操作系统
我们上述讨论时间片轮转切换,主要适用于我日常接触的分时操作系统,如Linux,Windows,macos,核心目的是公平与高并发吞吐,让每个用户,进程都以为自己在独占计算机
在某些领域,如刹车控制,工业自动化流水线,则必须使用实时操作系统(RTOS,Real-Time OS)
实时性的本质:实时操作系统不强调绝对的时间片轮转公平,其强调的是认为响应的确定性与极限死线,如果一个高优先紧急任务到达,即便当前进程的时间片没到,系统也必须在微秒级内强行完成上下文切换,绝对不能允许排队等待
大部分操作系统都支持实时操作系统,但是这一部分基本用不到,所以一般在编译的时候都被注释掉了
三,O(1)调度算法与调度队列
3.1进程调度的核心诉求与优先级数组
当系统中存在大量处于就绪状态的进程时,调度系统面临着CPU分配给谁的问题
为了满足不同进程对响应速度的差异化需求,Linux引入了优先级的概念。在底层显示中,内核并没有采用单一的无序链表,而是设计了优先级数组。例如,内核会维护一个指针数组(如task_struct *queue[140]),数组的下标直接对应着进程优先级(0-139),每一个数组元素都是一个链表的头指针,挂载所有优先级下的就绪进程
3.1.1优先级的映射方法
实时进程:
优先级范围:0-99(数值越小,优先级越高)
映射规则:这部分进程优先级直接原样映射到多级就绪队列的0-99下标位置
普通进程:
优先级范围:100-139(由经典的nice值动态计算得来)
3.2查找下一个进程:从O(N)遍历到位图优化
有了优先级队列,当我们想要挑选下一个最高优先级进程来运行时,就需要去查找这个队列数组中,哪一个是第一个的非空队列,这就是经典的Linux调度算法O(1)调度器
3.2.1传统顺序遍历困境
按照最朴素的思维:
我们写一个for循环,按顺序遍历这个数组
有性能瓶颈:在Linux系统中,我们的长度是140,假如长度是10000呢?况且我们也可以在140的基础上进行优化,使其达到更高的性能
3.2.2位图技术
为了打破这个性能的魔咒,实现更高的效率,Linux内核引出了位图(bitmap)结构来进行辅助查找
位图的映射原理:系统使用一段连续的比特位来映射对应队列是否为空。例如使用一个长整型数组long bitmap【5】(在32位系统中,5*32 = 160个bit,足以覆盖140个优先级队列)
0与1的状态:如果第0个队列有进程排队,那么位图的第个bit就被置为1,如果队列为空,对应的bit就是0
硬件级查找:如果想知道1对应的队列呢?现代CPU提供了专门的硬件指令(如x86的bsf/bsr指令),可以在一个CPU时钟周期内,直接计算出一个整数中最低位或最高位1在哪里,这样就能实现高性能的查找
3.3活跃队列与过期队列
活跃队列和过期队列都在一个数组里面
即使解决了查找效率问题,传统的单队列模型还会遇到一个问题:高优先级的进程如果不断产生,低优先级的进程就会遭遇饥饿,永远分不到时间片,为了解决这个问题,调度算法引入了两个核心的队列集合
3.3.1活跃队列(Active Array)
这里面存放的是当前时间片还没有用完的进程
调度器每次都从活跃队列的最高优先级链表中取出一个进程投入运行
当这个进程在CPU上跑完它的时间片后,它就失去了继续待在活跃队列的资格
3.3.2过期队列(Expired Array)
当活跃队列中的某个进程耗尽了时间片,调度器会为它重新计算下一次的优先级和时间片,然后将其直接放入过期队列中
3.3.3指针交换
随着时间推移,活跃队列里面的进程越来越少,最终会被清空。(使用一个nr_active来记录进程数量,为0时交换指针)此时,过期队列里则装满了重新洗牌后等待运行的进程
当活跃指针为空的瞬间,调度器就对活跃队列和过期队列两个指针进行交换
3.3.4完整流程
上一个进程的时间片结束,系统会先通过nr-active查看当前进程的数量,若数量>0,则调用bitmap查找下一个进程所在位置
调取queue找到进程位置,然后将头部的结点取出,将其PCB链接到current指针指向
current指针将其指向的进程加载到CPU中,使用调度算法,CPU开始运行
3.4相关问题
3.4.1新进程来了怎么办
之前的做法:将新进程链接到过期队列里面,这个时候就是操作系统宏观的就绪状态。但是Linux没有分的这么明确,活跃队列和过期队列都是运行队列
现代的一些做法:进程支持抢占,就是说,把新的进程按照优先级插入到活跃队列中抢占了活跃队列之后的时间
3.4.2调度队列其他元素
cpu_loadCPU负载因子:主要服务于多CPU高并发的情况,一个新进程来的时候,要先通过cpu_load查看多个CPU的负载状态,找到负载低的cpu插入
nr_switches切换次数:衡量cpu有多忙的指标