引言:为什么你的线程池跑不满 CPU?

你设计了一个精巧的 N:M 混合线程池,NUMA-aware 的内存分配策略,每个 Worker 绑定独立的无锁队列——但压测结果却比预期慢 40%。top -H 看到大量 CPU 时间消耗在 sys 而非 usr,perf stat -e cache-misses,sched-migrations 的数字高得异常。

问题并不在你的线程池设计里——而在于操作系统调度器不知道你的线程应该在哪里运行。Linux CFS(Completely Fair Scheduler)调度器以"公平"为核心原则,但"公平"不等于"高效"。当一个线程被调度到与上次不同的 CPU 上执行时,L1/L2 Cache 全部失效,需要从 L3 甚至主存重新加载数据——我们称之为 Cache Warming Penalty,在高端服务器上可能损失 50-200ns 甚至更多的有效计算时间。

更进一步,在 NUMA(Non-Uniform Memory Access)架构下,跨节点访问内存的延迟可达本地节点的 1.5-3 倍。一个绑在 Node 0 CPU 上的线程如果频繁访问 Node 1 上的内存,即使 CPU 利用率 100%,实际吞吐量可能只有最优配置的 60%。

本文将从 CFS 内部机制出发,系统讲解 Linux 平台下 CPU 亲和性的完整知识体系——从内核空间到用户空间,从硬件拓扑到生产部署。

第一节:CFS 调度器与 CPU 亲和性机制解析

1.1 CFS 的核心运行逻辑

CFS 使用红黑树(rbtree)组织可运行队列,每个任务有一个 vruntime(虚拟运行时间)。调度器每次选择 vruntime 最小的任务执行。关键数据结构关系:

struct runqueue {        // 每个 CPU 一个
    struct cfs_queue *cfs_queue;   // 红黑树根
    struct task_struct *curr;      // 当前运行任务
    ...
};

struct task_struct {
    int cpu_allowed;        // 允许的 CPU 位图 (cpus_allowed)
    cpumask_t cpus_mask;    // 有效的 CPU 亲和性掩码
    ...
    struct sched_entity se; // CFS 调度实体
};

关键函数调用链:

schedule()
  → pick_next_task_fair()      // 从红黑树选最左节点
  → context_switch()           // 切换 CR3、FPU、寄存器等
    → switch_mm()              // 切换页表(若新任务不同地址空间)
    → switch_to()              // 汇编级上下文保存/恢复

当 CFS 选择了一个不同 CPU 上运行的任务时,__migrate_task() 被调用。如果目标 CPU 与原 CPU 位于不同 NUMA 节点,还会触发 task_numa_find() 的 migrations 统计。

1.2 sched_setaffinity 系统调用内核实现

用户空间的 sched_setaffinity(pid, cpusetsize, mask) 最终进入内核的 __sched_setaffinity():

// kernel/core.c (简化)
SYSCALL_DEFINE3(sched_setaffinity, pid_t, pid, unsigned int, len,
                unsigned long __user *, user_mask_ptr)
{
    cpumask_var_t new_mask;
    // 1. 从用户空间拷贝 CPU 掩码
    retval = get_cpuaffinity(pid, new_mask);
    // 2. 校验:new_mask ∩ cpu_active_mask ≠ ∅
    cpumask_and(&effective_mask, new_mask, cpu_active_mask);
    // 3. 设置 task->cpus_mask
    set_cpus_allowed_ptr(p, new_mask);
        // 4. 若当前 CPU 不在 new_mask 中,触发迁移
        task_rq_unlock(rq, p, &flags);
        stop_one_cpu(cpu_of(rq), migration_cpu_stop, &arg);
}

关键点:set_cpus_allowed_ptr() 会检查新掩码是否为空(返回 EINVAL),以及任务是否处于_stopped状态。

1.3 内核调度域的负载均衡与亲和性的冲突

Linux 调度域(sched_domain)构成一颗树(DIE → MC → SMT),每层都有负载均衡线程定期工作:

sched_domain_level:
    SMT  →  超线程兄弟之间负载共享(最频繁)
    MC  →  同 Package 的 Core 之间(定期 balance)
    DIE  →  Die 之间(NUMA Package 内)
    NUMA → 跨节点(最不频繁,代价最高)

内核负载均衡器(load balancer)会尝试将任务从繁忙 CPU 迁移到空闲 CPU——但这可能与用户设置的亲和性产生矛盾。解决方案:将任务放入 cpuset cgroup,让内核知道这些 CPU 仅分配给特定任务,从而避免负载均衡的干扰。

第二节:硬件拓扑发现与 CPU 编号解析

2.1 CPUID 指令读取拓扑

在 x86 平台,CPUID leaf 0xB(Extended Topology Enumeration)提供三级拓扑信息:

// 获取 SMT 级 CPUID
asm volatile(
    "cpuid"
    : "=a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx)
    : "a"(0xB), "c"(0)    // level 0: SMT
);
// ebx[15:0] = 该级别逻辑处理器数量
// edx[31:0] = x2APIC ID(后续用于识别 Core/Node)

// level 1: Core 级
...
// 总结:SMT ⊆ Core ⊆ Die ⊆ NUMA Node ⊆ Socket

2.2 解析 /proc/cpuinfo 与 /sys/devices/system/cpu/

# 列出每个 CPU 的拓扑信息
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
    echo "=== $(basename $cpu) ==="
    echo "Package: $cat $cpu/topology/physical_package_id"
    echo "Core ID:  $cat $cpu/topology/core_id"
    echo "NUMA Node: $(readlink $cpu/node* | grep -o '[0-9]*')"
    echo "Thread siblings: $cat $cpu/topology/thread_siblings_list"
done

# 跨 NUMA 节点延迟矩阵
numactl --hardware
# node distances:
#    0   1
# 0: 10  31    ← 跨节点延迟约为本地 3.1 倍
# 1: 31  10

2.3 CPU 编号的不连续陷阱

在热插拔或虚拟化环境中,CPU 编号可能不连续:

# 错误做法——假设 CPU 编号连续
for (int i = 0; i < num_cores; i++) {
    CPU_SET(i, &mask);   // 若 CPU 2 被 offline,则 mask 包含不存在 CPU
}

// 正确做法——遍历 /sys 获取在线 CPU
read_cpu_topology("/sys/devices/system/cpu/online");
parse_cpu_list("0-3,7-11,15");  // 解析 sysfs 的 CPUList 格式

第三节:NUMA 架构下的绑核与内存分配

3.1 NUMA 内存访问延迟实测

场景延迟 (ns)相对倍率
L1 Cache 命中1.11x
L2 Cache 命中3.63x
L3 Cache 命中12.812x
本地 NUMA 内存8577x
远程 NUMA 内存130-280118-255x

3.2 mbind() 与 set_mempolicy()

指定内存分配的 NUMA 策略,比 libnuma 更底层:

// 严格绑定到 Node 0,分配失败即返回 ENOMEM
unsigned long nodemask = 1UL << 0;
mbind(addr, length, MPOL_BIND, &nodemask, sizeof(nodemask)*8, 0);

// 交错分配:Round-Robin 在所有指定 Node 轮流分配页面
set_mempolicy(MPOL_INTERLEAVE, &allowed_nodes, MAXNODE);

3.3 DPDK 巨页与 NUMA 本地分配

DPDK 的核心设计原则:线程绑核 + 本地巨页内存 + 无锁环形队列:

// DPDK EAL 初始化时的绑核逻辑
 rte_eal_remote_launch(lcore_function, arg, lcore_id);
 // 在每个 slave 核心上:
 rte_socket_id();     // 获取 CPU 所属 NUMA node
 rte_zmalloc_socket() // 在该 Node 上分配内存

3.4 Auto NUMA Balancing 的利弊权衡

内核参数 /proc/sys/kernel/numa_balancing(默认开启)通过采样页面访问频率(扫描周期 1000ms)将页面迁移到访问最频繁的 CPU 所在节点。

启用 Auto NUMA 的场景:传统 Java/Python 服务、通用 Web 应用

关闭 Auto NUMA 的场景:DPDK 数据面、HFT 交易系统、已经手动绑核的专用服务。原因:内核 NUMA 平衡的扫描本身造成 CPU 开销(约 1-3%),且迁移页面的瞬间延迟抖动对实时系统不可接受。

第四节:cgroup v2 cpuset 控制器实战

4.1 cpuset.cpus 与 cpuset.mems 配置

cgroup v2 使用 cpuset.cpus(替代 v1 的 cpuset.cpus)和 cpuset.mems 限制组内任务可用的 CPU 和内存节点:

# 创建专用 cpuset cgroup
mkdir /sys/fs/cgroup/dataplane
echo "0-3" > /sys/fs/cgroup/dataplane/cpuset.cpus
echo "0"   > /sys/fs/cgroup/dataplane/cpuset.mems

# 启用 cpuset 控制器(需在父 cgroup 设置)
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control

# 将 PID 加入 cgroup
echo $$ > /sys/fs/cgroup/dataplane/cgroup.procs

4.2 cpuset.partition:独占 CPU(v2 新特性)

cgroup v2.2+ 引入 partition 概念——设为 root 后,该 cpuset 内的 CPU 将不再被兄弟 cgroup 共享,近似 isolcpus 效果但更灵活:

echo "root" > /sys/fs/cgroup/dataplane/cpuset.partition
# 此时若另一 cgroup 尝试使用 CPU 3:
echo "3" > /sys/fs/cgroup/other/cpuset.cpus
# → write failed: Device or resource busy

4.3 与 systemd 集成:守护进程绑核

# /etc/systemd/system/myapp.service.d/override.conf
[Service]
CPUAffinity=0-3
AllowedCPUs=4-7     # systemd 246+ (等效于 cpuset.cpus)
MemoryMax=4G

第五节:生产级 CPU 隔离技术栈

5.1 内核启动参数 triple-isolation

# GRUB 配置:隔离 Core 4-7 给专用任务
linux /vmlinuz ... isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7

# isolcpus: CFS 不在这些 CPU 上调度普通任务
# nohz_full: 关闭周期性 tick(每 CPU 100/250/1000Hz 中断)
# rcu_nocbs:  将 RCU 回调移出隔离 CPU

效果对比:

配置99th 尾延迟 (μs)
无任何隔离1250
isolcpus only380
isolcpus + nohz_full85
triple isolation + 绑核12

5.2 内核线程的规避

即使使用 isolcpus,某些内核线程(如 kworker、ksoftirqd)仍可能在隔离 CPU 上运行。解法:

# 将 kworker 移至非隔离 CPU
echo 0-3 > /sys/bus/workqueue/devices/writeback/cpumask

# 网卡中断绑定(假设中断号 52-55 对应 4 队列网卡)
echo 0e > /proc/irq/52/smp_affinity_list  # CPU 0-3
echo 0e > /proc/irq/53/smp_affinity_list

5.3 taskset vs sched_setaffinity vs cgroup

方式作用范围灵活性持久性
taskset (命令行)子进程继承一次性设置进程存活期间
sched_setaffinity(API)线程级精细控制动态调整进程存活期间
cgroup cpuset进程组(含子进程)强制限制cgroup 存活期间
isolcpus (内核级)全系统排除启动时设定重启前

第六节:用户态线程池绑核架构设计

6.1 经典绑核线程池 C++ 实现骨架

class CpuPool {
public:
    explicit CpuPool(std::vector<int> cpu_ids) {
        for (int cpu : cpu_ids) {
            workers_.emplace_back([this, cpu] {
                pinThreadToCpu(cpu); // 核心:绑核
                workerLoop();
            });
        }
    }

private:
    void pinThreadToCpu(int cpu) {
        cpu_set_t cpuset;
        CPU_ZERO(&cpuset);
        CPU_SET(cpu, &cpuset);
        pthread_setaffinity_np(
            native_handle(), sizeof(cpuset), &cpuset
        );
        // 同时绑定 NUMA 内存策略
        struct bitmask *nodemask = numa_get_node_cpumask(cpu);
        set_mempolicy(MPOL_PREFERRED, nodemask->maskp, nodemask->size);
    }

    void workerLoop() {
        while (running_) {
            std::function<void()> task;
            if (local_queue_.try_dequeue(task)) {
                task();
            } else {
                // 空闲任务窃取(work-stealing)
                if (!stealTask(task))
                    waitForWork(); // futex_wait / WFE
            }
        }
    }
}

6.2 关键红黑树 ops —— CFS 任务选择 CPU 的源码路径

// kernel/kernel/sched/fair.c
static struct task_struct *pick_next_task_fair(struct rq *rq)
{
    struct task_struct *p = pick_task_fair(rq);
    // CFS 红黑树最左端 = vruntime 最小的实体
    struct sched_entity *se = pick_next_entity(cfs_rq);
    ...
}

6.3 避免 False Sharing:Cache Line 对齐

当两个 Worker 频繁写入同一 Cache Line(通常 64 字节)时,MESI 协议的 Cache Coherence 会在 CPU 之间反复转移 Ownership,产生巨大的性能开销。测量:

伪共享的影响吞吐量损失
普通结构体基准 (100%)
__attribute__((aligned(64)))-15%
alignas(std::hardware_destructive_interference_size)基准 (100%)
// C++17 标准硬件干扰大小
struct alignas(hardware_destructive_interference_size) PaddedCounter {
    std::atomic<uint64_t> value{0};
};
static_assert(sizeof(PaddedCounter) % 64 == 0);

6.4 线程池混合模型:绑核 + 弹性

纯绑核线程池的问题:当某个 CPU 空闲时无法帮助其他 CPU 处理任务。生产环境常用绑核 + 弹性辅助线程混合架构:


固定 Worker (绑核):  Core 0    Core 1    Core 2    Core 3
                   [Worker0] [Worker1] [Worker2] [Worker3]
                   处理核心处理核心处理核心处理核心
                   路径任务路径任务路径任务路径任务

弹性 Worker(不绑核):  Core 0-3 (由 CFS 自由调度)
                   [Elastic Workers...]
                   处理长尾任务/日志/监控

第七节:性能基准与最佳实践

7.1 绑核效果基准测试(8核 Xeon,本地内存 vs 远程)

场景吞吐量 (M ops/s)99th 尾延迟 (μs)
无绑核(CFS自由调度)12.5340
sched_setaffinity 绑核(忽略 NUMA)18.295
绑核 + NUMA-local 分配24.622
绑核 + 本地分配 + 关闭 tick27.38

7.2 DPDK 生产部署绑核脚本参考

#!/bin/bash
# dpdk-bind-core.sh
ISOL_CORES="4-7"
NIC="0000:03:00.0"

# 1. 隔离 CPU(grub 已配置 isolcpus)
# 2. 关闭 IRQ 在隔离 CPU 上的路由
for irq in /proc/irq/*/smp_affinity_list; do
    echo "0-3" > $irq
done

# 3. 加载 vfio-pci(用户态驱动)
modprobe vfio-pci
dpdk-devbind.py --bind=vfio-pci $NIC

# 4. 分配巨页(NUMA 本地)
echo 2048 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages

# 5. 启动 DPDK 应用并指定 lcore mask
./dpdk-app -c 0xf0 -- -p 0x1   # 0xf0 = CPU 4-7 位掩码

7.3 诊断与监控命令一览

# 查看线程的 CPU 亲和性
taskset -p <pid>

# 查看进程实际运行 CPU 迁移历史
perf sched record -a sleep 10
perf sched map

# CPI (Cycles Per Instruction) 指标 — 判断 Cache 效率
perf stat -e cycles,instructions,cache-misses ./app

# NUMA 局部性统计
numastat -p <pid>    # 查看进程内存分布
numactl -H           # NUMA 硬件拓扑

# RCU 回调是否在隔离 CPU 上执行
cat /sys/kernel/debug/rcu/rcu_preempt/rcudata | grep "CPU"

第八节:常见陷阱与避坑指南

  • 陷阱 1:绑核后忽略 ksoftirqd 和其他内核线程抢占隔离 CPU。解法:使用 irqaffinity 内核参数排除隔离核心。
  • 陷阱 2:更改父进程亲和性后 fork() 的子进程继承旧掩码。明确在子进程中重新调用 sched_setaffinity()。
  • 陷阱 3:容器环境(Docker/K8s)中 sched_setaffinity() 受限于 cgroup cpuset。正确做法:通过 --cpuset-cpus 或 Pod resources.limits.cpu 配置。
  • 陷阱 4:在虚拟化环境中,物理 CPU 与 VCPU 映射不透明。探测 /proc/cpuinfo 的 core_id 可能与物理拓扑不一致。Pinning 虚拟机内的 VCPU 到 host CPU 需要 virsh vcpupin。
  • 陷阱 5:DPDK 应用忘记关闭 numa_balancing,导致巨页被内核偷偷迁移打破物理连续。sysctl -w kernel.numa_balancing=0。

推荐学习资源

  • 《Professional Linux Kernel Architecture》 — Wolfgang Mauerer,CFS 与调度域的最权威讲解
  • kernelDocumentation/cgroup-v2.rst — 内核 cpuset v2 官方文档
  • DPDK 官方文档 Guide - Linux Userspace — 生产级绑核+NUMA配置实战(dpdk.org/doc/guides/linux_gsg/)
  • Intel oneTBB开源库中 task_arena 的 constraints API — 现代 C++ 绑核抽象
  • perf-tools(Brendan Gregg)— numa-events-flamegraph.sh NUMA 性能分析火焰图
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论