ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核CPU拓扑:从sysfs节点到绑核实战与源码解析

Linux内核CPU拓扑:从sysfs节点到绑核实战与源码解析 这段时间在整理旧服务器的性能基线本想着敲个lscpu扫一眼就完事结果每次都顺着输出里的Core(s) per socket、Thread(s) per core找到了同一个地方——/sys/devices/system/cpu/cpu*/topology/。这次干脆把 Linux 内核里drivers/base/topology.c这个文件从头读了一遍算是把这块一直会用但没深究的知识补上了。这篇笔记适合两类人一类是刚接触 Linux 内核不知道drivers/base下这些目录到底在干嘛的读者另一类是日常做性能调优、绑核、中断隔离的运维和嵌入式工程师想把/sys下的拓扑节点真正用明白。我尽量把源码逻辑、实际输出、踩坑记录放在一起写读完你至少能自己解释physical_package_id和core_id的区别也能在出问题时知道往哪查。1. 从一条 sysfs 路径说起为什么内核要把 CPU 拓扑暴露给用户态1.1 先看一个真实的 topology 目录随便找一台 x86_64 机器进到/sys/devices/system/cpu/cpu0/topology/目录里通常会看到这几个文件$ ls /sys/devices/system/cpu/cpu0/topology/ core_id core_siblings_list physical_package_id thread_siblings_list如果你的内核比较新或者跑的是一台 IBM/S390 之类的机器可能还会看到die_id、book_id、drawer_id以及对应的*_siblings_list。先不管这些附带项基础四件套先读出来$ cat /sys/devices/system/cpu/cpu0/topology/physical_package_id 0 $ cat /sys/devices/system/cpu/cpu0/topology/core_id 0 $ cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list 0-1 $ cat /sys/devices/system/cpu/cpu0/topology/core_siblings_list 0-7含义很直白physical_package_id这颗逻辑 CPU 所在的物理 CPU 封装编号也就是俗称的第几个 socket。core_id在这个物理封装内部的物理核编号。thread_siblings_list和当前 CPU 共享同一个物理核的所有逻辑 CPU 编号。core_siblings_list和当前 CPU 共享同一个物理封装的所有逻辑 CPU 编号。换句话说thread_siblings_list回答的是哪些 CPU 是同一个超线程兄弟core_siblings_list回答的是哪些 CPU 在同一颗物理 CPU 里。1.2 这些节点到底在回答什么问题如果只看/proc/cpuinfo你能拿到一块 CPU 的型号、主频、缓存的完整信息但拿不到 CPU 之间的层级关系。而调度器、性能工具、用户态程序最需要知道的恰恰是层级关系两个逻辑 CPU 是否在同一个物理核上如果是它们共享执行单元、一级缓存和部分二级缓存同时跑两个重负载线程会互相争抢。两个逻辑 CPU 是否在同一个物理封装里如果是它们共享三级缓存和内存控制器跨封装访问内存会慢一截。两个逻辑 CPU 是否在同一个 NUMA 节点上如果不是远程内存访问的延迟可能会差几倍。/proc/cpuinfo里的physical id、core id、cpu cores字段其实也能看但格式不适合程序直接解析而且不同架构的字段还不一样。sysfs 下的topology目录把这些信息单独拎出来结构化、按逻辑 CPU 分目录用户态工具和脚本拿到就能用不需要再做字符串解析的脏活。1.3 topology 数据服务谁简单梳理一下吃这些数据的无非三类角色调度器内核负载均衡要构造调度域得知道哪些 CPU 是兄弟哪些 CPU 是邻居哪些 CPU隔得很远。用户态调优工具lscpu、hwloc、numactl这类工具基本就是把这些节点内容读出来拼装成报告。应用和中间件数据库、DPDK 这类高性能程序在初始化时会自己读这些节点决定把线程绑到哪个核上尽量避免跨 NUMA、避免超线程争抢。之前遇到一个排查案例某个数据库实例在更新之后性能突然抖动看mpstat发现它把两个业务线程都绑在了同一个物理核的两个超线程上。查到最后就是初始化脚本里写死了taskset -c 0,1而完全没有去读thread_siblings_list。这类问题在手动绑核的场景里其实非常普遍。2. drivers/base/topology.c 麻雀解剖注册、属性与回调2.1 它不是一个传统意义上的驱动看到路径是drivers/base/topology.c大家第一反应可能是驱动但打开源码你会发现它和块设备驱动、网络驱动完全不是一回事。它没有probe函数也不绑定某个具体的硬件设备本质上是给每个 CPU 设备添加一组 sysfs 属性文件的内核模块。内核里的 CPU 设备在/sys/devices/system/cpu/cpuN下cpu0、cpu1这些都是struct cpu设备节点。topology.c做的事情就是定义一组struct attributestatic struct attribute *topology_attrs[] { #if defined(CONFIG_SCHED_BOOK) defined(CONFIG_SCHED_DRAWER) dev_attr_book_siblings.attr, dev_attr_book_siblings_list.attr, dev_attr_drawer_siblings.attr, dev_attr_drawer_siblings_list.attr, #endif dev_attr_die_id.attr, dev_attr_physical_package_id.attr, dev_attr_core_id.attr, dev_attr_thread_siblings.attr, dev_attr_thread_siblings_list.attr, dev_attr_core_siblings.attr, dev_attr_core_siblings_list.attr, NULL, };然后把这组属性挂成一个名为topology的attribute_group通过内核的 sysfs 机制塞进每个 CPU 设备目录。这也是为什么每个cpuN/topology/下的文件结构几乎一摸一样——它们是同一批模板属性。读这个文件的正确姿势不能照着驱动模型去理解而应该把它当作内核数据导出层来看。它承接的是 CPU 拓扑信息的末梢展示真正的数据采集在架构代码里。2.2 属性文件背后的 id 和 cpumask每个属性文件背后都对应一个show回调函数名的规律是show_物理含义。比如static ssize_t show_physical_package_id(struct device *dev, struct device_attribute *attr, char *buf) { return sysfs_emit(buf, %d\n, cpu_data(dev-id).phys_proc_id); }dev-id就是逻辑 CPU 编号也就是目录名里的那个cpuN。在 x86 上它取的是cpuinfo_x86结构体里的phys_proc_id字段在 ARM64 上取的是cpu_topology[cpu].package_id。不同架构的实现路径不同但导出到用户态的名字是统一的。如果你在 ARM64 机器上读源码会看到类似这样的结构体struct cpu_topology { int thread_id; int core_id; int package_id; int llc_id; cpumask_t thread_sibling; cpumask_t core_sibling; cpumask_t llc_sibling; };thread_siblings_list这类列表型属性比数字型复杂一点。源码里不会直接输出一个cpumask_t的原样内容而是用cpumap_print_to_pagebuf()之类的辅助函数把位图转成人类可读的范围表示法。比如位图的 bit 0 和 bit 1 被置位就会输出0-1而不是0b00000011。这里有个历史细节值得注意老版本里还同时存在thread_siblings和thread_siblings_list两个文件。前者输出原始位图格式适合程序解析后者输出短横线分隔的列表格式适合人眼阅读。新版内核保留了*_list作为主要形式但旧脚本里如果还依赖非_list文件的格式移植时得小心。2.3 内核内部与用户态的翻译官要理解topology.c的定位记住一句话内核调度器不依赖这些 sysfs 节点来工作。调度器内部用的是include/linux/topology.h里定义的宏和sched_domain结构topology_physical_package_id(cpu)物理封装 ID。topology_core_id(cpu)物理核 ID。topology_core_cpumask(cpu)同一物理核内所有逻辑 CPU 的 cpumask。topology_thread_cpumask(cpu)同一 SMT 线程组内的 cpumask。topology_die_cpumask(cpu)同一个 die 内的 cpumask。这些宏在不同架构里的实现不一样。x86 上很多直接取自cpu_data结构和cpu_smt_mask()ARM64 上则指向struct cpu_topology实例。drivers/base/topology.c只是把这些内部数据翻译成稳定的 sysfs 接口方便用户态工具读取。所以如果你自己写内核模块想在模块里获取 CPU 拓扑信息正确的做法是直接调用topology_*系列宏而不是去打开/sys/devices/system/cpu/cpuX/topology/下的文件。我在早期写一个调度器实验模块时就干过这种蠢事——在模块里用struct file *去读 sysfs不仅慢还容易在中断上下文里死锁。后来翻到include/linux/topology.h才意识到接口就在眼前。提示真要给内核模块写拓扑相关逻辑优先看cpu_smt_mask()、cpu_coregroup_mask()、topology_physical_package_id()这几个接口只有在写用户态程序时才需要去读 sysfs。3. 一台双路服务器上的实测节点内容与绑核应用3.1 测试环境与初始输出纸上谈兵没有意义我拿手头一台双路服务器实际拉了一遍数据。机器大概是这个配置2 颗物理 CPU每颗 8 个物理核每核 2 个线程一共 32 个逻辑 CPU。$ lscpu Architecture: x86_64 CPU(s): 32 On-line CPU(s) list: 0-31 Thread(s) per core: 2 Core(s) per socket: 8 Socket(s): 2 NUMA node(s): 2 Model name: Intel(R) Xeon(R) CPU E5-2640 v4lscpu显示NUMA node(s): 2每个 socket 对应一个 NUMA 节点。接下来直接看 sysfs 里的数据。3.2 逐个节点读出来的实际含义先看cpu0$ cat /sys/devices/system/cpu/cpu0/topology/physical_package_id 0 $ cat /sys/devices/system/cpu/cpu0/topology/core_id 0 $ cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list 0-1 $ cat /sys/devices/system/cpu/cpu0/topology/core_siblings_list 0-15再看cpu16$ cat /sys/devices/system/cpu/cpu16/topology/physical_package_id 1 $ cat /sys/devices/system/cpu/cpu16/topology/core_id 0 $ cat /sys/devices/system/cpu/cpu16/topology/thread_siblings_list 16-17 $ cat /sys/devices/system/cpu/cpu16/topology/core_siblings_list 16-31两组数据结合起来机器的拓扑结构就清楚了CPU0 和 CPU1 在同一个物理核上是超线程兄弟。CPU0 到 CPU15 的physical_package_id都是 0属于第一个物理封装。CPU16 和 CPU0 的core_id都是 0但physical_package_id不同说明不同 socket 上的物理核编号是各自独立编号的。core_siblings_list没有交叉说明两个 socket 的 CPU 集合完全隔离。这里很容易犯一个错看到 CPU16 的core_id是 0就以为它和 CPU0 是同一个核。一定要同时看physical_package_id才能判断唯一的物理核。3.3 用 topology 文件做线程绑核的完整操作假设现在有 4 个 CPU 密集进程希望它们两两分布在两个物理封装上而且每个进程都独占一个物理核的两个兄弟线程。目标是把进程 A 放在 CPU0/1进程 B 放在 CPU2/3进程 C 放在 CPU16/17进程 D 放在 CPU18/19。先写个小脚本把每个逻辑 CPU 的层级关系列出来for cpu in /sys/devices/system/cpu/cpu[0-9]*; do n$(basename $cpu) pkg$(cat $cpu/topology/physical_package_id) core$(cat $cpu/topology/core_id) threads$(cat $cpu/topology/thread_siblings_list) echo $n pkg$pkg core$core threads$threads done | sort -t -k2,2n -k3,3n | head -20输出大概是这样cpu0 pkg0 core0 threads0-1 cpu1 pkg0 core0 threads0-1 cpu2 pkg0 core1 threads2-3 cpu3 pkg0 core1 threads2-3 ... cpu16 pkg1 core0 threads16-17 cpu17 pkg1 core0 threads16-17 cpu18 pkg1 core1 threads18-19然后启动进程绑定taskset -c 0,1 ./worker_a taskset -c 2,3 ./worker_b taskset -c 16,17 ./worker_c taskset -c 18,19 ./worker_d 这样绑定的好处是每个 worker 可以在兄弟线程之间切换利用同一个物理核的硬件线程资源但不会和另一个 worker 抢同一个核心。如果你希望每个进程独占一个物理核、只用一个线程那就把taskset -c后面的参数换成单个 CPU比如taskset -c 0 ./worker_a同时把中断或者别的负载挪到兄弟线程之外。注意taskset -c 0,1并不是绑定到 CPU0 和 CPU1 两个核而是允许进程在 CPU0 和 CPU1 之间调度。CPU0 和 CPU1 是否共享一个物理核必须靠thread_siblings_list判断不能靠 CPU 编号猜测。4. 拓扑数据是打哪儿来的ACPI、设备树与架构代码4.1 x86 的 APIC/ACPI 解析路径x86 平台的 CPU 拓扑数据不是凭空冒出来的。现代 x86 机器在启动时内核通过 ACPI 的 MADT 表Multiple APIC Description Table拿到每个逻辑 CPU 的 Local APIC ID然后根据 APIC ID 的位段划分来解析拓扑。比如经典的 Intel 超线程拓扑模型里APIC ID 的 bit0 表示 SMT 线程号bit1 往上的一段表示 core ID再往上表示 package ID。内核在smpboot.c和cpu/common.c里把这些位段拆开填充到phys_proc_id、cpu_core_id等字段。这也是为什么物理 CPU 编号和 Linux 逻辑 CPU 编号经常对不上。BIOS 里 APIC ID 的排列顺序、是否开了超线程、NUMA 节点怎么映射都会影响逻辑 CPU 编号的分布。你经常会在双路机器上看到逻辑 CPU 0-7 属于 package 0但逻辑 CPU 8-11 突然跳到 package 1然后又跳回来——这就是 APIC ID 排序导致的。4.2 ARM64 与嵌入式 Linux 的设备树路径到了 ARM64情况就完全不一样了。很多嵌入式 Linux 场景没有 ACPI靠的是设备树里的cpu-map节点cpu-map { cluster0 { core0 { thread0 { cpu CPU0; }; thread1 { cpu CPU1; }; }; core1 { thread0 { cpu CPU2; }; thread1 { cpu CPU3; }; }; }; cluster1 { core0 { thread0 { cpu CPU4; }; thread1 { cpu CPU5; }; }; }; };内核对设备树这部分的支持主要在drivers/base/arch_topology.c它会把cpu-map解析成struct cpu_topology再填进cpu_topology[cpu]数组。解析完成后drivers/base/topology.c的 show 回调读取这些字段用户态就能在/sys下看到一致的拓扑节点。这里要特别提醒嵌入式方向的同学设备树里cpu-map的节点层级如果写错了或者cpu属性引用的 phandle 对不上内核可能完全静默失败最后在/sys/.../topology/里看到的全是 0 或者只有自己。我见过不止一次因为设备树里少了thread0层级导致所有 ARM 核的thread_siblings_list都为空的情况。4.3 数据填错后调度器会怎样拓扑数据如果不对最直接的影响是调度域被压缩或者打散。调度器在初始化时会根据架构提供的cpu_coregroup_mask()、cpu_cpu_mask()等接口一层一层构造调度域。正常的层级一般是共享 SMT 线程的一组 CPU构成最内层调度域。共享物理核的一组 CPU构成下一层调度域。共享物理封装或 LLC 的一组 CPU构成再往上一层。最后是 NUMA 节点之间的调度域。如果拓扑数据丢失调度器可能会把所有 CPU 当成一个扁平集合负载均衡时不会优先考虑缓存亲和性或者反过来错误地认为某些 CPU 不在同一个缓存域导致任务被频繁迁移到很远的位置。在实际运维中最明显的问题是虚拟化环境。很多虚拟机没有正确透传拓扑信息你在虚机里看到的physical_package_id经常全是 0core_siblings_list可能就是0-3。这时lscpu显示的结构和宿主机完全没关系绑核策略也得重新设计不能沿用物理机的经验。5. 读源码和看节点时的三个大坑5.1 物理 ID 与逻辑 CPU 编号千万别混这句话我在不同场景里说了很多次但每次还是会有人踩。看physical_package_id输出为1就以为是 CPU1这是最典型的误读。实际上physical_package_id是给用户看的第几个物理 CPU 封装的索引和 Linux 逻辑编号完全是两套编号体系。举个反例。某台服务器上逻辑 CPU 28 的physical_package_id是 0逻辑 CPU 0 的physical_package_id也是 0。如果你写脚本用 CPU 编号直接除以逻辑核数来算物理位置多半会算错。正确做法永远是去读三个文件physical_package_id、core_id、thread_siblings_list拼接出唯一物理核标识再根据业务需求选 CPU。5.2 为什么 thread_siblings_list 可能只有自己不是所有机器都开了超线程。在一些只支持单线程的 ARM 板子、或者 BIOS 里关闭了 SMT 的 x86 机器上每个物理核只有一个逻辑 CPUthread_siblings_list读出来就是孤零零的一个编号。还有一种情况是内核配置问题。如果CONFIG_SMP关闭或者架构实现没有填充sibling相关的 cpumaskthread_siblings_list也可能退化成单元素。遇到这种输出先别急着怀疑内核 bug先跑一遍lscpu看看Thread(s) per core是多少如果是 1那thread_siblings_list只有自己是正常行为。5.3 热插拔之后拓扑节点会怎样服务器 CPU 热插拔场景下有人会关心echo 0 /sys/devices/system/cpu/cpuN/online之后cpuN/topology/目录会不会消失实测下来目录通常不会消失因为 sysfs 设备节点还在只是 CPU 处于离线状态。你再读里面的文件数据还是启动时填充的那套。也就是说拓扑信息是一次性初始化好的热插拔不会实时重算。所以别指望通过上下线 CPU 来修正拓扑有拓扑不对的问题还是得从固件、设备树或内核启动参数层面解决。5.4 一个绑核反而变慢的排错记录最后分享一个实际排错过程也是让我决定把topology.c从头读一遍的契机。场景是某个数据处理服务运维同学觉得业务线程太分散干脆用taskset把所有线程绑到 CPU 0-7。结果跑起来之后吞吐量不升反降CPU 0-7 的用户态占用率很高但总吞吐掉了两成。排查步骤是这样top -H确认线程都落在 CPU0-7。看 CPU0 的thread_siblings_list发现是0-1CPU1 也是0-1。也就是说这些核里每两个逻辑 CPU 共享一个物理核。再看负载业务线程不止 4 个同一物理核上的两个线程都在跑计算正好互相争抢 ALU 和缓存。把绑核范围从连续的 CPU0-7改成读取core_id之后按物理核分配比如线程数少时只绑每个thread_siblings_list里的偶数编号 CPU把兄弟线程空出来处理中断。改完之后吞吐恢复甚至比原本不绑核时还好一点。问题根源不是 tskset 用错了而是绑核策略没有考虑拓扑层级。这算是 topology 节点在实际运维中最常见的价值体现。6. 从 topology 再往前走NUMA、调度域与容量6.1 NUMA 节点和 topology 是两张不同的表如果你顺着 topology 继续深入一定会碰到 NUMA。/sys/devices/system/cpu/cpuN/topology/只能告诉你 CPU 之间在多近的层级上而/sys/devices/system/node/nodeN/才告诉你内存离得有多远。比如双路机器上node0 通常对应 package0node1 对应 package1。numactl --hardware会显示不同 node 的 distance。topology 和 NUMA 数据互为补充topology 管计算资源共享NUMA 管内存访问成本。一个进程如果绑定了 CPU 0-15 却把内存分配在 node1那它跑起来就会频繁跨 node 访问内存性能损失不小。我自己现在的习惯是做任何 CPU 绑定的调优一定会同时开两个窗口一个看lscpu -e一个看numactl -H。前者决定绑哪个核后者决定内存策略。6.2 别把 topology.c 和 arch_topology.c 混为一谈在drivers/base/目录下翻代码的时候很容易被两个文件搞混drivers/base/topology.c和drivers/base/arch_topology.c。topology.c做的事很纯粹把已经填好的拓扑数据通过 sysfs 暴露给用户态。而arch_topology.c负责的是架构相关的拓扑生成和容量计算包括解析设备树里的cpu-map填充struct cpu_topology。计算 CPU 容量capacity用于调度器的频率不变和任务放置。维护频率缩放相关的时钟因子。也就是说如果你要调 CPU 容量、做能耗调优应该去arch_topology.c和kernel/sched/topology.c如果你只是疑惑/sys节点为什么长这样那看topology.c是对的。两个文件名字像职责差得很远。6.3 给继续读内核的同学带句话我的阅读路线大致是先从/sys节点反查drivers/base/topology.c再跳到include/linux/topology.h看通用接口然后回到自己的架构目录找具体实现。x86 看arch/x86/kernel/cpu/和 ACPI 相关代码ARM64 看arch/arm64/include/asm/topology.h和drivers/base/arch_topology.c。读完之后再配合一台真机把每个 sysfs 文件的内容和源码里的数据来源一一对应起来基本就能把这条线串通。自己动手在机器上跑一遍比看十篇博客都管用。你只要打开/sys/devices/system/cpu/cpu0/topology/把每个文件 cat 一遍再对照lscpu的输出就能把这套机制的内外逻辑完整对上。后面再遇到该绑哪个核这类问题心里就有底了。
返回列表