☰
同一段控制代码,有时只运行几十微秒,有时却慢了好几倍?从 CPU 缓存、缺页到锁竞争的定位实战
2026/10/28 1:54:10 网站建设 项目流程

在工业控制、伺服驱动和机器人控制软件中,有一种问题很难通过普通功能测试发现:同一段控制代码,在大多数周期内只运行几十微秒,系统运行一段时间后,却偶尔出现执行时间翻倍、延迟突然增大,甚至超过控制周期的情况。更让人困惑的是,程序没有崩溃,通信状态正常,CPU 总体利用率也不高,单步调试时更难复现。

例如,一个控制任务按照 1 ms 的周期运行,每次读取反馈数据、执行控制算法、更新目标输出。在轻负载下,算法计算可能只需要几十微秒。但在系统负载变化、缓存状态改变、内存访问异常或多个线程竞争共享资源时,某次执行可能明显变慢。如果控制任务必须在本周期截止时间前完成,那么这种偶发的执行时间增长,就可能演变为周期超时、输出更新滞后或控制性能下降。

这里首先要区分两个概念:代码执行时间和任务响应时间。代码执行时间通常关注某段代码从开始到结束消耗了多少时间;任务响应时间还可能包含唤醒延迟、等待 CPU、等待锁、等待 I/O,以及其他调度和同步开销。很多工程问题看起来像是“算法突然变慢”,实际却是线程在执行算法之前等待了很久,或者在算法内部等待了共享资源。

因此,定位执行时间波动不能只看一条总耗时日志,也不能简单地把所有异常归因于 CPU 性能不足。正确的方法是先统一测量边界,再把总时间拆成可以验证的阶段,逐步排查 CPU 缓存、内存管理、同步机制、系统调用和线程调度等因素。只有确定额外时间究竟花在哪里,优化才有明确方向。

一、先确定测量的到底是什么:执行时间、调度延迟还是端到端延迟?

实时系统优化经常遇到一个问题:团队里每个人都在讨论“延迟”,但实际上测量的并不是同一个指标。有人测量算法函数的执行时间,有人测量线程从唤醒到运行的时间,还有人测量从收到外部指令到输出更新的整体耗时。如果这些指标混在一起,测试结果很容易互相矛盾。

1. 同一个控制周期,至少有三种不同的时间指标

假设某个控制任务的周期为 1 ms,可以将一个周期粗略拆分为以下阶段:

周期触发 / 任务唤醒 ↓ 线程获得 CPU ↓ 读取反馈与准备数据 ↓ 执行控制算法 ↓ 更新目标输出 ↓ 完成本周期工作

在这个过程中,可以分别测量:

第一,线程唤醒延迟。从预期唤醒时刻到线程真正开始运行的时间差。它主要反映调度、定时器和系统干扰等因素,但具体定义必须明确。例如,线程被唤醒和线程开始执行并不是同一个事件。

第二,代码执行时间。从某段代码入口到出口的时间差。它可能包括指令执行、缓存未命中、内存访问、锁等待和函数内部系统调用等开销,具体取决于测量边界。

第三,端到端响应时间。从系统定义的起点,例如指令到达或采样时刻,到目标输出更新或设备反馈确认的时间差。这个指标更贴近实际控制需求,但它可能同时包含通信、排队、调度、计算和设备处理时间。

如果函数入口到出口耗时为 40 μs,并不意味着线程在周期触发后 40 μs 就能完成任务。假设线程启动前等待了 200 μs,那么从预期触发时刻到函数结束的时间至少还需要加上这部分等待,此外还可能存在数据准备和输出提交时间。

因此,在报告中应明确每个指标的起点、终点和包含的处理环节。否则,单独报告“控制算法执行时间 40 μs”并不能证明 1 ms 周期一定满足要求。

2. 使用单调时钟测量代码段耗时

对于 Linux 用户态程序,可以使用clock_gettime()测量一段代码的耗时。下面是一个简化示例:

#include <stdint.h> #include <stdio.h> #include <time.h> static uint64_t monotonic_ns(void) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { return 0; } return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; } int main(void) { uint64_t start; uint64_t end; start = monotonic_ns(); /* 替换为实际控制计算代码 */ control_algorithm(); end = monotonic_ns(); if (start != 0 && end >= start) { printf("execution time: %llu ns\n", (unsigned long long)(end - start)); } return 0; }

这里的control_algorithm()只是示意,实际程序需要包含相应的函数声明和实现。示例也没有覆盖所有错误处理,因此不能直接作为生产级实时测量代码使用。

选择CLOCK_MONOTONIC的原因是,它适合测量同一时钟域内的时间间隔,不会像日历时间那样因系统时间调整而出现不适合计算耗时的问题。需要注意的是,时钟读数以纳秒表示,不意味着测量精度、分辨率或系统响应能力一定达到纳秒级。

另外,测量操作本身也有成本。对于只运行几微秒的函数,时钟读取开销可能占据一定比例。应先测量空测量路径的开销,并通过重复实验评估其影响。不能简单地把每次测得的耗时都当成未经扰动的真实执行时间。

3. 不要只记录平均值,必须关注尾部和最大值

假设一段控制算法运行了 100 万次,其中绝大多数样本在 30~40 μs 之间,但有少数样本达到 200 μs。平均值可能看起来仍然不错,却掩盖了真正影响实时性的长尾事件。

建议至少统计:

  • 最小值、平均值和最大值。

  • P50、P95、P99 等分位数。

  • 超过执行预算的次数。

  • 超过周期截止时间的次数。

  • 连续超时次数与超时发生时的系统状态。

分位数和最大值应结合样本量、运行时间和测试负载解释。有限时间内没有观察到更长的执行时间,不等于证明系统不存在更坏的情况。对于严格实时系统,有限样本的压力测试只能提供证据,不能单独构成数学意义上的最坏执行时间保证。

二、CPU 缓存、内存访问和缺页:为什么计算量相同,耗时却不同?

当代码逻辑和输入数据基本一致时,CPU 执行时间仍可能因硬件微架构和内存系统状态而变化。对实时程序而言,缓存命中情况、内存访问模式、页面映射和缺页处理都可能造成执行时间波动。

1. CPU 缓存未命中会增加数据访问成本

现代处理器通常采用多级缓存。CPU 执行指令时,相关数据可能已经位于 L1 或 L2 缓存中,也可能需要访问更低层级的缓存或主存。不同路径的访问延迟存在差异。

假设控制算法需要遍历一组反馈数据。如果这些数据在连续周期内被重复使用,它们可能保持在缓存中,访问较为高效。但如果另一个线程同时处理大量数据,或者控制任务需要访问一个规模很大的、访问模式不规则的数据结构,缓存命中率可能下降。

这会导致相同的计算逻辑出现不同的执行时间。

不过,缓存未命中并不意味着一定会造成严重抖动。实际影响取决于处理器架构、缓存层级、数据规模、访问局部性和并发任务行为。需要通过测量验证,而不是只凭理论推断。

2. 数据布局可能比增加 CPU 频率更值得优化

假设某个控制任务需要访问多个轴的反馈值。如果数据分散在大量独立对象中,算法执行时可能频繁访问不连续的内存区域。如果把周期性使用的数据整理为连续结构,并减少不必要的指针跳转,就可能改善缓存局部性。

例如:

struct AxisFeedback { double position; double velocity; double torque; }; struct AxisFeedback feedback[AXIS_COUNT];

这是一种简单的连续数组布局示例。它是否优于其他数据结构,取决于实际访问模式、轴数量和算法组织方式。

如果算法每个周期都遍历所有轴,那么连续存储通常更容易形成良好的空间局部性;如果算法只访问少量轴,或者多个线程需要分别更新不同字段,则还要考虑缓存行竞争和数据同步问题。

优化时可以优先检查:

  • 是否在周期内反复分配和释放小对象。

  • 是否存在大量不必要的指针跳转。

  • 是否反复复制同一批反馈数据。

  • 是否有多个线程频繁读写同一组共享变量。

  • 是否把与实时计算无关的大型数据结构放入关键执行路径。

但不应仅凭代码表面结构就断言缓存问题是根因。必要时应结合硬件性能计数器或对照实验,验证优化前后的缓存行为和执行时间分布。

3. 缺页可能把短代码变成一次长时间等待

缺页是实时系统中需要重点关注的因素之一。

当程序访问某个虚拟地址时,如果相应页面尚未建立所需映射,或者访问触发了需要操作系统处理的缺页事件,CPU 就可能进入内核处理路径。处理过程可能涉及页面映射、内存分配、文件读取或其他操作,具体开销取决于缺页类型和系统状态。

因此,某段代码在绝大多数周期内只访问几十微秒,但第一次访问某块内存时耗时明显增加,并不一定是算法计算量发生了变化。

这里要区分不同情况:

  • 轻微缺页可能只涉及建立或更新页面映射。

  • 匿名内存首次触碰可能触发实际物理页面分配和清零。

  • 文件映射相关缺页可能涉及存储访问。

  • 内存压力下的回收和换页可能进一步扩大延迟。

并非每次缺页都会导致同样的耗时,也不能把所有缺页都直接归类为磁盘 I/O。关键在于确认实时路径是否可能遇到不可预测的内存管理工作。

4. 预分配、预触碰和锁定内存应当配合使用

在具有严格时间约束的控制任务中,常见的工程措施包括:

  • 在系统初始化阶段完成必要的内存分配。

  • 对周期任务使用的缓冲区进行预触碰,提前访问相关页面。

  • 避免在关键周期中反复创建和销毁大量对象。

  • 根据实时性要求评估是否需要使用mlockall()锁定进程内存。

  • 控制内存使用量,避免因为锁定过多内存而影响系统其他任务。

Linux 用户态程序可以考虑以下接口:

#include <sys/mman.h> if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) { /* 记录错误,并按系统设计决定是否允许进入实时运行状态 */ }

该示例展示了锁定当前和未来映射内存的基本调用方式,不是完整的生产实现。使用时需要考虑进程权限、资源限制、可用物理内存和失败处理。锁定内存也不代表所有可能的延迟来源都被消除。

预触碰内存同样需要正确实施。仅仅分配一块内存,并不一定意味着所有相关页面都已建立映射;实际效果还与分配器、映射类型和操作系统行为有关。应在目标系统上验证,而不是把某个 API 调用当作实时性保证。

三、锁竞争、动态内存和日志:代码看起来在计算,实际上可能一直在等待

如果 CPU 缓存和缺页不足以解释执行时间波动,就需要检查软件自身的同步机制与运行时行为。很多“计算突然变慢”的问题,实际上来自互斥锁、内存分配器、日志输出和其他不可预测的操作。

1. 互斥锁可能让短代码等待很久

考虑一个多线程控制程序:

  • 通信线程更新设备状态。

  • 业务线程修改目标参数。

  • 控制线程读取目标参数并计算输出。

  • 日志线程收集状态信息。

如果这些线程通过同一把互斥锁访问共享结构,控制线程就可能在进入算法之前等待其他线程释放锁。

假设控制算法本身只需要 35 μs,但它进入关键代码前等待锁 150 μs,那么如果测量边界包含这段等待,记录到的时间就可能接近 185 μs。此时,算法的算术运算并没有变慢,变化来自同步等待。

排查时应明确测量区间是否包含加锁、解锁,以及等待锁的时间。可以分别记录:

  • 请求获取锁的时间。

  • 成功获取锁的时间。

  • 持锁开始与结束时间。

  • 等待时间和持锁时间的最大值。

  • 发生长等待时的线程状态。

不过,单纯记录锁等待时间仍不一定能够定位持锁者。还需要结合线程 ID、调用路径或其他跟踪手段,确认是谁持有锁、为什么持有这么久。

2. 优先级反转可能让高优先级任务受低优先级任务影响

优先级反转是实时系统中常见的同步问题。

例如,低优先级线程获得了一把锁,高优先级控制线程随后需要同一把锁,于是控制线程阻塞。此时,如果中优先级线程持续运行并妨碍低优先级线程释放锁,那么高优先级控制线程的等待时间就可能进一步增加。

这并不是简单的“高优先级线程被低优先级线程抢占”,而是高优先级线程被共享资源依赖关系间接阻塞。

对于需要严格时限的线程,应根据系统设计评估互斥锁的优先级继承等机制。例如,POSIX 线程环境在支持相应协议时,可以通过pthread_mutexattr_setprotocol()配置互斥锁协议。但是否支持、是否正确生效以及是否适合具体应用,都应在目标系统上验证。

更重要的是,优先级继承不能替代合理的锁设计。应尽量缩短持锁时间,避免持锁期间执行阻塞 I/O、等待其他线程或进行不必要的复杂计算。

3. 动态内存分配会带来额外的不确定性

malloc()、free()以及 C++ 的动态对象创建和销毁,在某些程序中会引入锁竞争、内存管理开销和页面访问开销。

在普通业务程序中,这些成本通常可以接受。但在高频实时任务中,如果每个周期都动态分配多个对象,执行时间就可能受到分配器内部状态、并发访问和内存碎片等因素影响。

例如,一个 1 ms 周期的控制任务每次都临时构造一批对象,即使平均分配耗时很短,偶尔发生的慢路径仍可能消耗大量时间预算。

可以优先考虑:

  • 在初始化阶段分配固定容量的缓冲区。

  • 对周期内的数据结构采用可复用对象。

  • 使用固定容量队列或经过验证的内存池。

  • 避免在关键路径中频繁创建和销毁复杂对象。

  • 对容量不足的情况定义明确的异常策略,而不是无界扩容。

不过,内存池并不是自动具备硬实时特性的方案。如果内存池本身使用共享锁、容量管理存在长时间扫描,或者失败时退回通用分配器,仍然可能引入不可预测的延迟。

4. 同步日志可能比控制算法更慢

开发阶段经常使用printf()或文件日志记录控制变量、算法结果和线程状态。问题在于,日志接口可能涉及格式化、共享锁、缓冲区管理、终端输出或文件 I/O。

如果关键控制线程在每个周期都同步输出大量日志,那么一次输出操作就可能远远超过原本的算法耗时。

例如,控制函数正常只需要几十微秒,但日志路径遇到缓冲区刷新或输出设备繁忙时,函数整体耗时可能明显增加。

更合理的设计通常是让实时线程只写入轻量级、预分配的记录缓冲区,由低优先级线程异步整理和输出日志。还可以在异常时记录必要的事件编号、计数器和时间戳,避免每个周期都执行复杂格式化。

但异步日志也需要考虑环形缓冲区容量、生产者与消费者之间的同步,以及缓冲区满时的行为。不能让实时线程在缓冲区满时无限等待日志线程,否则原本为了避免阻塞设计的日志系统,仍然可能成为新的阻塞源。

四、从调度、系统调用到硬件性能计数器:怎样把异常时间定位到具体原因?

当执行时间波动无法通过简单的代码检查解释时,应结合 Linux 的线程状态、调度跟踪和性能分析工具,判断额外时间到底消耗在哪里。

1. 先确认 CPU 负载和线程运行状态

可以使用:

mpstat -P ALL 1

观察各 CPU 的使用情况。

查看线程的调度信息:

ps -eLo pid,tid,psr,cls,rtprio,pri,ni,stat,comm

查看指定线程的实时调度策略:

chrt -p <TID>

查看线程的 CPU 亲和性:

taskset -pc <TID>

这些工具能够帮助判断线程是否运行在预期 CPU 上、是否采用预期调度策略,以及系统是否存在明显的 CPU 饱和问题。

但 CPU 总体利用率低,不代表实时任务不会超时。一个线程可能在等待锁、I/O 或其他同步条件,导致任务完成时间很长,而 CPU 仍然处于空闲状态。

相反,CPU 利用率高也不一定意味着实时任务一定超时。是否超时取决于关键线程获得 CPU 的时机、实际执行需求和截止时间预算。

2. 使用调度跟踪区分“等待 CPU”和“执行变慢”

在支持相关跟踪事件的 Linux 系统中,可以先检查:

trace-cmd list -e | grep -E 'sched_wakeup|sched_switch'

然后在可控的测试窗口中记录:

sudo trace-cmd record \ -e sched:sched_wakeup \ -e sched:sched_switch \ sleep 10

再查看记录:

trace-cmd report

分析时应关注目标线程何时被唤醒、何时进入可运行状态、何时真正获得 CPU,以及在异常周期中是否出现额外的等待或切换。

如果线程已经被唤醒,却很久以后才获得 CPU,那么需要进一步检查调度竞争、优先级配置、CPU 亲和性和内核活动。

如果线程很快获得 CPU,但测量的函数耗时仍然很长,那么应继续排查函数内部的锁等待、系统调用、内存访问和其他处理路径。

需要强调的是,调度事件不能直接证明 CPU 缓存未命中、缺页或锁竞争的根因。跟踪数据必须与应用层时间戳和具体调用路径结合使用。此外,启用跟踪本身也可能影响被测系统,应先评估开销,并在必要时使用更轻量的观测方式。

3. 使用perf观察 CPU 时间消耗

对于可以使用性能分析工具的目标 Linux 系统,可以通过perf获取进一步证据。例如:

sudo perf stat -p <PID> \ -e task-clock,context-switches,cpu-migrations,page-faults \ sleep 10

这个命令用于观察指定进程在采样窗口中的任务时钟、上下文切换、CPU 迁移和缺页等计数。具体事件的可用性、统计口径和权限限制取决于处理器、内核和perf版本。

需要注意,perf stat的进程级统计通常反映的是整个进程在统计窗口中的行为,不能自动把某次异常执行时间与某一次缺页或上下文切换对应起来。

如果需要定位具体代码路径,可以考虑对目标程序进行受控采样,例如:

sudo perf record -g -p <PID> -- sleep 10 sudo perf report

调用栈采样能够帮助发现 CPU 时间集中在哪些函数中,但它不一定能够完整解释阻塞等待的时间。一个线程在等待锁或 I/O 时可能没有持续消耗 CPU,因此单纯的 CPU 采样可能低估该路径对端到端延迟的影响。

对于硬件性能计数器,还可以根据处理器支持情况观察缓存未命中、分支预测失败等事件。不同 CPU 架构和型号的事件名称、含义与计数限制可能不同,不能把某个处理器上的事件定义直接套用到另一种处理器上。

4. 设计一次只改变一个变量的对照实验

如果直接同时修改 CPU 亲和性、线程优先级、内存分配方式和日志配置,即使性能改善,也很难确定真正起作用的是哪项改动。

更有效的方法是建立可重复的对照实验。

例如:

实验主要变化重点观察
A基准程序,不改变配置建立原始耗时分布
B关闭关键路径中的同步日志观察尾部延迟是否变化
C将周期内动态分配改为预分配观察最大耗时与缺页情况
D减少共享锁的持有时间观察锁等待与执行时间
E在受控条件下调整 CPU 亲和性观察调度延迟和 CPU 迁移
F增加系统背景负载观察长尾事件是否随负载变化

每次实验都应保持硬件、软件版本、测试时长和数据采集口径一致,并记录实际负载、温度与频率变化等可能影响结果的因素。

还应考虑测试本身的扰动。例如,开启高频日志或调用栈跟踪可能改变线程运行时序。因此,建议先在低开销条件下采集基准数据,再针对可疑阶段开展更细粒度的诊断。

五、如何把执行时间波动纳入实时系统工程验收?

执行时间优化不能只停留在“这次运行更快了”。工程上更重要的问题是:在规定的运行条件下,任务是否能按时完成?发生异常时,系统能否识别并采取正确措施?测试结果是否足以支持当前的性能结论?

1. 从平均执行时间转向完整的时间预算

假设某个控制任务的周期为 1 ms,系统需要在下一个周期截止时间前完成全部关键工作。工程师不能简单地认为“算法平均耗时 100 μs,所以还有 900 μs 余量”。

因为完整的周期预算还可能包括:

  • 周期任务的唤醒延迟。

  • 反馈数据读取和一致性处理。

  • 算法计算时间。

  • 锁等待与同步开销。

  • 输出数据准备和提交。

  • 与内核、驱动及硬件交互相关的时间。

  • 系统中其他必要的周期内处理。

这些时间并非在所有架构中都简单相加。有些操作可能重叠,有些阶段可能由不同线程完成。因此,应根据实际执行模型建立时间线,并确认每个阶段的起止点。

例如,可以分别记录任务开始时间、算法开始与结束时间、输出提交时间,以及周期结束时间。这样才能判断问题出在任务启动晚、算法耗时增长,还是输出路径过长。

2. 关注最大值和截止时间违例,而不只是平均值

对于具有明确周期约束的控制任务,应将执行预算和截止时间分别定义。

假设任务周期为 1 ms,但算法的预算只有 200 μs。即使算法没有超过 1 ms,也可能挤占同一周期中的其他必要处理时间,造成整个任务链路超时。

因此,应至少明确:

  • 任务周期。

  • 允许的最大启动延迟。

  • 算法执行预算。

  • 输出提交的截止时间。

  • 整个控制周期的完成截止时间。

  • 超时后的告警和故障处理策略。

测试报告应包含实际运行时长、样本数量、负载条件、统计方法以及观测到的最大值。有限样本中的最大值是已观测最大值,不应直接称为经过严格证明的最坏执行时间。

3. 核心隔离和实时调度优化应该建立在证据之上

在实时 Linux 系统中,CPU 亲和性、核心隔离、IRQ 亲和性和实时调度策略都可能影响关键线程的响应时间。

但它们解决的是不同层面的问题。

CPU 亲和性限制线程可以在哪些 CPU 上运行,不代表这些 CPU 上的其他活动都会自动消失。核心隔离旨在减少指定 CPU 上的部分调度干扰,但仍需要考虑中断分配、内核活动、内存系统、共享缓存、总线竞争和硬件电源管理等因素。

同样,将线程设为SCHED_FIFO也不能解决锁等待、动态内存管理和同步 I/O 引起的所有延迟。

因此,优化顺序应由测量结果决定:

  • 如果线程唤醒到实际运行之间存在明显延迟,优先分析调度和系统干扰。

  • 如果执行时间与缺页或首次内存访问相关,优先检查内存预分配和页面状态。

  • 如果耗时波动与锁竞争同步出现,优先缩短关键路径上的锁持有时间。

  • 如果异常主要发生在日志或系统调用阶段,应重新设计关键路径的阻塞行为。

  • 如果任务在固定负载下仍出现无法解释的长尾,应进一步检查硬件、内核、驱动和测量工具带来的影响。

对于望获rtLinux或其他实时 Linux 平台,应在相同硬件、内核与驱动版本、应用程序、负载和测量口径下比较优化前后的结果。只有当测试条件一致、指标定义明确、结果能够重复,才能判断优化是否真正改善了实时性。

4. 最终要形成可复现的性能基线

建议为关键实时任务建立一份长期维护的性能基线,至少包括:

  • 测试硬件与处理器型号。

  • 操作系统、内核和驱动版本。

  • 线程调度策略、优先级与 CPU 亲和性。

  • 任务周期和时间预算。

  • 背景负载与设备工作状态。

  • 测量起止点和时钟来源。

  • 样本数量、测试时长、分位数和最大值。

  • 缺页、上下文切换和超时等辅助指标。

  • 已知限制、测试边界和异常处理策略。

每次修改内核配置、驱动、编译选项或控制算法后,都应重新执行相关基准测试。如果只比较平均值,而忽略最大值和截止时间违例,就可能出现“平均性能改善了,但极端延迟反而变差”的情况。

总结:同一段控制代码执行时间不稳定,未必意味着算法本身变慢。CPU 缓存和内存访问可能改变执行成本,缺页可能触发额外的内核处理,互斥锁可能造成等待,动态内存和同步日志可能引入长尾,而线程调度延迟又可能进一步影响端到端响应时间。

真正有效的优化,应先把测量口径统一,再通过阶段化时间戳、调度跟踪、性能计数和对照实验寻找证据。对于实时系统而言,目标不仅是让代码平均运行得更快,还要让整个任务链路的耗时更可预测,并且能够验证它是否满足既定截止时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询