在现代操作系统中,CPU 调度是决定系统响应能力、实时性与吞吐量的子系统之一。对于工业控制、机器人视觉、高频数据采集这类对延迟敏感的场景,需要理解内核如何分配执行资源、何时发生上下文切换、公平性与实时性如何权衡,以及多核拓扑下的负载均衡逻辑。
一、CPU 调度
在之前的 Linux 内存管理体系中,我们讨论了进程如何获取、访问和管理内存资源。而 CPU 调度(CPU Scheduling)解决的是另一个问题:当多个线程都需要执行,而 CPU 执行资源有限时,操作系统应该让谁先执行、执行多久,以及在哪个 CPU 上执行?
内存管理与 CPU 调度两个子系统共同决定了一个程序的运行表现。
1.1 为什么需要 CPU 调度
假设系统只有一个逻辑 CPU,同时运行三个线程:
- 线程 A:工业相机图像采集
- 线程 B:视觉算法推理
- 线程 C:设备状态日志记录
三个线程都可以处于可运行状态,但单个逻辑 CPU 在同一时刻通常只能执行其中一个线程。单核系统中,多个线程通过时间上的交替执行实现并发(Concurrency) ;多核系统中,不同线程还可以同时运行在不同的逻辑 CPU 上,实现并行(Parallelism)。
但即便是多核 CPU,仍然需要调度器------因为每个逻辑 CPU 都可能有多个线程竞争执行资源。
1.2 CPU 调度平衡指标
一个优秀的调度器并不只是追求 CPU 利用率最高,还需要在多个相互冲突的目标之间做权衡:
| 目标 | 含义 | 典型场景 |
|---|---|---|
| 公平性 Fairness | 合理分配 CPU 执行时间 | 多进程、多用户系统 |
| 吞吐量 Throughput | 单位时间完成更多工作 | 计算任务、批处理系统 |
| 响应时间 Response Time | 尽快响应新到来的任务 | GUI、交互服务 |
| 调度延迟 Scheduling Latency | 减少可运行任务等待 CPU 的时间 | 高频采集、控制线程 |
| 实时性 Real-Time | 满足任务的时间约束 | 工业控制、音视频流 |
| CPU 局部性 Locality | 减少迁核与缓存失效开销 | 高性能多线程程序 |
| 能效 Energy Efficiency | 在性能和功耗之间权衡 | 移动设备、异构 CPU 平台 |
这些目标经常相互冲突:更频繁地切换任务可能提升交互响应能力,但会增加上下文切换和缓存扰动;延长执行片段有利于吞吐量,却可能增加其他任务的等待时间。因此,CPU 调度本质上是一种受优先级、时间约束和硬件拓扑限制的资源分配问题。
1.3 Linux 调度的基本单位是线程
在 Linux 内核中,进程和线程都通过 task_struct 描述。从调度器的视角看,它管理的是一个个可调度任务(Task),而不是简单地按应用程序分配 CPU。
一个典型的 C++ 进程内部结构:
一个 C++ 进程
├── 主线程 ── task_struct
├── 相机采集线程 ── task_struct
├── 算法推理线程 ── task_struct
└── 通信控制线程 ── task_struct
这些线程通常共享所属进程的虚拟地址空间、文件描述符等资源,但分别拥有自己的执行上下文、内核调度状态以及调度参数。Linux 支持针对单个线程设置调度策略、优先级和 CPU 亲和性------这一点对于设计工业设备的高频采集与控制链路尤为重要。
二、Linux CPU 调度器的整体架构
Linux 并不是只靠一个调度算法决定所有线程的执行顺序,它采用的是 Scheduler Core(调度核心)+ Scheduling Classes(调度类)+ Per-CPU Runqueue(每 CPU 运行队列) 的分层架构。
2.1 内核调度架构
#mermaid-svg-3GsAptR4Isk5xhx6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3GsAptR4Isk5xhx6 .error-icon{fill:#552222;}#mermaid-svg-3GsAptR4Isk5xhx6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3GsAptR4Isk5xhx6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3GsAptR4Isk5xhx6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3GsAptR4Isk5xhx6 .marker.cross{stroke:#333333;}#mermaid-svg-3GsAptR4Isk5xhx6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3GsAptR4Isk5xhx6 p{margin:0;}#mermaid-svg-3GsAptR4Isk5xhx6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 .cluster-label text{fill:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 .cluster-label span{color:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 .cluster-label span p{background-color:transparent;}#mermaid-svg-3GsAptR4Isk5xhx6 .label text,#mermaid-svg-3GsAptR4Isk5xhx6 span{fill:#333;color:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 .node rect,#mermaid-svg-3GsAptR4Isk5xhx6 .node circle,#mermaid-svg-3GsAptR4Isk5xhx6 .node ellipse,#mermaid-svg-3GsAptR4Isk5xhx6 .node polygon,#mermaid-svg-3GsAptR4Isk5xhx6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3GsAptR4Isk5xhx6 .rough-node .label text,#mermaid-svg-3GsAptR4Isk5xhx6 .node .label text,#mermaid-svg-3GsAptR4Isk5xhx6 .image-shape .label,#mermaid-svg-3GsAptR4Isk5xhx6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-3GsAptR4Isk5xhx6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3GsAptR4Isk5xhx6 .rough-node .label,#mermaid-svg-3GsAptR4Isk5xhx6 .node .label,#mermaid-svg-3GsAptR4Isk5xhx6 .image-shape .label,#mermaid-svg-3GsAptR4Isk5xhx6 .icon-shape .label{text-align:center;}#mermaid-svg-3GsAptR4Isk5xhx6 .node.clickable{cursor:pointer;}#mermaid-svg-3GsAptR4Isk5xhx6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3GsAptR4Isk5xhx6 .arrowheadPath{fill:#333333;}#mermaid-svg-3GsAptR4Isk5xhx6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3GsAptR4Isk5xhx6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3GsAptR4Isk5xhx6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3GsAptR4Isk5xhx6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3GsAptR4Isk5xhx6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3GsAptR4Isk5xhx6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3GsAptR4Isk5xhx6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3GsAptR4Isk5xhx6 .cluster text{fill:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 .cluster span{color:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3GsAptR4Isk5xhx6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3GsAptR4Isk5xhx6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-3GsAptR4Isk5xhx6 .icon-shape,#mermaid-svg-3GsAptR4Isk5xhx6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3GsAptR4Isk5xhx6 .icon-shape p,#mermaid-svg-3GsAptR4Isk5xhx6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3GsAptR4Isk5xhx6 .icon-shape .label rect,#mermaid-svg-3GsAptR4Isk5xhx6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3GsAptR4Isk5xhx6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3GsAptR4Isk5xhx6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3GsAptR4Isk5xhx6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-3GsAptR4Isk5xhx6 .tasks>*{fill:#FFE0B2!important;stroke:#E65100!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .tasks span{fill:#FFE0B2!important;stroke:#E65100!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .tasks tspan{fill:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .core>*{fill:#BBDEFB!important;stroke:#0D47A1!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .core span{fill:#BBDEFB!important;stroke:#0D47A1!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .core tspan{fill:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .dl>*{fill:#FFCDD2!important;stroke:#B71C1C!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .dl span{fill:#FFCDD2!important;stroke:#B71C1C!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .dl tspan{fill:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .rt>*{fill:#FFF9C4!important;stroke:#F57F17!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .rt span{fill:#FFF9C4!important;stroke:#F57F17!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .rt tspan{fill:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .fair>*{fill:#C8E6C9!important;stroke:#1B5E20!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .fair span{fill:#C8E6C9!important;stroke:#1B5E20!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .fair tspan{fill:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .rq>*{fill:#E1BEE7!important;stroke:#4A148C!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .rq span{fill:#E1BEE7!important;stroke:#4A148C!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .rq tspan{fill:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .cpu>*{fill:#B2DFDB!important;stroke:#004D40!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .cpu span{fill:#B2DFDB!important;stroke:#004D40!important;stroke-width:2px!important;color:#000!important;}#mermaid-svg-3GsAptR4Isk5xhx6 .cpu tspan{fill:#000!important;} 调度类 (Scheduling Classes)
调度策略 / 优先级
Per-CPU Runqueue (rq)
pick_next_task()
context_switch()
Linux Tasks
进程 / 线程
Scheduler Core
kernel/sched/core.c
Deadline Class
sched/dl.c
EDF + CBS
RT Class
sched/rt.c
FIFO / RR
Fair Class
sched/fair.c
CFS / EEVDF
CPU Core
实际内核还包含停止任务、空闲任务,以及可选的
sched_ext等调度类。Scheduler Core 负责公共调度流程,具体调度类负责自己的任务选择和队列管理。
这种设计能够让普通任务、实时任务和具有截止时间约束的任务使用不同的调度算法,而不必为每一种算法重写完整的内核上下文切换机制。
2.2 Scheduling Class 与 Scheduling Policy 的区别
- Scheduling Policy(调度策略) :是应用程序可以指定的策略,例如
SCHED_OTHER、SCHED_FIFO、SCHED_RR、SCHED_DEADLINE。 - Scheduling Class(调度类) :是内核实现这些策略的调度模块,例如
fair_sched_class、rt_sched_class、dl_sched_class。
一个调度类可以实现多个调度策略。例如 rt_sched_class 同时负责 SCHED_FIFO 和 SCHED_RR,而常规的 SCHED_OTHER、SCHED_BATCH 和 SCHED_IDLE 由公平调度类处理。
2.3 调度类之间的优先级
通常情况下,Linux 调度类具有以下优先级关系(从高到低):
stop_sched_class:CPU 停止等内核特殊任务dl_sched_class:SCHED_DEADLINErt_sched_class:SCHED_FIFO/SCHED_RRfair_sched_class:SCHED_OTHER/BATCH/IDLEext_sched_class:可选 BPF 自定义调度idle_sched_class:CPU 无其他任务时执行
这里有两个重要结论:
- 第一,普通任务的 nice 值再低,也不能仅靠 nice 超过具有可运行资格的实时任务。
- 第二,调度类优先级不等于任务在所属调度类内部的优先级。例如两个
SCHED_FIFO线程的先后顺序由实时优先级决定;两个公平调度线程则依据权重、虚拟运行时间等机制竞争 CPU。
三、任务状态、运行队列与调度发生的时机
一个线程什么时候真正需要 CPU?它什么时候从 CPU 上离开?这涉及任务状态、等待队列、运行队列和内核调度入口。
3.1 线程状态不等于线程是否正在执行
Linux 任务通常具有以下状态:
| 状态 | ps 标识 | 含义 |
|---|---|---|
| Running / Runnable | R | 正在运行或已经可以运行 |
| Interruptible Sleep | S | 等待事件,可被相关信号唤醒 |
| Uninterruptible Sleep | D | 通常等待内核资源或 I/O 完成 |
| Stopped / Traced | T / t | 被停止或正在被调试 |
| Zombie | Z | 已经退出,但尚未被父进程回收 |
这里需要特别注意 TASK_RUNNING:在内核中,TASK_RUNNING 并不意味着线程此时真的占用 CPU,它可能只是有资格执行,正在等待调度。
例如:
- 线程 A:正在 CPU 上执行
- 线程 B:
TASK_RUNNING,等待 CPU - 线程 C:
TASK_INTERRUPTIBLE,等待网络数据
线程 A、B 都属于可运行任务,而线程 C 尚未具备继续执行的条件。因此: Runnable(可运行)与 Running(正在运行)是两个不同概念。
很多 Linux 性能问题恰恰发生在二者之间:线程已经被唤醒,却迟迟没有真正获得 CPU。
3.2 Runqueue:运行队列
Linux 使用运行队列(Runqueue) 管理可运行任务。现代 Linux 的 SMP 调度体系主要采用 Per-CPU Runqueue,即每个逻辑 CPU 都有自己的运行队列。
每个逻辑 CPU 具有自己的 rq;虚线代表跨 CPU 任务迁移与负载均衡的可能性。真实 rq 内还包含多个调度类各自的数据结构。
核心数据结构位于内核 kernel/sched/sched.h(概念性成员,并非完整源码):
c
struct rq {
struct task_struct *curr; // 当前 CPU 正在执行的任务,指向本运行队列上正在CPU上运行的task_struct
unsigned int nr_running; // 本CPU运行队列中可运行任务的总数量,处于TASK_RUNNING状态任务计数
struct cfs_rq cfs; // CFS完全公平调度类对应的运行队列,存放普通SCHED_OTHER/SCHED_IDLE任务
struct rt_rq rt; // RT实时调度类运行队列,管理SCHED_FIFO、SCHED_RT实时任务
struct dl_rq dl; // DL截止时间调度类运行队列,管理SCHED_DEADLINE限期调度任务
// 其他调度统计、负载及同步信息:负载统计、时钟计数、锁、迁移相关成员省略
// 其他调度统计、负载及同步信息...
};
这些成员的准确定义会随内核版本变化,但核心设计保持一致。一个 CPU 的运行队列并不只是普通的 FIFO 队列:公平调度、实时调度和 Deadline 调度分别维护适合自己算法的数据结构,调度核心在合适的时机从这些队列中选择下一个任务。
Linux 不采用所有 CPU 共享一个全局任务队列,主要是为了降低全局锁竞争,改善 CPU 缓存局部性,并允许各个 CPU 相对独立地进行调度。代价则是需要额外的多核负载均衡机制。
3.3 Linux 调度触发
可以从三个主要场景理解调度触发的时机:
场景一:当前线程主动阻塞
例如某个 C++ 线程执行条件变量等待:
cpp
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; });
当等待条件尚未满足时,cv.wait() 会释放互斥锁并进入等待。对于常见 Linux C++ 运行时,这类阻塞同步最终通常依赖 futex 等内核等待与唤醒机制。线程进入睡眠状态后,调度器可以选择其他任务执行。
注意:这不是不断检查 ready 的忙等待,而是通过阻塞避免无意义地占用 CPU。
场景二:更合适的任务变为可运行
例如控制线程原本阻塞在条件变量上,另一个线程完成数据生产并通知它:
生产者完成数据写入
↓
通知条件变量
↓
唤醒等待线程
↓
线程变为 Runnable
↓
加入运行队列
↓
检查是否需要抢占当前任务
↓
获得 CPU 后继续执行
这里需要特别注意:notify_one() 并不等于消费者线程立即执行。它只会使一个符合条件的等待者有机会恢复执行,随后还要经过调度,并重新获取相关互斥锁。
场景三:当前线程需要被抢占
例如当前任务已经运行一段时间,而公平调度器判断其他任务更应该运行。内核可能设置重新调度标志(通常涉及 TIF_NEED_RESCHED),在满足调度条件时进行切换。时钟中断可以帮助触发这种检查,但调度并不只发生在周期性时钟中断上。
3.4 CPU 调度过程
从内核实现上看,路径可概括为:
任务阻塞 / 唤醒 / 需要抢占
↓
进入调度相关路径
↓
schedule() / 相关入口
↓
__schedule()
↓
更新运行队列状态
↓
pick_next_task() 选择下一个任务
↓
context_switch() 切换执行上下文
↓
新任务继续执行
在 kernel/sched/core.c 中,可以重点关注 __schedule()、pick_next_task()、context_switch()、try_to_wake_up()。其中 __schedule() 是理解调度核心的重要入口。
3.5 上下文切换
上下文切换(Context Switch) 指 CPU 从一个任务的执行上下文转移到另一个任务。主要涉及 CPU 寄存器状态、栈指针、程序执行位置、任务相关内核状态,以及必要时的地址空间切换。
这里需要联系前面的内存管理知识:
- 当两个线程属于同一个进程时,它们通常共享同一个
mm_struct,因此不需要像切换到不同地址空间的进程那样切换用户地址空间。 - 当切换到不同进程时,可能需要改变 CPU 使用的页表根等地址空间相关状态。
上下文切换不意味着每次都完整清空 CPU Cache 或 TLB。现代处理器、Linux 内核和硬件地址空间标记技术会尽量减少不必要的失效操作。即便如此,频繁切换仍会造成额外开销,包括内核调度工作、寄存器状态保存恢复,以及缓存和分支预测器局部性受到影响。
调度器必须在以下两者之间平衡:
响应能力↔上下文切换开销 \text{响应能力} \quad \leftrightarrow \quad \text{上下文切换开销} 响应能力↔上下文切换开销
这也是公平调度算法设计中的重要取舍。
四、公平调度:从 CFS 到 EEVDF
4.1 CFS :模拟理想的公平 CPU
CFS 全称为 Completely Fair Scheduler(完全公平调度器)。它从 Linux 2.6.23 开始成为默认普通任务调度机制的重要基础。
CFS 的思想可以概括为:假设存在一个理想 CPU,可以让所有可运行任务按照各自的权重同时分享计算能力。
例如单个 CPU 上有三个同权重任务:
A=33.3 A=33.3%,\quad B=33.3%,\quad C=33.3% A=33.3
真实 CPU 无法在同一逻辑处理器上同时执行三个普通线程,因此只能通过交替执行来逼近理想情况。CFS 引入虚拟运行时间(Virtual Runtime,vruntime),用于衡量任务已经获得的 CPU 服务量。
4.2 vruntime
CFS 使用一个非常重要的近似关系:
Δvruntime=Δtexec×NICE_0_LOADweight \Delta \text{vruntime} = \Delta t_{\text{exec}} \times \frac{\text{NICE\_0\_LOAD}}{\text{weight}} Δvruntime=Δtexec×weightNICE_0_LOAD
其中:
- Δtexec\Delta t_{\text{exec}}Δtexec 是实际消耗的 CPU 时间
weight是任务调度权重NICE_0_LOAD是 nice 为 0 时使用的基准权重,通常为 1024
因此,对于 nice 为 0 的普通任务:
Δvruntime≈Δtexec \Delta \text{vruntime} \approx \Delta t_{\text{exec}} Δvruntime≈Δtexec
如果一个任务权重更高,同样执行 10 ms,它累积的 vruntime 就会更少。这意味着在公平调度计算中,高权重任务可以获得更多 CPU 执行时间。
4.3 nice 与 CPU 时间分配
Linux 普通任务的 nice 范围为:
−20≤nice≤19 -20 \le \text{nice} \le 19 −20≤nice≤19
nice 越低,任务权重越高。其调度权重大致遵循每改变一个 nice 等级,权重乘以或除以 1.25 的关系。例如:
| nice | 权重 |
|---|---|
| -5 | 3121 |
| 0 | 1024 |
| +5 | 335 |
| +10 | 110 |
| +19 | 15 |
理想条件下:CPU 时间占比 = 该任务权重 ÷ 全部就绪任务权重总和 。
代入 vruntime 公式:
tA⋅1024WA=tB⋅1024WBt_A \cdot \frac{1024}{W_A} = t_B \cdot \frac{1024}{W_B}tA⋅WA1024=tB⋅WB1024两边1024直接约掉:
tAWA=tBWB⇒tAtB=WAWB\frac{t_A}{W_A} = \frac{t_B}{W_B} \Rightarrow \frac{t_A}{t_B}=\frac{W_A}{W_B}WAtA=WBtB⇒tBtA=WBWA
物理 CPU 时间之比 严格等于权重之比。
假设只有两个始终可运行的普通任务 A、B,在同一 CPU 上竞争,且没有其他调度限制:
- A 的 nice 为 0,权重为 1024
- B 的 nice 为 +5,权重为 335
它们的长期 CPU 时间份额 近似为:
ShareA=10241024+335≈75.3% \text{Share}_A=\frac{1024}{1024+335}\approx75.3\% ShareA=1024+3351024≈75.3%
ShareB=3351024+335≈24.7% \text{Share}_B=\frac{335}{1024+335}\approx24.7\% ShareB=1024+335335≈24.7%
这解释了为什么 nice 是相对权重,而不是绝对的 CPU 配额。一个 nice 为 +19 的任务,如果系统中没有其他任务竞争 CPU,仍然可以使用几乎全部可用计算能力;同样,nice 为 -20 也不能保证线程在指定时间内必然执行。
4.4 经典 CFS 如何选择任务
传统 CFS 维护一棵按照 vruntime 排序的红黑树(Red-Black Tree)。

最左侧节点 vruntime 最小。在这一经典机制中,调度器倾向于选择 vruntime 最小的任务。当某个任务执行后,其 vruntime 增加,它在公平时间线中的位置向后移动,其他任务便获得执行机会。
红黑树使任务插入、删除等操作具有 O(logN)O(\log N)O(logN) 复杂度,并可以有效查找最左节点。这解释了 CFS 为什么不需要对所有任务反复进行完整排序。
不过,这只是经典 CFS 的选择逻辑,现代 Linux 版本的最终实现是多样的。
4.5 EEVDF:现代公平调度
EEVDF 全称:Earliest Eligible Virtual Deadline First(最早符合资格的虚拟截止时间优先)。Linux 从 6.6 开始逐步引入 EEVDF,改进原来的 CFS 任务选择机制。
它仍然追求 CPU 时间的公平分配,但不再单纯依靠最小 vruntime 选择下一个任务,而是考虑两个重要因素:
- 任务是否应该获得 CPU(资格)
- 应该优先安排哪个任务(优先级)
EEVDF 由此引入了 lag(服务量偏差) 和 virtual deadline(虚拟截止时间)。
| 调度机制 | 选择逻辑 |
|---|---|
| 经典 CFS | 谁的 vruntime 更小?优先最小 vruntime |
| 现代 EEVDF | 先判断是否 eligible,再选最早 virtual deadline |
4.5.1 lag:一个任务是否已经获得公平份额
lagi∝V−vruntimei \text{lag}_i \propto V - \text{vruntime}_i lagi∝V−vruntimei
其中 VVV 表示调度器依据队列服务状态计算出的虚拟时间基准。
- 当 lagi>0\text{lag}_i > 0lagi>0:意味着该任务相对于公平份额处于欠服务状态,应该获得更多 CPU 时间。
- 当 lagi<0\text{lag}_i < 0lagi<0:则意味着该任务已经获得了相对较多的 CPU 时间。
实际内核中,lag 的计算还涉及实体权重、队列虚拟时间等细节
EEVDF 通常从满足资格条件的任务中继续选择。
4.5.2 Virtual Deadline:决定哪个任务先执行
EEVDF 根据任务的请求执行片段(slice)和权重计算虚拟截止时间。
vdeadlinei=vruntimei+slicei×NICE_0_LOADweighti \text{vdeadline}_i = \text{vruntime}_i + \text{slice}_i \times \frac{\text{NICE\_0\_LOAD}}{\text{weight}_i} vdeadlinei=vruntimei+slicei×weightiNICE_0_LOAD
其中 slice 越短,在其他条件相同的情况下,虚拟截止时间通常越靠前。最终选择逻辑可概括为:
公平调度运行队列
↓
计算任务 lag
↓
筛选 eligible 的任务
↓
比较 virtual deadline
↓
选择最早的虚拟截止时间
↓
执行任务
例如三个任务:
| 任务 | lag | 虚拟截止时间 | 结果 |
|---|---|---|---|
| A | +4 | 20 | 符合资格 |
| B | +2 | 12 | 优先选择 |
| C | -3 | 8 | 暂不符合资格 |
虽然 C 的虚拟截止时间更早,但它尚不符合资格;B 则在符合资格的任务中具有最早的截止时间,因此选择 B。
4.5.3 EEVDF 为什么有利于低延迟任务
对于周期性唤醒、每次执行时间较短的任务,EEVDF 能够通过请求执行片段和虚拟截止时间改善调度响应。同时,它仍通过 lag 管理公平性,避免某个任务长期无条件地抢占 CPU。
例如一个轻量的设备状态处理线程,每次运行时间较短但需要频繁响应,可能受益于这种机制。
但是,EEVDF 中的 deadline 并不是实时调度中的硬截止时间。它不能保证每个线程必须在某个绝对时间之前完成,也不能替代 SCHED_DEADLINE。这是两者非常重要的区别。
4.5.4 EEVDF 如何处理频繁休眠的线程
如果一个任务消耗了较多 CPU 时间,产生负 lag,然后通过短暂休眠来重置调度状态,就可能破坏公平性。EEVDF 的相关实现通过延迟出队(deferred dequeue) 和 lag 衰减机制 减少这种利用休眠行为占便宜的问题。
其思想是:休眠不应该让任务无代价地抹去此前占用过多 CPU 的历史。
在近期内核源码中,可以重点阅读 pick_eevdf()、update_deadline() 及相关资格判断逻辑。
五、实时调度:SCHED_FIFO、SCHED_RR 与 SCHED_DEADLINE
公平调度追求长期资源分配合理,但实时系统更关心:任务能否在规定的时间约束内获得足够 CPU 时间,并完成执行。因此,Linux 提供了专门的实时调度策略。
5.1 Linux 的主要调度策略
| 策略 | 主要目标 | 调度特点 |
|---|---|---|
| SCHED_OTHER | 通用公平性 | 默认普通任务 |
| SCHED_BATCH | 批处理吞吐量 | 降低对交互性任务的竞争 |
| SCHED_IDLE | 后台低优先级工作 | 只有较低竞争资格 |
| SCHED_FIFO | 实时优先级调度 | 同优先级不强制轮转 |
| SCHED_RR | 实时轮转 | 同优先级按时间片轮转 |
| SCHED_DEADLINE | 时间约束与带宽管理 | EDF + CBS |
实时优先级通常为 1~99,数值越大,实时优先级越高。普通 SCHED_OTHER 线程的 sched_priority 为 0,其 nice 值在公平调度内部生效。
5.2 SCHED_FIFO:固定优先级的先进先出调度
SCHED_FIFO 的规则相对直接。假设两个实时线程:
- 线程 A:
SCHED_FIFO,优先级 80 - 线程 B:
SCHED_FIFO,优先级 40
当 A 和 B 同时具备运行资格,且在同一个 CPU 上竞争时,A 优先运行。如果 A 一直执行计算:
cpp
while (true) { do_work(); }
它不会仅仅因为运行了某个固定时间片,就自动让出 CPU 给同优先级或低优先级的任务。通常只有在主动阻塞、调用 sched_yield()、被更高优先级任务抢占,或者受到相应调度带宽限制等情况下,它才会停止当前执行。
这与普通公平调度非常不同。因此,如果将一个不断忙循环的线程设置为很高的 FIFO 优先级,它可能严重影响其他线程,甚至造成系统交互卡顿。
现代 Linux 通常提供实时任务带宽限制(RT throttling),以降低实时线程长期独占 CPU 的风险。常见系统中:
/proc/sys/kernel/sched_rt_period_us/proc/sys/kernel/sched_rt_runtime_us
典型默认值分别为 100000 和 950000。在对应实时带宽控制生效的配置中,这代表每个 1 s 周期最多分配约 950 ms 给受控实时任务,保留一部分执行资源供其他工作使用。
具体行为还受内核配置、调度域和带宽控制方式影响,不能视为所有 Linux 系统的统一保证。
5.3 SCHED_RR:带时间片的实时调度
SCHED_RR 可以理解为在 FIFO 的固定优先级基础上,增加同优先级任务之间的轮转机制。例如两个优先级都为 50 的线程:
A 执行一个 RR 时间片
↓
B 执行一个 RR 时间片
↓
A 继续执行
注意,RR 的时间片约束并不会让优先级低的任务自动获得与高优先级任务平等的 CPU 份额。如果一个始终可运行的 RR 线程优先级为 80,另一个线程优先级为 30,后者仍然可能长期得不到执行机会。
可以通过 chrt -r -p 50 <pid> 将指定线程设为优先级 50 的 SCHED_RR(需要相应权限)。查看实际 RR 时间片可使用 sched_rr_get_interval(),不要假定所有 Linux 发行版都有相同的默认时间片。
5.4 SCHED_DEADLINE:从优先级走向时间约束
固定优先级调度并不能直接描述周期控制任务的实际需求。例如一个线程每 10 ms 执行一次,每次最多需要 2 ms CPU 时间。它的需求并不是"优先级必须是 80",而是:每个周期需要多少 CPU 时间,以及最迟什么时候完成。
SCHED_DEADLINE 使用三个参数描述这类任务:
| 参数 | 含义 | 示例 |
|---|---|---|
| Runtime | 可获得的执行预算 | 2 ms |
| Deadline | 相对截止时间 | 8 ms |
| Period | 任务周期或最小激活间隔 | 10 ms |
这组参数满足:
0<Runtime≤Deadline≤Period 0 < \text{Runtime} \le \text{Deadline} \le \text{Period} 0<Runtime≤Deadline≤Period
含义是:希望任务在每个 10 ms 的周期中,于激活后 8 ms 内获得必要的执行预算。
5.4.1 EDF:Earliest Deadline First
EDF 的规则是: 优先执行调度截止时间最早的任务
假设两个 Deadline 任务:
- A 的当前调度截止时间为 12 ms
- B 的当前调度截止时间为 8 ms
在其他调度约束允许时,优先执行 B。EDF 使用时间紧迫程度,而不是 FIFO/RR 那样的固定优先级,决定任务先后顺序。
5.4.2 CBS:Constant Bandwidth Server
EDF 决定谁先运行,但还需要解决任务执行时间失控的问题。例如一个任务预计只使用 2 ms CPU,但由于算法异常,实际上执行了 8 ms。如果缺乏执行预算约束,它可能影响其他周期性任务的时间要求。
CBS 为任务管理执行预算与调度截止时间,对超出预算的任务进行相应限制和补充调度安排。Linux 的 SCHED_DEADLINE 将全局 EDF(GEDF)与 CBS 机制结合,实现时间约束调度和任务间的带宽隔离。
任务的 CPU 利用率需求可以表示为:
Ui=RuntimeiPeriodi U_i = \frac{\text{Runtime}_i}{\text{Period}_i} Ui=PeriodiRuntimei
例如:
U=210=20 U = \frac{2}{10} = 20% U=102=20
表示此任务要求平均预留相当于一个逻辑 CPU 20% 的执行能力。
在多核系统中,Deadline 调度还要考虑全局运行容量、CPU 亲和性、调度域及准入控制。
特别需要强调:设置了
SCHED_DEADLINE不代表物理系统自动拥有无条件的硬实时保证。要真正论证任务能否满足截止时间,还需要分析最坏执行时间(WCET)、系统中断、共享资源阻塞、驱动响应和其他干扰。
六、内核抢占、实时内核与优先级反转
6.1 抢占与中断的区别
硬件中断(Interrupt)和线程抢占(Preemption)并不是同一个概念:
- 中断是 CPU 接收到硬件或其他中断事件后,转而执行相应处理逻辑。
- 抢占则是调度器决定暂停当前任务,改为执行另一个任务。
例如一个网卡中断到来后:
网卡产生硬件中断
↓
进入中断处理路径
↓
网络数据就绪
↓
唤醒网络接收线程
↓
判断是否需要重新调度
↓
在允许调度的时机切换任务
因此,中断能够引发后续的调度需求,但"中断发生"本身不等于"线程上下文切换已经完成"。这也是单片机裸机中断与 Linux 用户空间事件回调的重要区别。
6.2 内核抢占模型
Linux 根据内核配置提供不同程度的抢占能力。经典的抢占模式包括:
PREEMPT_NONE:更侧重吞吐量,内核代码中的抢占点有限。PREEMPT_VOLUNTARY:通过自愿调度点改善响应。PREEMPT:允许在更多满足安全条件的内核执行位置进行抢占。
现代内核还支持 PREEMPT_DYNAMIC,可在受支持的构建和体系结构上动态选择抢占模式;部分版本还提供 lazy preemption 等模式。这些机制主要改变的是内核代码执行期间可以多快响应重新调度请求,并不意味着普通用户线程在用户态只能等待一个系统时钟 tick 才能被抢占。
6.3 preempt_count 的作用
Linux 使用抢占计数及相关状态跟踪某些不适合发生抢占的执行上下文。例如在普通非 PREEMPT_RT 内核中,持有某些自旋锁期间通常禁止抢占。
进入临界区
↓
禁止内核抢占
↓
访问受保护数据
↓
退出临界区
↓
允许内核抢占
↓
如需要则触发调度
这样做是为了保证底层同步和关键执行路径的正确性。但代价也十分明显:不可抢占区间越长,高优先级任务可能经历的调度等待时间就越长。这是 Linux 实时性优化的重点之一。
6.4 PREEMPT_RT 如何改善实时性
PREEMPT_RT 通过改变内核锁、中断和执行上下文的处理方式,缩短不能被抢占的执行区间。两个重要机制分别是:
第一,更多中断处理改为线程化
传统硬中断处理直接处于中断上下文,而 PREEMPT_RT 尽可能将较多中断处理工作转移到可调度的线程上下文中。如此一来,中断线程能够参与优先级调度。
第二,将大量普通自旋锁改为具备优先级继承能力、允许睡眠的锁实现
在 PREEMPT_RT 下,普通 spinlock_t 的语义与标准非实时内核有所不同;底层真正必须维持非抢占语义的位置,则使用 raw_spinlock_t 等机制。
这样能够显著缩短较高优先级任务被内核代码阻塞的时间。但 PREEMPT_RT 仍存在不可抢占区间,不能保证所有任务都有绝对固定的响应延迟。
6.5 优先级反转(Priority Inversion)
这是实时系统必须理解的问题。假设三个线程:
- 线程 H:高优先级,90
- 线程 M:中优先级,50
- 线程 L:低优先级,10
L 首先获得一个互斥锁。随后 H 需要这把锁,但锁仍然被 L 持有,于是 H 阻塞。与此同时,M 不断执行,由于 M 的优先级高于 L,L 迟迟拿不到 CPU。结果:
高优先级 H
↓
等待 L 释放锁
↓
L 被中优先级 M 抢占
↓
H 被间接阻塞
虽然 H 的优先级最高,却可能因为低优先级任务 L 的执行受到阻碍而长期无法完成。这就是优先级反转。
解决机制:Priority Inheritance(优先级继承)
优先级继承(PI)的思路是:当 H 因为 L 持有的锁而阻塞时,临时提高 L 的有效调度优先级,使其能够更快地释放资源。
H 等待 L 的锁
↓
L 临时继承 H 的优先级
↓
L 优先运行并释放锁
↓
H 获得锁继续执行
↓
L 恢复原来的优先级
Linux 的 rtmutex 和 PI futex 支持这种机制。但并不是所有 C++ 互斥锁都会自动启用优先级继承。如果在 Linux 上使用 pthread,可以研究 PTHREAD_PRIO_INHERIT 协议;而 std::mutex 本身不提供可移植的设置 PI 协议接口。
对于高优先级设备控制线程,尽量缩短共享锁持有时间、减少与普通后台线程的锁依赖,往往比单纯提高优先级更加重要。
七、多核 CPU 调度:负载均衡、亲和性、NUMA 与 PELT
前面的讨论主要关注同一个 CPU 内部的任务选择。在现代多核系统中,还必须解决哪个 CPU 最适合执行某个线程
7.1 CPU 负载均衡
假设四核 CPU:
- CPU0:5 个可运行线程
- CPU1:1 个可运行线程
- CPU2:0 个可运行线程
- CPU3:0 个可运行线程
如果任务允许迁移,那么将部分线程从 CPU0 迁移到空闲 CPU,可能提升系统整体吞吐量并降低等待时间。Linux 通过调度域(Scheduling Domains) 组织 CPU 拓扑和负载均衡。
对于多核平台,调度域可能体现以下层次:
系统级调度范围
↓
NUMA / Package
↓
CPU Cluster
↓
Core
↓
SMT Siblings
真实层次取决于 CPU 架构、拓扑以及内核配置。调度器会考虑各 CPU 及 CPU 组的负载、容量和迁移成本,决定是否需要迁移任务。除了周期性负载均衡,线程唤醒时的 CPU 选择以及 CPU 空闲时寻找任务,也是重要的负载分配路径。
7.2 为什么不把线程全部均匀分散
因为任务迁移并不是免费的。假设线程 A 一直运行在 CPU0,它近期访问过的数据可能已经位于 CPU0 所在核心或共享缓存层级中。如果将 A 迁移到 CPU3,它可能面临额外的缓存未命中和数据访问延迟。
因此,调度器需要在两者之间取舍:
负载均衡收益↔迁移与缓存局部性成本 \text{负载均衡收益} \quad \leftrightarrow \quad \text{迁移与缓存局部性成本} 负载均衡收益↔迁移与缓存局部性成本
这解释了为什么高性能系统并不是 CPU 分布越平均越好。例如高频数据处理线程,如果经常跨核心迁移,即使 CPU 总体占用率不高,也可能表现出运行时间抖动。
7.3 CPU Affinity:CPU 亲和性
CPU Affinity 用于限制线程允许在哪些逻辑 CPU 上执行。例如:
bash
# 查看线程的 CPU 亲和性
taskset -pc <tid>
# 将指定线程限制在 CPU2
taskset -pc 2 <tid>
# 在 CPU2 和 CPU3 上启动程序
taskset -c 2,3 ./my_program
注意
taskset -p默认操作指定的任务 ID;想操作已有进程中的全部线程,可以结合-a参数。
CPU 亲和性可能带来这些收益:减少迁移、增强缓存局部性、降低关键线程受到其他负载影响的概率。但它也可能带来副作用:如果把相机采集、算法推理和通信线程全部固定到一个逻辑 CPU,就可能人为制造严重竞争,而其他 CPU 保持空闲。
因此,CPU 绑核是约束调度器的手段,不是通用的性能优化开关。另外,仅给关键线程设置 affinity 并不代表这个 CPU 已经独占;其他线程及中断仍可能运行在该 CPU 上。
7.4 NUMA:CPU 调度与内存访问的关系
NUMA(Non-Uniform Memory Access)架构中,不同 CPU 对不同内存节点的访问成本可能不同。例如双路服务器:

如果线程运行在 Node 0,却频繁访问 Node 1 的内存,那么就可能产生更高的远端内存访问成本。因此,CPU 调度与内存管理并不是完全独立的。Linux 的 NUMA balancing 等机制会尝试改善任务与其访问内存之间的放置关系。
对高吞吐量点云、图像和大数组处理程序而言,CPU 亲和性与内存分配策略可能需要一起分析。
7.5 PELT:Linux 如何估计线程负载
PELT 全称为 Per-Entity Load Tracking。Linux 需要估计一个线程消耗多少 CPU 能力,才能决定应该将它安排在哪里、如何分配时间,以及是否需要提升 CPU 频率。
PELT 使用指数衰减的历史统计方式计算任务的平均负载和利用率信号,其经典半衰期约为 32 ms。直观理解:
线程持续繁忙 → util_avg 逐渐上升
线程开始长期休眠 → util_avg 逐渐衰减
这样,调度器不只是看线程当前是否正在执行,还能利用近期的负载历史做决策。不过,PELT 的利用率信号并不完全等同于 top 或 ps 显示的 CPU 占用率,它还涉及频率、CPU 容量归一化等处理。
7.6 异构 CPU:大核、小核与能效调度
在 ARM big.LITTLE 等异构平台上,不同 CPU 的最大计算能力可能不同。即使两个核心的 CPU 利用率都是 50%,它们能完成的实际工作量也未必相同。
Linux 使用 CPU capacity 及任务利用率估计辅助任务放置。EAS(Energy Aware Scheduling) 进一步利用平台能耗模型,在性能与能耗之间进行权衡。与此相关的 uclamp 机制允许任务提供利用率范围提示,例如 UCLAMP_MIN 表示期望的最低性能水平,UCLAMP_MAX 表示期望的性能上限。
uclamp 影响调度器的任务放置或 CPU 频率决策,但不是保证线程获得某个 CPU 时间百分比的硬配额。这类机制对无人机、机器人板载计算机等功耗受限的平台特别有意义。
7.7 sched_ext:用 BPF 扩展调度器
Linux 6.12 引入了 sched_ext 调度类,允许通过 BPF 程序定义调度行为。它可以用于研究特定工作负载的调度策略,例如自定义任务放置、调度队列和任务选择机制。
不过,它并不是所有 Linux 系统默认正在使用的调度器。只有在内核支持并加载了相应 BPF 调度程序时,相关机制才会生效;出现异常时还提供回退到默认公平调度的机制。先理解 core.c、fair.c、rt.c 和 deadline 调度,比直接研究 BPF 自定义调度器更重要。
八、调度时间、CPU 配额与系统抖动
调度器决定谁能获得 CPU,但这不意味着它能够单独决定线程的实际执行时间。一个周期性线程的时间表现,还受到时钟、定时器、CPU 频率、系统中断以及资源限制的影响。
8.1 调度延迟与执行时间
首先需要区分四个概念。对于周期任务,主要时间阶段包括:
- 等待时间(Waiting Time):可以包括线程处于睡眠状态以及在运行队列中等待的时间,分析时应将二者分开。
- 调度延迟(Scheduling Latency):通常指任务已经变为可运行状态,到实际获得 CPU 之间的时间。
- 执行时间(Execution Time):是任务实际执行所消耗的 CPU 时间,可以与墙上时钟经过的时间不同。
- 响应时间(Response Time):取决于所采用的定义,通常衡量事件发生到任务开始响应,或事件发生到任务完成之间的时间。
例如:
- t0=100.000 mst_0 = 100.000\ \text{ms}t0=100.000 ms:线程被唤醒
- t1=100.350 mst_1 = 100.350\ \text{ms}t1=100.350 ms:线程获得 CPU
- t2=101.150 mst_2 = 101.150\ \text{ms}t2=101.150 ms:本次计算完成
那么:
Tsched=t1−t0=0.350 ms T_{\text{sched}} = t_1 - t_0 = 0.350\ \text{ms} Tsched=t1−t0=0.350 ms
如果线程在这段执行期间没有再次被抢占,并且不涉及其他等待:
Texec≈t2−t1=0.800 ms T_{\text{exec}} \approx t_2 - t_1 = 0.800\ \text{ms} Texec≈t2−t1=0.800 ms
从线程唤醒到本次工作完成的时间为:
Tresponse=1.150 ms T_{\text{response}} = 1.150\ \text{ms} Tresponse=1.150 ms
但真实工业系统还需要计入硬件事件产生、驱动处理、用户线程唤醒、通信传输和执行器响应等时间。
8.2 调度时钟 Tick 与高精度定时器
Linux 的调度 Tick 可以参与运行时间统计、时间片维护和任务重新调度判断。内核的 CONFIG_HZ 可能设为 250、1000 等数值,具体由内核构建决定。
但有两个重要事实:
- 调度器不需要等到下一个 Tick 才能响应所有唤醒事件。
- 周期性定时器到期不代表对应线程能够立即开始执行。
例如 clock_nanosleep() 可以利用绝对时间指定下一次唤醒目标,但线程真正开始运行仍取决于 CPU 调度和系统运行状态。对于周期性控制线程,建议尽可能采用基于绝对时间的周期驱动,避免简单相对睡眠不断累积时间漂移。
8.3 NO_HZ 与 CPU 隔离
对于极端关注延迟抖动的负载,Linux 还支持 Tickless 等机制:
NO_HZ_IDLE:可在 CPU 空闲时减少周期性 Tick。- 在满足条件的情况下,
NO_HZ_FULL可以减少只有单个可运行任务的指定 CPU 上的调度 Tick。
但这不等于该 CPU 完全不会受到操作系统干扰。IRQ、RCU、内核工作线程、系统调用、SMT 共享资源等仍可能引入抖动。真正的 CPU 隔离还需要综合考虑 cpuset、IRQ affinity、housekeeping CPU 和硬件拓扑。CPU 隔离是一种系统级优化,不只是执行一次 taskset。
8.4 cgroup CPU 控制:weight 与 quota 的区别
Linux 除了线程级调度外,还支持 cgroup 层面的 CPU 资源管理。在 cgroup v2 中,常见 CPU 控制接口包括:
| 接口 | 功能 |
|---|---|
cpu.weight |
设置 CPU 竞争时的相对权重 |
cpu.max |
设置 CPU 带宽上限 |
cpu.stat |
查看 CPU 使用量及节流统计 |
cpu.pressure |
查看 CPU 资源压力 |
cpu.uclamp.min/max |
设置利用率性能提示范围 |
其中 cpu.weight 与 cpu.max 的含义完全不同:
- 假设两个 cgroup 具有相同的竞争条件:Group A
cpu.weight = 200,Group Bcpu.weight = 100。当它们争抢相同 CPU 资源时,A 可以获得相对更高的资源份额。但当 B 没有负载时,A 仍可能使用全部可用 CPU 能力。 - 而
cpu.max = 20000 100000表示该组每 100 ms 最多消耗 20 ms 的 CPU 执行预算,即平均相当于 0.2 个逻辑 CPU 的执行能力。
这里的预算是组内相关任务在多个 CPU 上的总执行时间预算,并不是每个核心分别允许使用 20%。如果工作线程并行执行,它们还可能较快地耗尽预算,随后受到 throttling(节流),直到预算补充。
因此可能出现:机器仍然有空闲 CPU,但容器中的线程已经耗尽 CPU 配额,无法立即继续运行。对于 Kubernetes、Docker 或受 systemd CPUQuota 限制的服务,这一点非常重要。
九、Linux CPU 调度分析与性能诊断
理论理解之后,真正的工程能力体现在能否判断:一个程序的延迟究竟来自 CPU 调度,还是锁、内存、I/O 或算法本身
9.1 查看系统与线程调度信息
首先确认内核版本:
bash
uname -r
uname -a
查看 CPU 逻辑拓扑:
bash
lscpu
查看系统所有线程的调度信息:
bash
ps -eLo pid,tid,psr,cls,rtprio,ni,stat,comm
其中 TID 是线程 ID,PSR 表示最近运行的逻辑 CPU,CLS 表示调度策略类别,RTPRIO 表示实时优先级,NI 表示 nice 值。
查看某个进程的线程:
bash
top -H -p <pid>
查看具体线程的内核调度统计:
bash
cat /proc/<pid>/task/<tid>/sched
这个文件可能包含 se.vruntime、执行时间统计、上下文切换次数等数据,具体字段与内核版本、编译配置有关。
9.2 查看线程等待 CPU 的时间
Linux 提供了 schedstat 统计接口:
bash
cat /proc/<pid>/task/<tid>/schedstat
在支持的配置中,它通常提供三个值:
- CPU 执行时间(ns)
- 运行队列等待时间(ns)
- 被调度执行次数
例如下面是一组假设统计:
85000000 23000000 1200
表示线程累计使用了约 850 ms CPU 时间,在运行队列中累计等待约 230 ms,并被调度执行约 1200 次。
注意这些是累计值,而不是某一次调度的延迟。如果要判断系统是否突然出现调度恶化,应采集两个时间点的数据并计算差值。
9.3 使用 perf sched 定位调度延迟
这是调试 Linux 调度问题非常有价值的工具。先记录调度事件:
bash
sudo perf sched record -a -- sleep 10
然后查看调度延迟:
bash
sudo perf sched latency
或者查看调度时间线:
bash
sudo perf sched timehist
进一步分析任务唤醒与迁移:
bash
sudo perf sched timehist -w -M
能够观察任务何时运行、等待了多久、何时被唤醒,以及发生了哪些 CPU 迁移。这有助于发现某些线程长期处于可运行状态却没有得到执行的情况。
9.4 ftrace:直接观察调度事件
Linux 内核有专门的调度追踪事件,例如:
sched_wakeup:观察任务被唤醒、变为可运行状态sched_switch:观察 CPU 从一个任务切换到另一个任务sched_migrate_task:观察任务在 CPU 之间迁移
可以通过 tracefs 查看系统实际支持的调度事件:
bash
sudo ls /sys/kernel/tracing/events/sched/
如果该路径不可用,需要检查 tracefs 是否挂载及内核配置。当需要精确分析某个线程为什么延迟 1 ms 才运行时,结合 sched_wakeup 和 sched_switch 时间戳,是比观察整体 CPU 利用率更直接的方法。
9.5 CPU 占用率不高,不代表没有调度压力
例如系统整体 CPU 利用率只有 35%,但一个关键线程仍可能出现很大的调度延迟。原因可能是:
- 关键线程被固定在一个繁忙 CPU 上
- CPU 亲和性限制过窄
- 实时任务占据了局部运行资源
- cgroup 配额耗尽
- 线程其实阻塞在互斥锁、I/O 或其他资源上
因此还可以查看:
bash
cat /proc/pressure/cpu
它提供 PSI(Pressure Stall Information)CPU 压力信息。典型输出形式:
bash
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
some 反映在指定统计窗口中至少有部分任务因为 CPU 资源竞争而等待的时间占比。它能够补充 CPU 利用率难以表达的资源竞争信息。
实际工程排查时建议采用以下判断顺序:
#mermaid-svg-Lf37DaaaPl9kN4kU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Lf37DaaaPl9kN4kU .error-icon{fill:#552222;}#mermaid-svg-Lf37DaaaPl9kN4kU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Lf37DaaaPl9kN4kU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Lf37DaaaPl9kN4kU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Lf37DaaaPl9kN4kU .marker.cross{stroke:#333333;}#mermaid-svg-Lf37DaaaPl9kN4kU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Lf37DaaaPl9kN4kU p{margin:0;}#mermaid-svg-Lf37DaaaPl9kN4kU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU .cluster-label text{fill:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU .cluster-label span{color:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU .cluster-label span p{background-color:transparent;}#mermaid-svg-Lf37DaaaPl9kN4kU .label text,#mermaid-svg-Lf37DaaaPl9kN4kU span{fill:#333;color:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU .node rect,#mermaid-svg-Lf37DaaaPl9kN4kU .node circle,#mermaid-svg-Lf37DaaaPl9kN4kU .node ellipse,#mermaid-svg-Lf37DaaaPl9kN4kU .node polygon,#mermaid-svg-Lf37DaaaPl9kN4kU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Lf37DaaaPl9kN4kU .rough-node .label text,#mermaid-svg-Lf37DaaaPl9kN4kU .node .label text,#mermaid-svg-Lf37DaaaPl9kN4kU .image-shape .label,#mermaid-svg-Lf37DaaaPl9kN4kU .icon-shape .label{text-anchor:middle;}#mermaid-svg-Lf37DaaaPl9kN4kU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Lf37DaaaPl9kN4kU .rough-node .label,#mermaid-svg-Lf37DaaaPl9kN4kU .node .label,#mermaid-svg-Lf37DaaaPl9kN4kU .image-shape .label,#mermaid-svg-Lf37DaaaPl9kN4kU .icon-shape .label{text-align:center;}#mermaid-svg-Lf37DaaaPl9kN4kU .node.clickable{cursor:pointer;}#mermaid-svg-Lf37DaaaPl9kN4kU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Lf37DaaaPl9kN4kU .arrowheadPath{fill:#333333;}#mermaid-svg-Lf37DaaaPl9kN4kU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Lf37DaaaPl9kN4kU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Lf37DaaaPl9kN4kU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lf37DaaaPl9kN4kU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Lf37DaaaPl9kN4kU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lf37DaaaPl9kN4kU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Lf37DaaaPl9kN4kU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Lf37DaaaPl9kN4kU .cluster text{fill:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU .cluster span{color:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Lf37DaaaPl9kN4kU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Lf37DaaaPl9kN4kU rect.text{fill:none;stroke-width:0;}#mermaid-svg-Lf37DaaaPl9kN4kU .icon-shape,#mermaid-svg-Lf37DaaaPl9kN4kU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lf37DaaaPl9kN4kU .icon-shape p,#mermaid-svg-Lf37DaaaPl9kN4kU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Lf37DaaaPl9kN4kU .icon-shape .label rect,#mermaid-svg-Lf37DaaaPl9kN4kU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lf37DaaaPl9kN4kU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Lf37DaaaPl9kN4kU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Lf37DaaaPl9kN4kU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-Lf37DaaaPl9kN4kU .root>*{fill:#2563eb!important;stroke:#1d4ed8!important;color:#fff!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .root span{fill:#2563eb!important;stroke:#1d4ed8!important;color:#fff!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .root tspan{fill:#fff!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .decision>*{fill:#fef3c7!important;stroke:#d97706!important;color:#92400e!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .decision span{fill:#fef3c7!important;stroke:#d97706!important;color:#92400e!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .decision tspan{fill:#92400e!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .branch>*{fill:#dbeafe!important;stroke:#60a5fa!important;color:#1e40af!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .branch span{fill:#dbeafe!important;stroke:#60a5fa!important;color:#1e40af!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .branch tspan{fill:#1e40af!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .leaf>*{fill:#f8fafc!important;stroke:#cbd5e1!important;color:#334155!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .leaf span{fill:#f8fafc!important;stroke:#cbd5e1!important;color:#334155!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .leaf tspan{fill:#334155!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .result>*{fill:#dcfce7!important;stroke:#16a34a!important;color:#166534!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .result span{fill:#dcfce7!important;stroke:#16a34a!important;color:#166534!important;}#mermaid-svg-Lf37DaaaPl9kN4kU .result tspan{fill:#166534!important;} Runnable
Sleep
线程响应延迟升高
线程状态?
CPU 调度延迟
线程阻塞延迟
Runqueue 等待
实时任务 / CPU 亲和性
任务迁移 / CPU Quota
Mutex / 条件变量 / Futex
I/O / 网络阻塞
缺页 / 内存回收
perf / ftrace 分析时间线
结合业务时间戳定位延迟来源
十、结合工业机器人系统理解 CPU 调度
10.1 以 200 Hz 图像采集链路为例
假设机器人视觉系统存在以下线程:
text
Camera Acquisition 200 Hz
↓
Image Processing
↓
Seam / Target Detection
↓
Control Decision
↓
Robot Communication 60 Hz
200 Hz 意味着图像的理论采样周期为:
Tcamera=1200=5 ms T_{\text{camera}} = \frac{1}{200} = 5\ \text{ms} Tcamera=2001=5 ms
60 Hz 的位姿更新周期约为:
Trobot=160≈16.67 ms T_{\text{robot}} = \frac{1}{60} \approx 16.67\ \text{ms} Trobot=601≈16.67 ms
采集线程每隔约 5 ms 就要处理一次图像到达事件。但某次图像延迟处理,并不一定意味着相机采集不稳定。可能是图像已经通过 DMA 或驱动进入缓存,但对应用户线程尚未获得 CPU;也可能是 ROS 2 Executor 中负责回调的线程正在执行其他回调。
所以应把时间线拆分为:
text
硬件曝光或采集完成
↓
驱动收到数据
↓
ROS 2 / SDK 数据就绪
↓
工作线程被唤醒
↓
等待 CPU 调度
↓
开始执行数据处理
↓
处理完成
如果想知道延迟是否来自 Linux CPU 调度,关键是找到:
Tready→running T_{\text{ready} \rightarrow \text{running}} Tready→running
而不能简单地把相机数据产生到算法结果输出的整个时间都归因于调度器。
10.2 ROS 2 MultiThreadedExecutor 为什么不能保证实时性
MultiThreadedExecutor 允许多个执行线程并发处理 ROS 2 回调。但它不保证所有回调立即并行,也不保证回调之间的时间隔离。其执行表现还受到 Callback Group、Executor 就绪实体选择、底层线程调度和回调阻塞行为的影响。
例如一个图像推理回调执行时间较长,而 Executor 的可用线程数量有限,就可能影响其他回调的开始时间。如果高频控制与较慢的算法推理被放在同一个执行资源池中,即使使用 MultiThreadedExecutor,也可能发生资源竞争。
因此,在设计 ROS 2 实时性要求较高的系统时,更值得优先考虑的是:明确关键链路、限制单次回调执行时间、减少共享资源阻塞,并将关键处理与非关键处理在执行资源上适当隔离。调度策略与绑核则应在理解真实负载后再调整。
10.3 为什么不建议将全部线程设置为 SCHED_FIFO
假设系统有三个线程:
- 相机采集 FIFO 80
- 算法推理 FIFO 70
- 机器人通信 FIFO 60
表面上看,线程优先级安排非常明确。但如果相机采集线程中存在无界循环或长时间锁持有,它就可能显著延迟下面两个线程的执行。而视觉推理的执行时间常随输入图像复杂度、缓存状态和模型路径变化。这类负载不一定适合直接使用持续高优先级的 FIFO 调度。
相比之下,可以考虑将真正执行时间短、周期要求严格的线程独立出来;相机采集与推理通过有界队列或缓冲区进行解耦;非关键算法保持普通公平调度。
调度优先级解决的是 CPU 竞争顺序,不解决算法执行时间无界的问题。
10.4 用 C++ 实测周期唤醒延迟
下面这个例子使用 clock_nanosleep() 的绝对时间模式,在 Linux 中实现一个 200 Hz 的周期唤醒实验。它不需要实时调度权限,适合直接在 Ubuntu 上运行。
cpp
#include <cstdint>
#include <iostream>
#include <ctime>
#include <cerrno>
constexpr int64_t PERIOD_NS = 5'000'000; // 5 ms
constexpr int ITERATIONS = 2000;
int64_t to_ns(const timespec& ts) {
return static_cast<int64_t>(ts.tv_sec) * 1'000'000'000 + ts.tv_nsec;
}
void add_ns(timespec& ts, int64_t ns) {
ts.tv_sec += ns / 1'000'000'000;
ts.tv_nsec += ns % 1'000'000'000;
if (ts.tv_nsec >= 1'000'000'000) {
++ts.tv_sec;
ts.tv_nsec -= 1'000'000'000;
}
}
int main() {
timespec next{};
clock_gettime(CLOCK_MONOTONIC, &next);
int64_t max_late_ns = 0;
for (int i = 0; i < ITERATIONS; ++i) {
add_ns(next, PERIOD_NS);
int rc;
do {
rc = clock_nanosleep(
CLOCK_MONOTONIC,
TIMER_ABSTIME,
&next,
nullptr
);
} while (rc == EINTR);
if (rc != 0) {
std::cerr << "clock_nanosleep error: " << rc << '\n';
return 1;
}
timespec actual{};
clock_gettime(CLOCK_MONOTONIC, &actual);
int64_t late_ns = to_ns(actual) - to_ns(next);
max_late_ns = std::max(max_late_ns, late_ns);
}
std::cout << "Max wakeup lateness: " << max_late_ns / 1000.0 << " us\n";
}
编译运行:
bash
g++ -std=c++17 -O2 sched_test.cpp -o sched_test
./sched_test
这个实验测量的是实际返回执行时间相对于计划唤醒时间的延迟,里面可能包含计时器唤醒、调度、CPU 电源状态等影响,并不是纯粹的调度器决策耗时。实验时可分别在系统空闲、CPU 满载、限制 affinity 等情况下运行,比较结果。
为了进行更严谨的实时实验,还应统计 P99、P99.9、最大值以及是否发生周期超时;对可能超时的生产代码,还要明确跳过周期、追赶周期或者丢弃过期数据的策略。
10.5 高频系统最重要的是控制抖动,而非只追求平均性能
假设一条视觉链路在多次执行中的耗时如下:
- 方案 A:大多数时候更快,但存在一次 5.8 ms 的长尾延迟
- 方案 B:平均执行时间更长,但结果较稳定
对于 5 ms 周期的实时处理链路,如果这段时间已经占用全部周期预算,方案 A 的一次长尾便可能导致超时。因此,工程上需要同时关注吞吐量、平均延迟、尾部延迟和截止时间违约次数。
同时,硬件触发、时间戳关联、缓冲区管理和线程调度需要协同设计。不能依靠提高 CPU 调度优先级来弥补不可靠的设备采样时间基准,也不能仅靠提高线程优先级来解决整条链路的长尾延迟。
总结
可以将这次学习的知识体系浓缩为:
Linux CPU Scheduling
├── 调度对象
│ ├── task_struct
│ ├── 线程状态
│ └── Runnable / Running / Sleeping
├── 调度核心
│ ├── Per-CPU Runqueue
│ ├── Scheduling Class
│ ├── __schedule()
│ └── context_switch()
├── 调度算法
│ ├── Fair:CFS → EEVDF
│ ├── RT:FIFO / RR
│ └── Deadline:EDF + CBS
├── 内核执行机制
│ ├── 抢占
│ ├── 中断
│ ├── PREEMPT_RT
│ └── 优先级继承
├── 多核调度
│ ├── Scheduling Domains
│ ├── Load Balancing
│ ├── CPU Affinity / NUMA
│ └── PELT / EAS
└── 工程性能分析
├── 调度延迟
├── CPU 资源竞争
├── cgroup CPU 配额
├── perf / ftrace
└── 实时性与 Jitter
Linux 调度并不只是时间片轮转,而是一整套将任务状态、资源权重、实时约束、处理器拓扑和系统抢占机制联系起来的执行资源管理系统。
对于 C++、ROS 2 和机器人软件开发,进一步深入的重点应放在线程唤醒路径、调度延迟、跨核迁移、锁竞争、优先级反转,以及 PREEMPT_RT 对控制链路时延的影响。这些内容直接决定了你能否从"程序偶尔卡顿"这一表面现象,深入定位到究竟是内核调度、共享资源竞争、内存访问还是设备通信引入的延迟。