应用明明没有死循环,CPU 使用率却居高不下;线程数量不多,延迟却突然抖动;机器还有空闲核心,任务为什么仍然排队?这些现象都绕不开三个基础问题:进程如何隔离资源,线程如何共享执行上下文,内核又如何把可运行任务分配给有限的 CPU。

本文从操作系统的运行模型出发,串起进程、线程、上下文切换、调度策略和 Linux 排障方法。文中的“任务”泛指内核能够调度的执行实体,在 Linux 中通常对应一个线程。

1. 进程与线程分别解决什么问题

进程首先是一个资源容器。每个进程拥有独立的虚拟地址空间,并持有文件描述符、信号处理方式、工作目录和安全凭据等资源。两个普通进程即使使用了相同的虚拟地址,也会通过各自的页表映射到不同的物理页,因此一个进程通常不能直接读写另一个进程的内存。

线程则是进程内部的执行单元。同一进程中的线程共享代码段、堆、全局变量和打开的文件,但各自拥有:

  • 程序计数器,用于记录下一条将要执行的指令;

  • 寄存器现场;

  • 独立栈,用于保存函数调用帧和局部变量;

  • 调度状态、优先级和线程局部存储。

两者的核心差异如下:

维度 进程 同一进程内的线程
地址空间 默认相互隔离 共享
数据交换 需要管道、套接字、共享内存等 IPC 可直接访问共享变量
故障影响 通常局限在当前进程 非法内存访问可能终止整个进程
创建与切换成本 通常较高 通常较低,但并非没有成本
资源所有权 持有地址空间和系统资源 主要持有执行现场

“线程一定比进程快”并不严谨。实际成本取决于是否切换地址空间、缓存是否失效、是否跨 CPU 迁移以及内核实现。工程上更重要的取舍是:需要强隔离时选择多进程,需要低成本共享大量状态时选择多线程。

2. 一次程序启动后发生了什么

在类 Unix 系统中,创建新进程常见的组合是 forkexec

  1. fork 创建调用进程的子进程。

  2. 父子进程起初拥有内容相同的虚拟地址空间。

  3. 内核通过写时复制避免立即拷贝全部物理内存。

  4. exec 用新的程序映像替换当前进程的地址空间。

  5. 调度器在合适的时机让新任务获得 CPU。

创建线程不需要建立全新的资源容器。Linux 的 clone 系列系统调用可以控制父子任务共享哪些资源,用户态线程库通常在此基础上实现线程 API。

程序并不是创建后就持续占有 CPU。任务会在几类状态之间转换:

                 时间片用完 / 被抢占
        +--------------------------------+
        |                                v
新建 -> 就绪 --------调度--------> 运行 --------> 终止
        ^                           |
        |                           | 等待 I/O、锁或事件
        +-------- 事件完成 <--------+
                    阻塞
  • 就绪:具备运行条件,正在运行队列中等待 CPU;

  • 运行:当前正在某个 CPU 上执行;

  • 阻塞:等待 I/O、锁、定时器或其他事件,不参与 CPU 竞争;

  • 终止:执行结束,父进程尚未回收退出信息时可能短暂成为僵尸进程。

Linux 工具中的状态字符更加细分。例如 R 表示正在运行或可运行,S 表示可中断睡眠,D 通常表示不可中断睡眠,Z 表示僵尸状态。D 状态任务常在等待块设备或网络文件系统,不能简单归因于“CPU 不够”。

3. 用户态与内核态不是进程状态

用户态和内核态描述的是 CPU 当前的特权级,而就绪、运行和阻塞描述的是任务的调度状态,两组概念不能混为一谈。

应用调用 readwritefutex 时会通过系统调用进入内核态。中断和异常也会让 CPU 转入内核执行相应处理程序。处理完成后,CPU 可以返回原线程,也可能因调度决策转去执行另一个线程。

一次系统调用不一定引发上下文切换。例如读取的数据已经在页缓存中,read 可能很快返回;若数据尚未就绪,线程才可能进入阻塞状态并让出 CPU。反过来,上下文切换也不一定由系统调用直接触发,时间片到期、高优先级任务唤醒和 CPU 负载均衡都可能导致切换。

4. 上下文切换到底保存了什么

当 CPU 从任务 A 切换到任务 B 时,内核需要保存 A 的寄存器、程序计数器和栈指针等执行现场,再恢复 B 的现场。若两个任务属于不同进程,还可能切换页表相关状态。

真正昂贵的不只有保存和恢复寄存器,还包括间接成本:

  • CPU 流水线中的工作被打断;

  • 新任务的指令和数据可能不在当前 CPU 缓存中;

  • 地址空间变化可能降低 TLB 命中率;

  • 任务迁移到另一个 CPU 后,缓存局部性进一步变差;

  • 大量线程竞争同一把锁,会形成唤醒、争抢、再睡眠的循环。

因此,线程数超过 CPU 核数并不必然有问题。I/O 密集型线程在等待期间会让出 CPU,适度超配能够提高利用率;CPU 密集型任务若远多于核心数,通常只会增加排队和切换成本。

5. 调度器要优化哪些目标

调度没有对所有工作负载都最优的单一算法。常见目标包括:

指标 含义 典型关注场景
CPU 利用率 CPU 处于有效工作的比例 批处理、计算任务
吞吐量 单位时间完成的任务数 离线作业、服务端请求
周转时间 从任务到达到完成的总时间 批处理
等待时间 在就绪队列中等待的时间 高负载系统
响应时间 到达后首次得到响应的时间 交互式应用
公平性 各任务是否合理获得 CPU 多租户、桌面系统

经典算法适合帮助理解权衡:

先来先服务 FCFS

按到达顺序执行,实现简单,但一个长任务可能让许多短任务等待,产生“护航效应”。

最短作业优先 SJF

优先运行预计执行时间最短的任务,在执行时间可准确预知时能降低平均等待时间,但现实中通常只能估计,还可能让长任务长期饥饿。

时间片轮转 RR

每个任务获得一个时间片,时间片用完后回到队尾。时间片太大时接近 FCFS,太小时则会产生大量上下文切换。它适合解释分时系统如何改善交互响应。

优先级调度

高优先级任务先运行,但低优先级任务可能饥饿。逐步提高长期等待任务的优先级,即“老化”,可以缓解这个问题。

这些算法是模型,不应直接套作对现代 Linux 调度器的完整描述。现代调度器还要处理多核负载均衡、CPU 亲和性、NUMA、实时任务、能耗和控制组配额等问题。

6. Linux 调度中的几个实用概念

普通任务和实时任务使用不同调度类别。普通业务进程通常采用公平调度策略;实时策略如 SCHED_FIFOSCHED_RR 拥有更强的抢占能力,配置不当可能让普通任务长期得不到 CPU,不应仅为“降低延迟”就随意启用。

nice 值

普通任务可以通过 nice 值表达相对权重。nice 值越低,通常能获得越多 CPU 时间。它影响的是竞争时的相对份额,并不等价于预留固定百分比的 CPU:

nice -n 10 ./batch-job
renice 5 -p 12345

CPU 亲和性

CPU 亲和性限制任务可以在哪些 CPU 上运行。固定 CPU 有时能改善缓存局部性,但也可能阻碍负载均衡:

taskset -cp 0-3 12345

除非已经通过性能数据确认迁移是瓶颈,否则不要把绑核当作通用优化。

容器 CPU 限额

容器内看到多个 CPU,不代表它能持续使用全部算力。cgroup 的 CPU 配额可能让任务运行一段时间后被节流。此时节点整体仍有空闲 CPU,容器内部请求却会排队。排查容器性能时,必须同时查看配额、节流次数和节流时长。

7. 并发正确性:共享内存带来的代价

线程共享地址空间让通信变得直接,也引入了数据竞争。count++ 通常包含读取、计算和写回多个步骤,两个线程交错执行可能丢失更新。

互斥锁保护临界区时,应遵守三个原则:临界区尽量短,不在持锁期间执行不可控的阻塞 I/O,所有访问共享不变量的路径使用一致的同步协议。

死锁需要四个条件同时成立:

  1. 互斥:资源同一时刻只能被一个任务占用;

  2. 占有并等待:任务持有资源的同时等待其他资源;

  3. 不可剥夺:资源不能被系统强制收回;

  4. 循环等待:多个任务形成首尾相接的等待环。

工程上常通过固定加锁顺序、一次性申请资源、超时与回退、缩小共享状态等方式破坏其中至少一个条件。增加线程数不能解决锁竞争,反而可能让问题更严重。

8. 用 Linux 工具定位 CPU 与调度问题

排障时应先区分“正在消耗 CPU”和“正在等待 CPU”。下面是一条从系统到线程的基本路径。

第一步:判断整机压力

uptime
vmstat 1
mpstat -P ALL 1

重点观察:

  • uptime 的负载平均值不是 CPU 使用率,它统计可运行任务以及部分不可中断睡眠任务;

  • vmstatr 长期明显高于可用 CPU 数量,说明运行队列存在竞争;

  • us 高通常表示用户态计算多,sy 高表示内核态工作多,wa 高表示 CPU 存在 I/O 等待;

  • 单个 CPU 满载而其他 CPU 空闲时,应检查单线程瓶颈和 CPU 亲和性。

第二步:定位进程和线程

top
pidstat -u -w -p 12345 1
top -H -p 12345
ps -L -p 12345 -o pid,tid,psr,stat,pcpu,comm

pidstat -w 能观察自愿与非自愿上下文切换。自愿切换通常发生在线程主动等待 I/O、锁或条件变量时;非自愿切换通常意味着被调度器抢占。切换数量必须结合吞吐量和延迟看,单独一个“很大”的数字不能证明异常。

第三步:获取调用路径

perf top -p 12345
perf record -g -p 12345 -- sleep 30
perf report

性能剖析要覆盖问题发生的时间窗口,并注意采样开销和符号信息。若热点集中在锁、系统调用或内存分配器,还需结合应用运行时工具继续分析。例如 Java 应用可以将高 CPU 的原生线程 ID 转成十六进制,再与线程转储中的 nid 对照。

9. 三类常见现象如何判断

CPU 使用率高,吞吐量也高

这可能只是系统接近设计容量。先检查延迟和错误率是否仍满足目标,再决定是否优化。仅仅追求更低的 CPU 使用率没有业务价值。

CPU 使用率不高,负载平均值却很高

任务可能大量处于 D 状态,或容器正在被 CPU 配额节流。检查进程状态、磁盘延迟、网络文件系统和 cgroup 指标,不要只扩容线程池。

上下文切换暴增,吞吐量下降

常见原因包括线程过多、锁竞争、频繁短时休眠和不合理的生产者/消费者唤醒。应结合 pidstatperf、运行时线程分析和业务流量确认根因。

10. 常见误区

  1. 进程切换一定发生,系统调用就一定切线程。 系统调用只是进入内核的一种方式,能立即完成时可以返回原线程。

  2. 负载平均值为 8,就表示 CPU 使用率是 800%。 负载平均值反映任务队列压力,不是利用率百分比。

  3. 线程越多,并发能力越强。 并发上限还受 CPU、锁、内存、连接池和下游容量约束。

  4. 提高优先级可以让程序整体更快。 优先级主要改变竞争者之间的 CPU 分配,无法消除程序自身的计算量。

  5. 看到上下文切换多就应该绑核。 切换和迁移是不同问题,绑核还可能制造新的热点。

11. 总结

进程提供资源隔离,线程提供进程内的并发执行,调度器负责在有限 CPU 上分配运行机会。理解三者的关键不是背诵定义,而是建立因果链:任务为什么从运行转为阻塞,为什么重新进入就绪队列,为什么得到或得不到 CPU,以及切换对缓存和延迟造成了什么影响。

遇到性能问题时,先用运行队列、任务状态、用户态与内核态占比判断瓶颈类型,再下钻到具体进程、线程和调用栈。指标只有放在同一时间窗口并结合吞吐量、延迟与资源限制,才有可靠的解释力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐