去年我在折腾一台新配置的电脑时遇到一个很典型的问题:跑一个单线程为主的工程计算程序,风扇直接起飞,但任务管理器里总 CPU 占用率却只有 30% 左右。后面我用工具把每个逻辑核心的占用摊开一看,当场就乐了——八个 E 核(能效核)忙得快冒烟,六个 P 核(性能核)几乎在旁边“看戏”。这种“CPU 大核闲着、程序死活不跑上去”的情况,在大小核混合架构普及之后真的太常见了。问题通常不是程序不行,而是系统调度器没有把任务放对位置。
这篇文章不聊虚的,只聊 CPU 亲和性(CPU affinity)这件事:它到底是什么、为什么能解决“大核闲置”、Linux 和 Windows 上分别怎么操作、绑核之后怎么验证有效,以及哪些场景千万别盲目绑核。我的话放在前头:绑核不是万能药,但它确实是让关键程序优先用上高性能核心最直接的手段。
1. 大核闲着、小核狂奔:先把脉再开药
1.1 大小核混合架构下,“你以为的 CPU”和“调度器眼里的 CPU”不是一回事
从 Intel 12 代 Alder Lake 开始,x86 桌面和移动端也全面转向了类似 ARM big.LITTLE 的大小核混合架构:一组性能核(P-Core,大核)负责高负载和低延迟场景,一组能效核(E-Core,小核)负责后台任务和省电场景。P 核单线程能力强、缓存和频率上限高,E 核面积小、能效比好,适合一堆不太吃单核性能的任务并行跑。
但这里有个容易被忽略的点:操作系统的调度器并不会自动把“你觉得重要的程序”放到“你觉得快的核心”上。操作系统调度器看到的不是一个一个带标签的物理核心,而是一组逻辑处理器编号组成的调度域。现代调度器确实会参考核心的算力差,但它的首要目标是“系统整体吞吐量高 + 功耗可控 + 延迟可接受”,不是“让某个程序跑得最快”。所以在默认的平衡策略下,一个后台服务、一个刚启动还没有来得及被判定为“前台高优先级”的程序,完全可能被丢到 E 核集群上一直跑。
举个例子:某些游戏平台或启动器带起来的游戏主程序,从启动器子进程继承的优先级和调度分类并不高;系统先把它塞进空闲的 E 核,等之后负载上来了,调度器再把它往 P 核迁移。可如果这个程序自己不配合,或者系统调度迁移发生得太晚,你就会看到“总占用率不高但卡成 PPT”的诡异现象。
1.2 调度器默认策略是把省电放在性能前面
Intel 在 Windows 11 里提供了 Thread Director,理论上能让调度器识别 P/E 核并优化线程放置;Windows 10 对大小核架构基本没有这套精细识别,效果主要靠更新补丁和厂商驱动兜底。Linux 这边,近年内核也在逐步完善混合架构的调度支持,但前提是你的发行版内核版本、固件、驱动都足够新,而且电源管理策略默认可能还是偏向节能。
现实是:在 Windows 10 上,很多 Alder Lake/Raptor Lake 用户不装厂商调度补丁,或者用了笔记本默认的“平衡”电源模式,P 核经常处于低负载甚至几乎空转的状态,E 核却被塞满了各种线程。在 Linux 上,不少桌面发行版默认的 CPU 调频器是 schedutil 或 conservative,它们会优先压低频率和选择低容量核心,对混合架构的利用率并不理想。
所以当你发现“大核闲着”时,先别急着怪程序或硬件。第一步是确认你的系统有没有正确识别核心类型,第二步是确认电源/性能模式,第三步才是考虑用亲和性手动干预。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CPU 亲和性其实只做一件小事:明确告诉调度器“我允许它去哪”
2.1 亲和性掩码与 CPU 编号的换算
CPU 亲和性,说人话就是:给一个进程或线程设一个“允许跑的核心白名单”。调度器在做任务分配的时候,只会从白名单里挑 CPU,白名单之外的核心即便空着也不会让这个进程跑上去。
这个“白名单”在系统底层是一把二进制的掩码。每个逻辑 CPU 占一个 bit,bit 为 1 表示允许,bit 为 0 表示禁止。比如一台只有 4 个逻辑 CPU 的机器,你想让进程跑在第 0 号和第 2 号核心上,掩码的二进制就是 0101,从右往左数分别是 bit0、bit1、bit2、bit3,对应十六进制就是 0x5。但在 Linux 下其实不用自己算得这么痛苦,taskset 支持直接写 0-3,8-11 这样的列表形式,系统会帮你换算成掩码。
需要注意一个反直觉的点:亲和性里的“CPU 编号”是操作系统给逻辑 CPU 编的号,不是 CPU 表面印的“P 核几号”。在超线程开启的状态下,同一个物理 P 核心会有两个逻辑 CPU 编号,比如物理 P0 会对应逻辑 CPU 0 和 1。想绑“大核”,你得先搞清楚在你这台机器上,哪些逻辑 CPU 编号属于 P 核。
以我常用来示范的一台 6P+8E 笔记本为例,它一共 20 个逻辑 CPU。在我的系统上,逻辑 CPU 0~11 是 6 个 P 核的 12 个线程,逻辑 CPU 12~19 是 8 个 E 核。注意,这只是我机器上的枚举结果,不同主板、不同 BIOS、Windows 和 Linux 下的编号顺序都可能不一样,所以千万别照抄这个编号去绑核。
2.2 亲和性不等于独占
第一次用 CPU 亲和性的人很容易把两个概念混淆:亲和性和 CPU 独占。
亲和性只是“限制可用的 CPU 范围”,不等于把这几个核心“锁给”某个程序。其它进程、内核线程、中断处理依然可能跑在这几个核心上。如果你想要的是“这个程序专用某几个核,其它东西都不许进”,那就不是亲和性单能干的事,还需要配 cpuset(Linux 下的核心隔离)、中断亲和性、CPU Sets 或专用核心隔离策略。
理解这个区别很重要,因为它决定了你设完亲和性之后对性能的预期。如果你把一个比较吃资源的程序绑到 P 核上,但同时有一堆后台软件也在 P 核上被调度,效果可能没有想象中的提升。这也是为什么很多严肃玩家和专业用户会用 Process Lasso 这类工具直接做 CPU 隔离和优先级规则,而不是只在任务管理器里手动勾一下。
3. Linux 绑核三板斧:taskset、systemd、C API
3.1 先用 taskset 给正在运行的进程改道
Linux 下最常用的绑核工具就是 taskset,它来自 util-linux,几乎所有发行版都自带。先看一个已经运行的进程当前允许跑在哪些 CPU 上:
bash复制taskset -pc <PID>
输出会类似 pid 1234's current affinity list: 0-19,表示允许在 0 到 19 号逻辑 CPU 上运行。接下来把进程限制到 0~5 和 12~17 号核心上:
bash复制taskset -pc 0-5,12-17 <PID>
这里的 0-5,12-17 就是允许列表,出了这个范围的核心它一概不能跑。如果你想在程序启动时就绑好核,语法也差不多:
bash复制taskset -c 0-5,12-17 ./my_program --threads=6
我实际使用中特别推荐用 -pc 而不是 -c 去改正在运行的进程,因为 -pc 后面跟 PID 是“改现有进程”,不容易出现因为命令拼错导致把进程跑崩的情况。启动时的 -c 配合列表写起来虽然方便,但如果列表里包含了一个不存在的高位编号,某些老版本 taskset 会直接拒绝启动,反而不够顺手。
3.2 开机自启服务用 systemd CPUAffinity
如果你希望一个常驻服务每次开机都自动绑到指定的核心上,最好别在启动脚本里手写 taskset,直接改 systemd unit 文件更干净。在 service 文件里加上:
ini复制[Service]
CPUAffinity=0-5 12-17
ExecStart=/usr/local/bin/my_daemon
然后重载并重启服务:
bash复制sudo systemctl daemon-reload
sudo systemctl restart my_daemon
也可以用 systemctl show <服务名> -p CPUAffinity 确认设置是否生效。CPUAffinity= 这个指令接受空格或逗号分隔的 CPU 列表,非常直观。它的好处是不需要额外包一层 taskset 命令,systemd 会在 fork 出主进程之前就把亲和性设置好,比在 ExecStart 里写 taskset -c ... /path/program 更可靠,因为后者在程序自己 fork 子进程时,子进程能不能继承到亲和性取决于父进程的环境,而 systemd 直接设置的是 cgroup 和进程属性。
不过要注意:如果你的服务会自己创建大量 worker 线程,并且每个 worker 想绑定到不同核心,光靠 systemd 的 CPUAffinity 不够,你需要在程序内部对每个线程单独调用亲和性 API。
3.3 自己写程序时在代码层锁核
如果你是开发者,想在代码里直接控制当前进程或某个线程的亲和性,Linux 下用的是 sched_setaffinity。下面是一段极简的 C 示例,把当前进程限到 0、1 两个核心上:
c复制#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
int main(void) {
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(0, &set); // 允许 CPU 0
CPU_SET(1, &set); // 允许 CPU 1
if (sched_setaffinity(0, sizeof(set), &set) == -1) {
perror("sched_setaffinity");
return 1;
}
// 后续代码只会被调度到 CPU 0、1 上
return 0;
}
sched_setaffinity 的 pid 参数传 0 表示当前进程。如果要对某个线程操作,就用 pthread_setaffinity_np,传线程句柄,具体可以查 glibc 的 pthread 扩展接口。写代码时有一个隐藏的坑:如果你在线程已经跑起来之后才绑核,线程可能已经带着一堆缓存状态在旧核心上跑了一段,迁移会有短暂的性能惩罚。最好在创建线程之前设置好新建线程的 affinity,或者在线程函数的最开头立刻绑定。
另外,OpenMP 程序可以直接用 OMP_PROC_BIND=true 和 GOMP_CPU_AFFINITY 环境变量控制线程绑定,比自己在代码里造轮子省事得多。
4. Windows 端把程序摁在大核上的办法:从一次性到常驻
4.1 任务管理器里的临时绑核
Windows 用户最快能上手的方法就是任务管理器。在“详细信息”标签页里找到进程,右键选择“设置相关性”(Set affinity),会弹出一个逻辑 CPU 的勾选列表。把属于 E 核的逻辑 CPU 取消勾选,只留下 P 核,确认之后进程就只能在这几个大核上跑了。
但这个方法有几个明显限制。第一,它只对当前运行的这个进程实例生效,进程一重启,设置就丢了。第二,它设置的是整个进程的亲和性,进程后来创建的新线程默认会继承,但如果程序内部自己调用 API 修改线程亲和性,任务管理器里的勾选可能管不住部分线程。第三,某些受保护的系统进程或反作弊驱动保护的进程,右键菜单里可能都点不开这个选项。
任务管理器适合用来做临时验证:先开一个疑似卡顿的程序,手动绑到 P 核,看体感和资源监控有没有变化。如果效果确认了,再考虑用下面的持久化方案。
4.2 PowerShell 与 start /affinity 的进阶操作
想更精确地控制,PowerShell 也可以。先拿到进程对象,再直接改 ProcessorAffinity 属性:
powershell复制$p = Get-Process -Name "HeavyTask"
$p.ProcessorAffinity = [IntPtr]0xFFF
这里的 0xFFF 是亲和性掩码的十六进制写法,表示允许 0~11 号逻辑 CPU。具体掩码值怎么来,要先用工具确认你的逻辑 CPU 拓扑,再算出目标和核心对应的 bit。比如你想让进程只跑在 12~19 号 E 核上,那掩码就是二进制从 bit12 到 bit19 全置 1,十六进制是 0xFF000。用 PowerShell 设完可以通过 $p.ProcessorAffinity 读回当前值,确认修改成功。
还有一个比较隐蔽但是好用的启动方式:在 CMD 里用 start 命令的 /affinity 参数。比如我要让程序启动后只能跑在 P 核(假设是逻辑 0~11),可以这样:
cmd复制start "HeavyTask" /affinity FFF "C:\Path\To\HeavyTask.exe"
注意 /affinity 后面跟的是十六进制掩码但不带 0x 前缀,这是 CMD 的一个老传统。这种方式适合写进批处理或快捷方式里,每次启动都自动带上。缺点和任务管理器一样:进程一旦被父进程拉起来之后,如果它内部再把亲和性重置了,外部就管不住。
4.3 想长期生效,用 Process Lasso 这类工具做规则
如果你遇到的是那种每天都要折磨你一次的程序,我建议不要折腾命令行脚本,直接上 Process Lasso。它可以针对某个可执行文件名保存规则,比如“永远把这个进程的亲和性限制在 P 核上”,而且支持“CPU Sets”而非传统亲和性。
CPU Sets 和传统亲和性有个明显区别:CPU Sets(对应 Windows 的 SetProcessDefaultCpuSetMasks 系列 API)在大小核架构下更灵活,它更像“主要允许在哪些核心上跑”,Windows 调度器仍然有机会在必要时把线程临时放到其它核心,而不是死板地一刀切。Process Lasso 生成的规则是常驻的,进程每次启动都会自动应用,对游戏、直播软件、IDE 这类“每次都要手动设置一次就烦死人”的场景非常省心。
我自己的习惯是:先用任务管理器或者 Process Lasso 的实时核心占用面板观察几天,确认这个程序的线程确实长期堆积在 E 核上,再考虑给它上规则。如果是程序本身有线程池任务,且不同线程对延迟不敏感,通常没必要绑;只有当验证结果显示“绑了大核之后明显更稳/更快”时,才值得去做一条永久规则。
5. 验证绑核结果:先确认跑了再说优化
5.1 Linux 上直接查看线程落在哪个逻辑 CPU
绑完核最怕的就是“我觉得它已经跑在大核上了”。Linux 下验证的方式非常直白,用 ps 就能看到每个线程当前所在的核心:
bash复制ps -eLo pid,tid,psr,comm | grep -E "my_program"
输出里第三列 psr 就是当前线程正在跑的处理器编号。比如某个 PID 下有好几个 TID,如果 psr 都落在 0~11 之间,就说明线程确实在 P 核上;如果仍有个别线程出现在 12~19,那你就要检查是不是该进程里存在没继承亲和性的线程,比如某些程序用别的方式单独创建了线程组。
如果想看动态变化,可以写个简单循环盯着:
bash复制while true; do ps -eLo pid,tid,psr,comm | grep my_program; sleep 0.2; done
再配合 taskset -pc <PID> 看进程允许列表有没有变化,就能判断是不是有软件或驱动偷偷把它改了。Linux 上还有一个值得看的文件是 /proc/<PID>/status 里的 Cpus_allowed_list,它是内核视角下当前进程的真实亲和性范围,比某些工具读出来的信息更底层。
5.2 Windows 上的观察方式
Windows 上没有 Linux 那种一行命令看线程当前所在逻辑处理器的工具,观察起来要稍微绕一下。
最土但有效的办法是把任务管理器切到“性能”标签页,右键 CPU 图选择“更改图形为” -> “逻辑处理器”。这时你会看到 20 个(或者更多)小图,每个小图代表一个逻辑 CPU。你运行程序后观察哪些小图被顶起来,再对照你自己整理的 P/E 核编号表,就能知道程序大概跑在哪些核上。
如果想更精确地看到某个进程的线程分布,可以用 Process Explorer(Sysinternals 工具)。打开进程属性切到 Threads 标签,能看到进程内每个线程的 CPU 占用历史;配合 Process Lasso 的按核心实时占用面板,能更直观地判断进程线程落在 P 还是 E。 Windows 自带的性能监视器也能加计数器,但对普通用户来说门槛偏高,我不建议一上来就上那套。
5.3 用实际成绩说话
确认了线程落在哪个核心,只是第一步,最终还是要看成绩有没有变化。拿一个纯 CPU 密集的测试负载做前后对比是最公平的:
bash复制# 先在 E 核上跑,做基线
taskset -c 12-19 sysbench cpu --threads=1 --time=10 run
# 再到 P 核上跑
taskset -c 0-11 sysbench cpu --threads=1 --time=10 run
比较两次输出的 events per second,你能很直观地看到同一个计算任务在 P 核和 E 核上的吞吐差距,这个差距通常能到 20%~40%。对实际程序,最好用程序内置的 benchmark 或者是完整跑一遍真实任务计时,不要只盯着任务管理器里的 CPU 占用率判断,因为占用率只能说明核心被用了多少,不能说明程序体感是否变好。
6. 哪些情况该绑、哪些情况别乱绑
6.1 值得用亲和性守住大核的场景
先说值得绑的情况。第一类是延迟敏感的单线程或少量线程的程序,比如串口数据采集、音频实时处理、游戏模拟器主线程、交互式科学计算界面。这类程序好不好用,取决于“单个核心能多快跑完关键路径”,把它摁在 P 核上,延迟通常能明显下降。
第二类是明显的调度不当的程序。Windows 10 跑在 Intel 大小核平台上,或者某些老程序初始化线程时没标记优先级,系统把它长期放在 E 核集群上,绑到 P 核属于“拨乱反正”。
第三类是虚拟化场景。QEMU/KVM 给虚拟机分配 vCPU 时,如果宿主机核心调度不太平,你可能会看到虚拟机内部线程在各个核心之间乱跳,最直接的解法就是把每个 vCPU 线程绑到固定的物理核心上,减少上下文迁移带来的抖动。这类用法在数据库、金融交易、工业控制等对时延敏感的场景很常见。
6.2 不适合绑核或容易帮倒忙的情况
越是全核并行的负载,越别乱绑。比如编译大型项目、视频渲染、科学计算里的多线程求解器,它们本来就希望充分利用所有核心,包括 E 核。你如果强行把它们限制在 P 核上,虽然单核更强,但总吞吐反而会下降,还可能导致 P 核温度飙升、降频,最终成绩更差。
很多人在笔记本上踩过一个坑:觉得“全绑 P 核肯定最猛”,结果绑完之后跑渲染任务,P 核瞬间过热撞功耗墙,整体速度还不如默认调度把任务匀给 P+E 核来得稳。笔记本的散热和供电是硬约束,P 核不可能长时间保持高频率满载。所以绑核之前先问自己一个问题:这个程序的瓶颈到底是不是“单核延迟”?如果不是,别动。
还有一种情况是绑核之后进程内部线程数大于绑定的核心数,大量的线程挤在少数几个逻辑 CPU 上互相切换,上下文切换开销可能远超省下的调度开销。这种情况下你应该先调整进程的线程数,再谈绑核。
6.3 一个容易踩的细节:超线程下到底该选哪些逻辑核
最后补一个实操中最容易忽略的细节:超线程物理核心的选择。以 6P+8E 的笔记本为例,P 核开了超线程,P0 核心就有两个逻辑 CPU(比如 0 和 1)。很多用户图省事,绑核列表直接写 0-11,结果 6 个 P 核的 12 个逻辑线程全被允许,也就是 6 个物理核心同时跑 12 个线程。
对纯单线程或低线程数负载这没问题,反正只用其中一个线程,另一个逻辑线程空着或者跑些轻量后台任务。但对高负载多线程任务,如果 12 个线程同时挤在 6 个物理 P 核上,超线程带来的提升通常只有 10%~20%,而功耗和温度会明显上涨。某些场景反而应该把进程绑在 6 个物理 P 核的“第一条逻辑线程”上,比如 0,2,4,6,8,10,物理核心一个线程一个活,另一个逻辑线程留给系统调度其它轻量任务。
判断同属一个物理核心的两个逻辑 CPU 编号,Linux 看 /sys/devices/system/cpu/cpuX/topology/thread_siblings_list,Windows 可以用 Coreinfo 这类工具查看逻辑处理器到物理核心的映射关系。绑核参数这种事,真不是“网上抄一段就行”,你至少得先花两分钟确认自己的拓扑图,才能避免绑到 HT 兄弟核上白费力气。
我自己在处理这类问题时,一般会先按“跑关键线程的程序绑 P 核、全核吞吐类负载不绑、后台服务限制到 E 核”这三个原则走。大多数情况下,真正需要 CPU 亲和性来救火的程序并不多,但只要遇到了对的那一个,你会发现之前纠结半天的“大核闲置”问题,其实也就是一行命令的事。
