C++ 语言实战(八):实时性之谜——P99 抖动 47ms 的真凶

无锁缓冲上线后,振动通道的平均时延账面非常漂亮,但监控图上还剩一块擦不掉的阴影:采集循环平均 2ms,P99 偶发 47ms。这章没有新代码要写——这起排查是全系列里「代码之外」比重最大的一章,真凶藏在操作系统里。工业软件和互联网服务在这一点上分道扬镳:互联网可以重试,产线的实时数据丢一拍就永远缺一拍。所以工业级 C++ 工程师迟早要下到 OS 层去抓一次鬼,这章就是那次下潜的完整记录。
一、事故现场:平均 2ms,P99 47ms
先把指标说精确:每 100ms 一轮的采集循环,P50 = 1.9ms,P99 = 47ms,P99.9 = 120ms。尖刺不是持续恶化,而是全天随机撒点,每小时十几发,负载高时更密。47ms 是什么概念?200 台设备一拍采 2 万个点,尖刺那一拍整体迟到,时间戳对齐全乱,上位机的频谱分析直接把那一轮作废。
应用层的第一轮自查全部扑空:热点函数没有异常(perf 采样几乎采不到尖刺时刻的样本——这个细节后面是关键线索)、无锁缓冲空转、日志无慢调用。代码层面一切正常,问题大概率不在我们的代码跑得慢,而在我们的代码没得跑。这个反直觉的定性,是本章排查的分水岭。
二、排查过程:层层排除的五个假设
实时性排查的标准打法是把「尖刺时刻 CPU 在干什么」变成选择题,一个假设一个证据地排除:
| 假设 | 取证手段 | 证据 | 结论 |
|---|---|---|---|
| ① 代码热点变慢 | 分段计时打点 | 尖刺轮各阶段都正常,唯「整体消失 47ms」 | 排除:不是某段代码慢 |
| ② 被其他进程抢占 | perf sched timehist | 尖刺时未被抢占,run delay 正常 | 排除:不是排队 |
| ③ CPU 迁移 | perf sched / 迁移计数 | 迁移每小时 2100 次,但单次迁移只损失 µs 级 | 从犯:不是主犯 |
| ④ 页错误 | /proc/vmstat pgmajfault 对照尖刺时刻 | 尖刺时刻与 major page fault 完全同步 | 主犯锁定 |
| ⑤ 透明大页整理 | 关闭 THP 对比 | 关闭后尖刺频率减半 | 从犯:叠加因素 |
④ 的决定性证据来自两处:/proc/vmstat 里 pgmajfault 的增长时刻与尖刺时刻逐一对齐;/proc/<pid>/smaps 里网关自身的代码段 Swap: 字段赫然是 40MB+——一部分二进制代码页已经被交换到磁盘上。采集循环跑到某个从未被换出的分支时(比如那个每周只触发一次的重连逻辑),CPU 当场停住等磁盘,把 47KB 的代码页从 swap 读回来——这个 IO 的量级,正好就是几十毫秒。
回头看「perf 采不到尖刺样本」这个细节就通了:perf 采样的是 CPU 在跑你的代码的时间,而尖刺时刻 CPU 在内核态等 IO——工具的盲区本身就是线索。根因链:这台边缘盒子上还跑着离线分析任务,内存紧张,Linux 的内存回收(kswapd 乃至直接回收)选中了「最近不常用」的网关代码页和匿名页换出——Linux 的账本上它们是冷数据,在产线的账本上它们是下一拍就要执行的实时代码。
三、底层原理:延迟是被谁偷走的
页错误:minor 与 major 差四个数量级
CPU 访问的虚拟页不在物理内存时触发缺页异常,分两种:minor fault——页其实还在物理内存(page cache)或刚分配零页,内核补个映射即可,微秒级;major fault——页被换出到 swap 或根本没读过文件,必须发起磁盘 IO,毫秒到几十毫秒级。平时程序体会不到,因为代码段按需分页(demand paging):启动时只装填真正执行过的页。对吞吐型服务无所谓,对实时循环就是地雷——每一个「首次触碰」都可能是一次磁盘 IO。
内存压力下的回收再补一刀:Linux 会把不活跃的匿名页和文件页换出(swap)或丢弃(有文件背书的代码页在回收后重新按需读入)。工业边缘盒子普遍内存紧张、还跑着分析类负载,网关天然是「冷页」大户——不解决页常驻问题,延迟下限就由磁盘决定。
CPU 亲和性:迁移的代价不止一趟
调度器为了负载均衡会把线程在核间搬运。一次迁移,新核的 L1/L2 全冷,工作集要重新预热——微秒到百微秒级损失。单看不致命,但和另一件事叠加就难受:采集线程、解析线程、出口线程被调度到不同核上,互相把对方的缓存行搬来搬去(呼应 ch07 的伪共享,只是这次战场在核间)。绑核(sched_setaffinity 或启动时 taskset)不是为了快,是为了稳——把「线程在哪」这个变量从延迟公式里删掉。
调度优先级:CFS 的公平不等于实时
默认的 SCHED_OTHER 是完全公平调度器(CFS)的地盘:按虚拟运行时间公平分片,谁也别想饿死——这套哲学对实时任务是灾难,因为「公平」意味着你可能随时被插队。SCHED_FIFO / SCHED_RR 是实时调度类:只要实时线程想跑,就跑赢所有普通线程,直到主动让出。代价与纪律并存:实时线程若写出死循环,普通线程集体饿死——内核用 rt_throttled(默认实时组最多吃 95% CPU)兜底。所以实时优先级是「外科手术式」工具:只给采集线程,优先级只提到能赢过后台负载的程度(比如 30),绝不无脑拉满。
四、正确姿势:把变量从延迟公式里删掉
修复四板斧,按性价比排序:
// ① 锁页:启动时把当前和未来的内存都钉在物理内存里
if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
perror("mlockall (need CAP_IPC_LOCK / ulimit -l)"); // 失败要可见,不能静默
}
// 预热:主动触碰代码与数据页,避免首次触碰的按需分页
warmup_all_paths(); // 启动后把所有分支跑一遍(含重连、异常路径)
// ② 绑核:采集线程钉在 2 号核(配合启动脚本 isolcpus/cpuset 隔离)
cpu_set_t set;
CPU_ZERO(&set); CPU_SET(2, &set);
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
// ③ 实时调度:SCHED_FIFO 优先级 30,只给采集线程
sched_param sp{.sched_priority = 30};
pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
第四板斧在系统层:关闭透明大页(THP 的后台合并 khugepaged 会制造毫秒级停顿,工业盒子一律 never)、swap 直接关掉或 vm.swappiness=1、采集核用 isolcpus 从调度器里隔离出去。最后把系统级信号接进监控:pgmajfault 增速、CPU 迁移次数、调度延迟(/proc/<pid>/schedstat)与业务 P99 并排画图——这一次的教训是监控必须覆盖 OS 层,应用层指标看不见的地方正是事故发生地。
顺带回应一个必然的质疑:mlockall 会不会把内存钉死导致系统更紧张?会,所以要配合容量规划——网关进程 RSS 31MB(ch01 的老数字),钉住的成本几乎可忽略;分析类大负载反而应该被 cgroup 限住内存。资源隔离是双向的:我钉我的,你限你的。
五、数据说话
修复前后同一台边缘盒子、同一负载连续 72 小时:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 采集循环 P50 | 1.9ms | 1.9ms |
| P99 | 47ms | 3.6ms |
| P99.9 | 120ms | 5.8ms |
| major page fault/小时 | 37 | 0 |
| CPU 迁移/小时 | 2,100 | 0 |
注意 P50 纹丝没动——这一章所有优化的收益全部在尾部。这正是工业软件与互联网的又一个分野:互联网看平均(用户体验均值),产线看 P99(一拍迟到整轮作废)。也因此本章的延迟思维值得单独背下来:先分位数定位(P99 是谁贡献的),再按「删变量」的思路修(页常驻删掉 IO 变量、绑核删掉迁移变量、实时优先级删掉排队变量),而不是笼统地「优化代码」。
六、面试怎么答
Q1:什么是页错误?minor 和 major 有什么区别? 定义(虚拟页不在页表时的缺页异常)+ 两分法:minor 页在物理内存/page cache,内核补映射即可,µs 级;major 需要磁盘 IO,ms 级起步。加分项:讲 demand paging 与内存回收——「代码段平时不加载,冷页会被换出」,然后接 47ms 事故:mlockall + 预热解决。能从面试题滑进事故复盘,就是工业经验的直接证明。
Q2:CPU 亲和性什么时候用? 先讲代价(迁移后缓存全冷、NUMA 跨节点访问)再讲用法(延迟敏感线程绑核 + isolcpus 隔离,把「线程在哪」变成确定项)。边界也要说:吞吐型服务不该乱绑——绑核牺牲负载均衡,调度器本来做得挺好。一句话收尾:亲和性是确定性的工具,不是性能工具。
Q3:Linux 的实时调度和普通调度有什么区别?什么时候用 SCHED_FIFO? CFS 按虚拟运行时间公平分片 vs SCHED_FIFO/RR 抢占式(实时类只要可运行就优先于普通类)。使用纪律三条:只给极少数线程、优先级克制(30 级别而非 99)、知道 rt_throttled 兜底(默认 95%)防止实时线程饿死系统。能主动讲「实时优先级会放大代码 bug 的破坏力,上之前先审代码」是工程成熟度信号。
Q4:怎么做一个低延迟程序? 按层答:应用层(无锁/预分配/避免首次触碰)、内存层(mlockall + 预热 + 禁 swap + 关 THP)、调度层(绑核 + 隔离核 + 实时优先级)、监控层(pgmajfault/迁移/调度延迟进指标,看 P99 而非平均)。最后一句方法论:「低延迟不是优化快,是删掉延迟公式里所有不确定的变量」——这是本章的正字标记。
七、落地清单
- 实时进程上线前:mlockall(MCL_CURRENT|MCL_FUTURE) + 全路径预热,失败必须可见告警
- 延迟敏感线程绑核 + isolcpus/cpuset 隔离,与后台负载物理分区
- 只给采集类线程 SCHED_FIFO(优先级 ≤50),上线前审查代码防饿死
- 边缘盒子一律关闭 THP,swap 关闭或 swappiness=1,内存用 cgroup 分区管理
- 监控加 OS 层三件:pgmajfault、CPU 迁移、调度延迟,与业务 P99 并排
- 性能指标永远看分位数:P99/P99.9 才是实时性的语言,平均会骗人
- 排查尖刺先定性「CPU 在干什么」:perf 采不到自己 = 去内核态和系统层找
47ms 的鬼抓到了,采集链路从 P50 到 P99.9 全部归位。下一章回到纯代码战场:几百路信号的缩放公式被写成几百个 if-else,运行时多花的每一纳秒,其实都能搬回编译期——模板与 constexpr 的零开销抽象。
