目录

Linux 内核避噪指南

导读: 对于高频交易、实时系统或 DPDK 网络包处理等极端工作负载,毫秒级的延迟波动(Jitter)都是不可接受的。本文基于 SUSE Labs 的研究,深入剖析 Linux 内核如何通过 CPU 隔离Full Dynticks (Nohz_full) 技术,实现“极致的确定性”。

内核的本职工作是提供服务(系统调用)并维护系统状态(内务管理)。虽然大部分“内务管理”对普通用户无感,但对于需要独占 CPU 的高性能任务来说,它们就是破坏性能的"噪音"。

如果你的任务需要 100% 的 CPU 时间,任何来自内核的微小干扰都会导致 CPU 缓存(Cache)被冲刷,或者导致处理延迟。

  • 主要来源: 页面回收(内存交换)、工作队列、定时器中断
  • 受害者: DPDK(用户态轮询)、虚拟化宿主机、基准测试工具。

在标准 Linux 中,每个 CPU 都会以 100Hz 到 1000Hz 的频率触发周期性中断。

💡 核心概念补充:定时器中断 (Tick) 定时器中断是操作系统的“心跳”。它强制打断当前运行的任务,执行以下关键操作:

  • 任务调度: 检查当前任务的时间片是否用完,决定是否切换任务。
    • 负载均衡:检查系统内其他核心是否太忙,尝试把任务从忙的核心拉到闲的核心
    • 强制抢占:如果有更高优先级的任务(比如内核线程)突然醒来,调度器要负责踢走当前任务
    • 时间片计算:更新进程运行了多久,是否需要重新计算优先级
    • 状态迁移:当任务因为等待 I/O 或锁而阻塞时,调度器将其移出运行队列;当事件到达时,将其唤醒
  • 维护系统时间: 更新墙上时间(几点几分)和 jiffies(开机以来的节拍数) 。
  • 负载统计: 统计 CPU 的负载(Load Average)、进程的 CPU 使用率。
  • 资源回收: 触发 RCU 回调等,检查是否有已经可以释放的内存或过期的回调函数。
  • 定时器处理: 检查用户或内核注册的定时器(如 sleep 或网卡超时定时器)是否到期。

影响: 即使 CPU 上只运行这一个任务,Tick 依然会打断它,造成微秒级的延迟和抖动。

事实上,当 CPU 无需工作时,就没有任务调度器需要维护,没有定时器排队,也没有定时用户。 “节能”成为打破周期性定时器的第一个诱因,因为它是专门针对 CPU 空闲状态的优化,由此进入第一个阶段:

  • 阶段一:CONFIG_NO_HZ_IDLE (Tickless Idle)
    • 原理: 当 CPU 空闲(没有任务跑)时,停止 Tick。
    • 目的: 主要为了省电。
    • 局限: 一旦 CPU 忙碌(运行任务),Tick 就会恢复,依然会干扰任务。

由于,定时单次事件(如计时器回调)和周期性事件(如调度程序、计时、RCU 等)的多个子系统,均依赖时钟中断,因此,CPU 执行任务时若要关闭时钟中断,要么用替代机制提供服务,要么在技术受限情况下缩减服务范围。经过不断地迭代优化,才最终走入第二阶段:

  • 阶段二:CONFIG_NO_HZ_FULL (Full Dynticks)
    • 原理: 即使 CPU 正在运行任务,也停止 Tick。
    • 目的: 实现性能隔离,消除抖动。
    • 条件:
      • ① 该 CPU 上只能运行一个可运行任务
      • ② 任务不能使用某些特定内核服务,如 POSIX CPU 定时器或某些性能监控事件
      • ③ 系统需要稳定的时钟源(如 x86 架构上的 TSC)

讲讲稳定的时钟源,通常指硬件层面的 TSC(Time Stamp Counter)。它是 CPU 内部的一个寄存器,记录自开机以来的处理器周期数。

内核在启动 nohz_full 前会检查 TSC 是否具有 CONSTANT(频率恒定)和 INVARIANT(不受节能影响)属性,否则会开启失败。

首先我们需要明确一些知识:物理世界的滴答来自硬件(晶振),但“知道过去了 1ms”并“让整个操作系统动起来”则是内核的任务

  • 硬件层(闹钟): 内核在启动时会编程一个硬件定时器(比如 Intel 的 LAPIC Timer)。内核告诉它:“请根据你的晶振频率计算,每隔 1ms 给我发一个电信号(中断)”。
  • 内核层(动作): 硬件电信号一响,CPU 就会被强行打断,跳到内核预设的中断处理程序里。这时内核才真正介入,它会把全局变量 jiffies 加 1(代表过去了一个滴答),然后检查是否要切换进程、是否要运行过期任务。

传统模式下,内核与硬件是同步耦合的。硬件每打一次铃,内核就得“起一次床”,更新内核的全局时间变量,如 xtimejiffies

而当开启 nohz_full 时,则无法通过数铃声(Tick 次数)计算时间,它必须主动去查表得到这个时间变量。若变量在 L1/L2 缓存里,则需要通过 MESI 协议 进行缓存一致性同步;若变量在主存中,则产生数百时钟周期的延迟;但依靠本地 TSC,直接读取自己核心内部的寄存器,延迟仅为几个时钟周期

没有稳定的硬件 TSC,内核可能被迫回退到使用 HPETACPI Power Management Timer 等外部硬件, 访问这些外部硬件需要通过总线,速度比直接读取 CPU 内部的 TSC 慢几十倍,通常就不建议开启了。

要让一个正在忙碌的 CPU 停止心跳(Tick),内核必须找到替代方案来处理那些原本依赖 Tick 的工作。

对于 RCU 和计时器,采取“转移负载”的手段,有些工作不需要在本地 CPU 上做,让它们去别的核跑。

  1. 未绑定的定时器 & 工作队列: 内核会自动把迁移到非隔离的 CPU 上,不让它们在隔离核上触发 。

  2. RCU (Read-Copy-Update) 回调:

    • 默认情况下,RCU 的清理工作(回调)会在产生它的 CPU 上执行 。
    • 解决方案: 启用 CONFIG_RCU_NOCB_CPU 配置。内核会启动 rcuo 的内核线程,把清理工作搬到非隔离 CPU 上运行。

    💡 核心概念补充:RCU (Read-Copy-Update) RCU 是 Linux 内核中一种高性能的同步机制,用于保护共享数据。它允许“读者”在不加锁的情况下并发读取,而“写者”在更新数据时需要等待所有“读者”离开临界区。这个清理和同步的过程非常依赖 Tick。

有些工作(比如计时、统计)必须得做,且没法搬走,那就改变它的工作模式 。

  1. Cputime 记账(统计 CPU 使用率):
    • 旧方法: 每次 Tick 来的时候看一眼 CPU 在干嘛(用户态还是内核态),粗略估算。

    • 新方法:上下文切换(Context Switch)监测,当用户从用户态进入内核态(系统调用)时,记录一个精确的时间戳,通过减法算出中间跑了多久 。

    • 代价: 虽然消除了 Tick,但每次**系统调用(Syscall)**或中断时的计算开销变大了。

      核心的权衡(Trade-off)问题:I/O 密集型应用(劣化明显) 、上下文切换频繁的应用,这些程序频繁进行内核交互,性能会明显下降。

      这个代价在“吞吐量”维度是负向的,但在“延迟确定性”维度是极大的正向(仅隔离核受到该配置影响)。

      选择的基础在:成熟的 DPDK 应用几乎不发起系统调用。

  2. RCU 静止状态报告:
    • 旧方法:Tick。CPU 定期通过中断向内核汇报:“我不忙,你可以更新数据了” 。
    • 新方法:被动推导。隔离核不再主动汇报。内核通过观察隔离核的运行状态(是否在用户态),“推断”它现在是静止的。

如果出现以下情况,Tick 无法停止:

  • 该核心上有多个任务在竞争(需要调度器)。
  • 使用了 perf 性能分析事件。
  • 使用了 POSIX CPU 定时器。

要实现真正的隔离,需要内核参数、任务绑定和中断亲和性三者配合。

CPU隔离的核心在于将特定CPU核心从通用调度和内核管理中"抠"出来,使其仅服务于指定任务,避免被其他进程或内核活动干扰。这种隔离不是简单的逻辑划分,而是通过硬件级别的内存保护机制和内核调度策略实现的。

确保你的内核开启了以下选项(现代发行版通常默认开启):

  • CONFIG_NO_HZ_FULL=y:允许全无滴答模式。
  • CONFIG_CPUSETS=y:更强大的资源隔离。
  • CONFIG_RCU_NOCB_CPU=y:允许卸载 RCU 回调。

假设你有 8 个 CPU (0-7),你想隔离 CPU 7 专门跑业务: 在 GRUB 配置中添加:

Bash nohz_full=7

  • 作用: 告诉内核 CPU 7 进入 Full Dynticks 模式。
  • 自动效果: 现代内核会自动将 rcu_nocbs 也设置为 7,无需手动指定。

强烈推荐使用 Cpuset。

  • isolcpus (不推荐): 这是一个旧的引导参数。它只是简单地让调度器如果不被显式要求,就不把任务放上去。但它无法动态修改。
  • cpuset (推荐): 这是一个强大的 Cgroup 子系统。

它们皆只能隔离调度器任务,不阻止内核中断(如定时器)

在现代 Linux(cgroup v2)中,可以先将 /sys/fs/cgroup 下的文件归纳为三类:

  1. 结构与管理文件 (Hierarchy Control)

    cgroup.procs (最重要):列出属于该组的所有 PID(进程 ID)

    • 操作:将一个 PID 写入这个文件,该进程就会被立即移动到这个 cgroup 中。

    cgroup.threads:类似 procs,但细化到线程级别(TID)。

    cgroup.controllers:显示当前 cgroup 支持哪些资源控制器(如 cpu, io, memory)。

    cgroup.subtree_control:控制子目录(子组)可以使用哪些控制器。

  2. 资源配额文件 (Resource Limitation)

    cpu.max (cgroup v2) / cpu.cfs_quota_us (cgroup v1):

    • 作用:硬限制。规定在一段时间内(周期),进程最多能占用多少微秒的 CPU 时间。
    • 场景:防止某个程序把 CPU 跑满,导致系统卡死。

    cpu.weight (cgroup v2) / cpu.shares (cgroup v1):

    • 作用:权重。当多个进程争抢 CPU 时,按比例分配时间,而不是强行限制死。

    cpu.stat (重点监控):统计信息。显示该组进程被“节流”(Throttled)了多少次,以及消耗的总 CPU 时间。

  3. CPU 亲和性与拓扑文件 (CPUSet - 隔离核心)

    cpuset.cpus

    • 作用:绑定物理核心。例如写入 0-3,7,意味着该组内的进程只能在 0,1,2,3 和 7 号核上运行。
    • 意义:这是实现 CPU 隔离 的软件基础。

    cpuset.mems:绑定内存节点(NUMA)。在多路服务器上,确保进程读取的是本地内存,减少延迟。

    cpuset.sched_load_balance:

    • 作用:是否开启负载均衡。如果设为 0,内核调度器就不会自动把其他核心的任务挪到这个核上。

旧配置:

# 1. 创建两个分组:housekeeping (管家) 和 isolated (隔离)
cd /sys/fs/cgroup/cpuset
mkdir housekeeping
mkdir isolated

# 2. 分配 CPU
# 0-6 号核处理系统杂事
echo 0-6 > housekeeping/cpuset.cpus
echo 0   > housekeeping/cpuset.mems
# 7 号核独占
echo 7   > isolated/cpuset.cpus
echo 0   > isolated/cpuset.mems

# 3. 关键步骤:关闭隔离组的负载均衡
# 这能防止调度器自动把别的任务迁移进来
echo 0 > cpuset.sched_load_balance
echo 0 > isolated/cpuset.sched_load_balance

# 4. 将现有进程迁移到 housekeeping 组
while read P
do
  echo $P > housekeeping/cgroup.procs
done < cgroup.procs
# 注:部分内核线程无法迁移是正常的,可忽略报错。

新配置:

# 1. 进入 v2 根目录
cd /sys/fs/cgroup

# 2. 在父级启用 cpuset 控制器(关键步骤)
# 只有写入这个文件,子组才能看到 cpuset 相关文件
echo "+cpuset" | sudo tee cgroup.subtree_control

# 3. 创建目录
mkdir housekeeping
mkdir isolated

# 4. 设置隔离组的 CPU(v2 的文件更简洁,去掉了 cpuset. 前缀)
echo 0-6 > housekeeping/cpuset.cpus
echo 0   > housekeeping/cpuset.mems

echo 7   > isolated/cpuset.cpus
echo 0   > isolated/cpuset.mems

# 5. 执行后,7 会从系统调度域中彻底剥离
echo "isolated" | sudo tee isolated/cpuset.cpus.partition

# 6. 迁移进程 (将根目录所有进程移入管家组)
# 注意:根目录是唯一可以同时拥有子组和进程的地方
cat cgroup.procs | while read pid; do
    echo $pid > housekeeping/cgroup.procs 2>/dev/null
done

硬件中断(网卡、磁盘)默认可能会发往 CPU 7。必须手动移走。

# 将所有中断的亲和性设置为 0-6 (排除 CPU 7)
for I in $(ls /proc/irq)
do
    if [[ -d "/proc/irq/$I" ]]
    then
        # 将掩码或列表写入 smp_affinity_list
        echo 0-6 > /proc/irq/$I/smp_affinity_list
    fi
done

上述的 CPU 编号都是逻辑核编号,通常我们会将一个物理核心上的逻辑和同时划入隔离区。这是由于,若兄弟核上运行杂物,同样会导致 L1/L2 缓存、执行引擎、内存带宽等资源竞争。

天下没有免费的午餐。 开启 nohz_full 会带来显著的副作用,必须明智地使用。

  • 原则: 不能把所有 CPU 都隔离了。必须至少留一个(通常是 CPU 0)来处理全系统的内务工作(未绑定的计时器、RCU 回调等)。
  • 建议: 在 NUMA 架构下,每个 NUMA 节点最好都留一个管家 CPU。

由于取消了 Tick,内核必须在进入/退出内核空间时进行昂贵的原子操作来记录时间。

  • 结论: 隔离核不适合运行 I/O 密集型应用。
  • 适用场景:
    • 纯计算任务。
    • DPDK(用户态驱动,绕过内核,不产生系统调用)。
  • 不适用场景: 频繁读写文件、频繁调用 socket 系统调用的标准网络应用。

一切配置完成后,我们如何确认 CPU 7 真的没有受到干扰?

编写一个简单的 C 程序,绑定到 CPU 7 并死循环:

// user_loop.c
int main(void) {
    // 实际生产中应使用 sched_setaffinity 或 cgroup 接口绑定
    // 这里假设已经通过 cpuset 将 shell 放入了 isolated 组
    while (1) ;
    return 0;
}

利用内核内置的追踪工具 ftrace 来监控 CPU 7。

监控脚本:

TRACING=/sys/kernel/debug/tracing
echo 0 > $TRACING/tracing_on
echo > $TRACING/trace

# 开启核心事件监控
# 1. 监控任务切换 (如果有其他任务抢占 CPU 7,会触发)
echo 1 > $TRACING/events/sched/sched_switch/enable
# 2. 监控中断向量 (如果有硬件中断或 tick 打断 CPU 7,会触发)
echo 1 > $TRACING/events/irq_vectors/enable

# 开始监控
echo 1 > $TRACING/tracing_on
# 运行测试程序
./user_loop &
PID=$!
sleep 10
kill $PID
echo 0 > $TRACING/tracing_on

# 查看结果
cat $TRACING/per_cpu/cpu7/trace

如果配置成功,你在 trace 文件中应该看到极少的内容:

# 只有开始时的调度进入,和结束时的调度退出
<idle>-0 [007] ... sched_switch: prev_comm=swapper ... ==> next_comm=user_loop
... (中间没有任何 irq_vectors 或 sched_switch) ...
user_loop-1553 [007] ... reschedule_entry ...

中间长达 10 秒的空白,代表着绝对的宁静与确定性。