进程状态与切换:CPU 如何在任务间切换
打开你的电脑,看一眼任务管理器或 top:几百个进程赫然在列。可你的 CPU 也许只有 8 个核心——同一时刻物理上最多只能真正执行 8 条指令流。那剩下几百个进程是怎么回事?它们看上去全都"在运行",这是怎么做到的?
答案是一个字:轮换。CPU 飞快地在这些进程之间切来切去,每个进程跑一小会儿就被换下,让位给下一个。切换快到你完全察觉不到,于是产生了"大家都在同时运行"的错觉。
但要让这套轮换可靠运转,操作系统必须解决两个问题:第一,它得随时知道每个进程现在能不能跑(有的在等 I/O,强行让它上 CPU 也是干等);第二,把一个进程换下、另一个换上时,得能完整保存和恢复现场,否则进程再回来时就乱套了。前者靠"进程状态",后者靠"上下文切换"。
上一篇我们讲了 PCB 里有个"状态"字段。这一篇我们就把这个字段如何流转、以及切换那一刻 CPU 和内核到底做了什么讲透,再回答:为什么要设计这几种状态、为什么切换值得我们如此在意。
一、先认识进程的几种基本状态
要理解轮换,先得知道一个进程在任意时刻处于什么"身份"。操作系统给每个进程贴了一个状态标签,最核心的是三种:
- 运行态(Running):进程正占着 CPU 执行指令。物理上有几个核,最多就只有几个进程处于这个状态。
- 就绪态(Ready):进程万事俱备,只差 CPU。它需要的资源都到位了,只是 CPU 暂时没空,它在就绪队列里排队等着被调度。
- 阻塞态(Blocked / Waiting):进程在等待某个事件,比如等磁盘读完、等网络数据到达、等一把锁。在事件发生前,就算把 CPU 给它也没用,所以它干脆主动退出竞争,不参与 CPU 调度。
区分就绪和阻塞,是这一节的关键:就绪是"能跑但没轮到",阻塞是"轮到也跑不了,因为在等别的东西"。把这两者混为一谈,整个状态机就乱了。
除了这三个核心态,通常还有两个边界状态:新建态(进程正在被创建、PCB 还没就绪)和终止态(进程执行完毕、等待资源回收,就是上一篇的僵尸进程)。
二、机制:状态之间如何流转
光知道有哪些状态还不够,关键是它们怎么互相转换、由什么触发。我们用一张状态图把流转关系画出来:
逐条解读这些迁移——每一条都由一个具体事件触发:
- 就绪 → 运行:调度器从就绪队列里选中它,把 CPU 交给它。这是唯一进入运行态的途径。
- 运行 → 就绪:注意,进程不是只有"主动让出"才下 CPU。时间片用完或来了更高优先级的进程把它抢占,它就被踢回就绪队列——它并没出错,随时能再跑,只是要重新排队。
- 运行 → 阻塞:进程主动发起一个会等待的操作(如读磁盘、等锁),自知短时间内进行不下去,于是主动让出 CPU 进入阻塞,把 CPU 留给别人。
- 阻塞 → 就绪:它等的事件发生了(磁盘数据到了、锁释放了),它重新具备运行条件,但不会直接上 CPU,而是回到就绪队列重新排队。
这里有个最容易错的点:阻塞结束后不是直接回到运行态,而是先回就绪态。因为 CPU 此刻多半正被别的进程占着,它必须重新排队等调度。"阻塞→运行"这条迁移是不存在的。
把这张图记牢,你再看任何并发问题,都能问自己:这个进程现在该处于哪个状态?它卡住了,是卡在就绪(等 CPU,说明 CPU 忙)还是阻塞(等事件,说明在等 I/O 或锁)?这一问,排障方向立刻清晰。
三、机制核心:上下文切换那一刻发生了什么
状态流转的背后,是 CPU 在进程间的物理切换。当"运行进程 A → 运行进程 B"发生时,内核要做一件精细活——上下文切换(context switch)。
所谓"上下文",就是上一篇说的进程执行现场:CPU 里那一组寄存器的值,尤其是程序计数器(PC,执行到哪了)、栈指针(SP)、各通用寄存器。切换的完整动作是:
- 触发:发生了一个要切换的事件(时间片到、A 主动阻塞、被抢占),CPU 陷入内核;
- 保存 A 的现场:内核把 CPU 当前所有关键寄存器的值,原样存进 A 的 PCB 里的现场保存区;
- 更新状态:把 A 的状态改成就绪或阻塞,挂到对应队列;
- 调度选 B:调度器从就绪队列挑出下一个进程 B;
- 切换地址空间:把页表基址寄存器(x86 的
CR3)换成 B 的,于是 CPU 看到的内存映射变成了 B 的地址空间(这步还会导致 TLB 部分失效); - 恢复 B 的现场:把 B 的 PCB 里之前存的寄存器值load回 CPU;
- 继续执行:当 PC 被恢复成 B 上次的值,CPU 就自然地从 B 上次中断的地方接着跑下去了。
上下文切换的本质,是"把 A 的现场存进它的 PCB,再把 B 的现场从它的 PCB 取出来装回 CPU"。PCB 就是现场的存放处,这正是上一篇 PCB 存在的意义之一。
整个过程对进程 A、B 都是透明的——A 完全不知道自己被换下又换上过,它只觉得自己一直在连续执行。这种"无感"正是抽象的威力。
四、为什么要设计"阻塞态"这个状态
机制讲完,开始回答设计动机。先问:为什么要单独搞一个阻塞态?只有"运行"和"就绪"不行吗?
设想没有阻塞态:一个进程发起磁盘读取,磁盘要花几毫秒(对 CPU 是"几百万个时钟周期"那么漫长)。如果它仍然待在就绪/运行的竞争里,调度器可能反复把 CPU 分给它,可它只能干等数据、白白空转,CPU 时间全浪费了。
阻塞态的设计,就是让这种"等事件"的进程主动退出 CPU 竞争:
阻塞态的意义在于把"在等待的进程"从调度候选里摘出去,让 CPU 这段时间能去服务真正能干活的就绪进程。这是 CPU 利用率的根本保障。
这直接解释了一个对后端极重要的现象:I/O 密集型 vs CPU 密集型。I/O 密集型程序(如普通 Web 服务)大量时间在阻塞态等网络/磁盘,CPU 经常空着,所以可以跑远多于核数的并发;CPU 密集型程序(如视频编码)一直在运行/就绪态抢 CPU,并发数超过核数只会徒增切换开销。是否会进阻塞态,决定了你该怎么设计并发模型。
五、为什么需要"可抢占"——时间片的意义
再看"运行→就绪"里那条"时间片用完/被抢占"。为什么不让一个进程一直跑到它自己愿意让出为止?
因为如果靠进程自觉让出,一个写了死循环、或者干脆恶意不让出的进程,就能永久霸占 CPU,整个系统卡死。早期的"协作式调度"就吃过这个亏。
现代系统用抢占式调度解决:内核借助时钟中断——硬件每隔一小段时间(比如几毫秒)就强制打断 CPU 一次,把控制权夺回内核。内核借这个机会检查:当前进程时间片用完了吗?有没有更该跑的进程?需要的话就强制切换。
时钟中断 + 时间片,是操作系统从进程手里"夺回" CPU 控制权的硬件手段。没有它,多任务的公平和系统的稳定都无从谈起。
这也呼应了第二篇讲的权限边界:开关中断是特权指令、时钟中断只能由内核处理——正是因为这套机制握在内核手里,用户进程才无法逃脱被抢占的命运。
这里还要厘清一个常被混淆的点:主动让出与被动抢占是两回事。一个进程进入阻塞态(等 I/O、等锁)是它主动让出 CPU——它自知干不下去了,让位是它自己的选择;而时间片耗尽、被高优先级进程抢占,是内核强制把它从运行态踢回就绪态——它没出任何问题、也不想走,只是被规则拽下了台。前者对应"运行→阻塞",后者对应"运行→就绪",触发者和后续去向都不同。把这两条路径分清,你才能准确判断一个下了 CPU 的进程到底去了哪、为什么去:是它在等什么(阻塞),还是单纯没抢过别人(就绪)。这个区分在看调度延迟、分析"为什么我的线程迟迟没被执行"时尤其关键。
六、为什么上下文切换值得我们如此在意:它的成本
切换很有用,但绝不免费,而且成本比直觉的大。它的开销分两部分:
- 直接成本:保存/恢复寄存器、切换页表、内核调度逻辑本身的执行时间。这部分通常在微秒级。
- 间接成本(往往更大):切换后,新进程的数据不在 CPU 缓存里(缓存被前一个进程"污染"了),切换地址空间还会让 TLB 部分失效。于是切换后的一小段时间,新进程频繁缓存未命中、TLB 未命中,跑得比平时慢——这部分隐性成本经常被低估。
上下文切换真正贵的地方,往往不是那几微秒的直接开销,而是切换后缓存和 TLB 失效带来的"余震"。这就是为什么"切换过于频繁"会显著拖垮吞吐量。
这把"共享 CPU"的代价摆到了台面上:让多个任务共用 CPU 很美好,但每次切换都要交"过路费"。如果切换太频繁(比如线程开太多、锁竞争激烈导致频繁阻塞唤醒),系统会把大量时间花在切换而非有效计算上——这种现象叫"颠簸",是高并发服务性能塌方的常见原因。
七、和工程实践 / 后端开发的联系
这套状态与切换机制,是后端性能分析的核心模型:
- 看懂
vmstat的cs列:cs就是每秒上下文切换次数。这个数异常高,往往意味着线程过多或锁竞争严重——大量线程在"阻塞↔就绪"间反复横跳,CPU 忙着切换而非干活。 - 线程池大小不是越大越好:线程数远超核数时,就绪队列里挤满线程,调度器疯狂切换,间接成本吃掉收益。CPU 密集型任务,线程数贴近核数往往最优。
- 区分两种"卡":服务变慢时,先判断进程是卡在就绪(CPU 不够,看 CPU 使用率是否打满)还是阻塞(在等 I/O/锁,看
iostat、看锁等待)。两者的解法完全不同——前者要加 CPU 或减负载,后者要优化 I/O 或减少锁竞争。 - 理解协程为什么轻:协程(goroutine 等)在用户态切换,不经历内核态上下文切换的完整开销,也较少污染 TLB,所以能开几十万个。它本质是在规避这一篇讲的切换成本——这正是下一组讲线程模型的铺垫。
学习这一部分最容易踩的坑
1. 把阻塞态当成"程序卡死了"
阻塞是进程主动、正常地在等某个事件(I/O、锁),事件一到就会被唤醒回就绪。它不是 bug、不是卡死,而是让出 CPU 的正确行为。把正常阻塞当成故障,会找错方向。
2. 以为阻塞结束后直接回到运行态
阻塞结束只让进程回到就绪态,要重新排队等调度,因为 CPU 多半正被别人占着。不存在"阻塞→运行"这条直达迁移。记错这点,状态机就理解偏了。
3. 以为"运行→就绪"一定是发生了 I/O
进程从运行被踢回就绪,最常见的原因是时间片用完或被高优先级进程抢占,跟 I/O 无关。I/O 会让它进阻塞态,不是就绪态。这两条迁移路径不要搞混。
4. 低估上下文切换的成本,盲目堆线程
切换的间接成本(缓存/TLB 失效)常被忽略。以为"多开线程就能更快",结果切换开销吃掉一切。看 vmstat 的 cs、按核数规划并发,才是正路。
总结
进程状态和上下文切换,回答了"一个 CPU 为什么能服务几百个任务"这个核心问题:靠状态标签管理每个进程能否运行,靠飞快的现场保存/恢复在它们之间轮换。但它也提醒我们——共享 CPU 并不免费,调度与切换本身就是成本。
- 进程核心有三态:运行(占着 CPU)、就绪(能跑但没轮到)、阻塞(在等事件,跑也没用);
- 状态迁移都由具体事件触发,关键是"阻塞结束先回就绪、要重新排队",且不存在"阻塞→运行";
- 上下文切换 = 把 A 的现场存进 A 的 PCB、把 B 的现场从 B 的 PCB 装回 CPU,对进程透明;
- 阻塞态的设计是为了把等待者移出竞争,保住 CPU 利用率,也决定了 I/O 密集与 CPU 密集的并发策略不同;
- 抢占式调度靠时钟中断 + 时间片,从进程手里夺回 CPU,保证公平与稳定;
- 切换真正贵在缓存/TLB 失效的间接成本,切换过频会拖垮吞吐,线程数应按核数规划。
当你能默画那张状态图、说清每条迁移由谁触发,并理解 vmstat 里 cs 飙高意味着什么,这部分就真正属于你了。接下来我们进入线程——看看人们如何在"一个进程"内部,再切分出多条能被独立调度的执行流。
参考资源:
- 《现代操作系统》(Andrew S. Tanenbaum)进程与线程章节
- 《操作系统导论》(OSTEP)调度与上下文切换章节
- 《深入理解计算机系统》(CSAPP)并发与异常控制流章节
- Linux 进程状态说明 - man ps