前阵子排查一台编译机性能问题时,我发现 load average 不算高,但个别 CPU 核的占用率经常冲到 95% 以上。当时我第一反应是想知道,是不是某个编译任务被意外绑了核,或者某个进程总是被调度器踢到同一个核上。在 LINUX 里查一个进程当前在哪个 CPU 核上运行,其实有不少办法,但每种办法都有各自的适用场景,用不对反而会得出错误结论。
这篇文章我会把实际工作中用过的查询手段、底层数据来源和容易踩的坑完整梳理一遍。无论你是刚接触 Linux 的新手,还是已经在用 top/ps 处理过不少问题的老手,应该都能找到一些平时不太容易注意到的细节。核心围绕一个问题:当你说“进程在哪个核上”的时候,你真正需要的信息到底是什么?这句话背后藏着好几种不同层次的需求,搞清楚了才能选对命令。
1. 查之前先想清楚:进程和 CPU 核之间的关系怎么影响性能
1.1 逻辑核、物理核与“处理器编号”
先说一个最基础的概念。我们在 Linux 里看到的 CPU 核编号,其实不是物理核心的编号,而是“逻辑处理器编号”。在超线程开启的机器上,一个物理核会分裂成两个逻辑核;在 ARM 大小核架构上,不同频率的核心也会混在一组编号里。所以查“进程在哪个核上”,本质是在问“进程在哪个逻辑处理器上跑”,也就是调度的最小单位。
用lscpu可以很直观地看到这套拓扑:
lscpu输出里重点看几个字段:CPU(s)是逻辑 CPU 总数,Thread(s) per core是每个物理核心上的线程数,NUMA node0 CPU(s)则告诉我们不同 NUMA 节点各自包含哪些逻辑核。比如常见的双路服务器上,逻辑核编号往往是交叉排列的:node0 对应 0-7 和 16-23,node1 对应 8-15 和 24-31。
/proc/cpuinfo里的字段更细一点:processor就是逻辑核编号,physical id是物理 CPU 插槽编号,core id是物理核心编号。通过这三个字段的对应关系,可以推算出某个物理核心到底对应哪几个逻辑核。这些信息在做 CPU 绑核时会非常关键,因为如果只看逻辑核编号而不看物理拓扑,你很可能把同一个物理核心的两个超线程当成两个独立核来用,性能收益大打折扣。
1.2 最典型的三种排查场景
什么时候会真正用到“查进程在哪个核”这个操作?我归纳下来主要有三种场景。
第一种是单核打满但其他核空闲。很多单线程程序有这个特征,比如部分数据库的 WAL 写入线程、某些编译任务。出现这种情况时,确认它是不是总在同一个核上运行能帮助我们判断问题性质:如果它频繁换核,cache 命中率会有波动,延迟抖动更明显;如果它稳定在一个核上,那问题可能出在单个核的计算能力上限。
第二种是验证 taskset 绑定是否生效。代码里用了sched_setaffinity或者启动命令里带了taskset,运行一段时间后想确认线程到底有没有被固定在目标核上。这时候查出来的结果就是绑定操作是否生效的直接证据。
第三种是 NUMA 架构下的跨节点访问问题。在 NUMA 机器上,核被分成不同 node,进程如果跑在 node0 的核上,却频繁访问 node1 的内存,内存访问延迟会显著增加。这种情况下先确认进程当前在哪个 node 的核上,再确认内存分配在哪个 node,两个信息一对照,问题基本就清楚了。
1.3 进程为什么会“换核”跑
理解了这个底层机制,你就明白为什么“查进程在哪个核”这个问题不只是一个静态查询。
Linux 的 CFS 调度器会持续做负载均衡。当某个核的运行队列过长,而其他核相对空闲时,调度器会把一些进程迁移到空闲核上。这个迁移动作的代价并不小:原核上的 L1/L2 缓存、TLB 等局部信息全部失效,新核要重新建立这些缓存。对性能敏感型应用来说,频繁迁移会带来明显的延迟抖动和吞吐量下降。
所以你会看到这样的现象:一个进程在没有绑核的情况下,用ps连续查几次,每次显示的核编号都可能不同。这很正常,是调度器在做全局负载均衡。理解了这个,“进程在哪个核上”这个问题就变成了一个动态观察题,需要连续采样而不是看一次静态结果就能下结论。
2. 四类常用查询手段与实测对比
2.1 ps 一条命令直接看 PSR 列
最直观的方法还是ps。很多人用ps只看 PID、CPU、MEM 这些常规列,不太注意到psr这个字段。psr是 processor number 的缩写,含义就是“进程当前正在或最后一次运行的处理器号”。
查所有进程的分布情况:
ps -eo pid,comm,psr | head -20只看某个指定 PID:
ps -o pid,comm,psr -p 12345-e表示所有进程,-o指定输出字段。如果你担心命令太长记不住,可以直接在ps后面加-F或-l看完整格式,但那些输出里不一定直接包含psr,还是-o最干净。
实际输出会长这样:
PID COMMAND PSR 12345 java 3表示 PID 12345 的 java 进程最后运行在 3 号逻辑核上。这里有个很容易误读的细节:psr显示的是“最后运行的核”,不是严格意义上的“当前正在运行的核”。如果进程在采样前刚好被调度走,或者在两个采样间隙发生了迁移,你看到的就是上一次运行的核编号。
如果还想看某个进程的所有线程分别落在哪些核上,加上-L参数:
ps -L -p 12345 -o pid,tid,comm,psrTID列就是线程 ID。在 Linux 里线程本质上就是共享地址空间的进程,所以ps -L会把它们都列出来。这一步在处理多线程 Java 应用、Nginx worker 进程时特别有用,能看出线程有没有被均匀打散到不同核上。
2.2 top 里打开 Last Used CPU 列
如果你习惯用top观察系统状态,那可以顺手把进程的 CPU 核编号列打开。默认的top没有显示这个字段,需要手动配置一下。
进入top后按f键,进入字段管理界面。用上下键搜索P: Last Used CPU (SMP),按空格键让左侧出现星号,表示该字段被选中显示。最后按q返回主界面,列表里就会多出一列P。
不同版本的top字段名会有点差异,procps-ng 版本一般叫Last Used CPU (SMP),有些老版本可能显示为Last CPU或者Processor。找不到的时候按f后输入/搜索CPU,基本都能快速定位。
这个方案的优势在于top本身自带每个核的负载条,按1可以展开所有核的实时占用率。一边看整体核负载,一边看进程落在哪个核,两个信息叠加能快速定位“是不是某个进程在持续占用某单个核”。缺点和ps一样,它只是瞬间采样值,反映的是当前显示时刻的状态,进程仍然可能在刷新间隙换核。
2.3 pidstat 按进程持续采样
如果已经装了 sysstat 包,pidstat是比ps更顺手的选择。它天然支持持续采样,输出自带时间序列,能直观看到进程在多个核之间跳来跳去的过程。
pidstat -p 12345 1每秒输出一行,其中CPU列就是这个进程最近运行的处理器编号。命令不会自动退出,需要Ctrl+C结束。这样跑一会儿就能看到完整的核分布情况。
想看线程级别的话,加-t参数:
pidstat -p 12345 -t 1输出会列出每个子线程的TID、%CPU和对应的处理器编号。对一个多线程服务做“线程分布体检”时,这个命令是最高效的。
不喜欢装额外工具的话,可以用一个简单的 shell 循环达到类似效果:
for i in {1..10}; do ps -o psr= -p 12345; sleep 0.2; done每次输出一行核编号,sleep 0.2控制采样间隔。连续 10 次后,看看数字是稳定在一个值还是各种乱跳,就能判断进程的迁移频率。
2.4 htop 的图形化 CPU 列
htop作为top的增强替代品,在交互体验上确实更好。不过默认配置并不一定会显示 CPU 核编号列,需要手动开启。
进入htop后按F2进入 Setup,选择Columns菜单,在右侧字段列表里找到PROCESSOR,把它加入显示列即可。保存退出后,每个进程后面会多出一列数字,显示它当前所在的逻辑核编号。
htop的体验优势在于顶部的核负载条是实时跳动的,下面每个进程的核编号也在实时变更。如果你在观察一个多线程程序,能看到一堆线程在几个核中间来回切换,视觉冲击力很强,也更容易理解“进程迁移”这个概念。
不过需要说明的是,htop不是标准工具,很多最小化安装的系统没有预装。没有权限装包时,还是优先用ps或pidstat。
四种方式各有用武之地,这里给个对比表方便直接选型:
| 方法 | 是否持续采样 | 是否支持线程级 | 适合场景 | 是否需额外安装 |
|---|---|---|---|---|
| ps -o psr | 否 | 需加 -L | 快速确认当前状态 | 否 |
| top P 列 | 否 | 需按 H 或 -H 启动 | 边看负载边看进程分布 | 否 |
| pidstat -p | 是 | 需加 -t | 观察迁移、持续监控 | sysstat |
| htop PROCESSOR 列 | 是 | 是 | 图形化直观查看 | htop |
3. 从 /proc 文件系统直接读底层字段:搞懂数据从哪来
3.1 /proc/[pid]/stat 里的 processor 字段
所有上层命令查询进程信息的最终来源,其实都是/proc伪文件系统。Linux 内核会为每个进程创建一个目录/proc/[pid]/,里面放着各种运行时信息。其中/proc/[pid]/stat是进程状态的核心文件,ps、top、pidstat最终读的都是它。
这个文件看起来是一长串空格分隔的数字,其中第 39 个字段就是processor,代表这个进程最近一次运行在哪个 CPU 上。直接用awk提取:
cat /proc/12345/stat | awk '{print $39}'输出一个数字,比如3,表示它在 3 号逻辑核上运行过。用ps验证一下:
ps -o pid,comm,psr -p 12345两边结果一般是一致的。原因很简单:ps的实现本质上就是从/proc/[pid]/stat这个文件里解析出psr字段,然后格式化输出给你看。
这里值得提一句:虽然第 39 个字段在主流内核版本里比较稳定,但不同发行版、不同内核版本下,字段数量可能略有差异。比如较新内核在文件末尾追加了guest_time、cguest_time等字段,但前面的编号基本没变过。如果想很严谨地确认字段位置,可以查看内核源码里的fs/proc/array.c,那是这个文件的“出生地”。
3.2 一个更易读的命令:/proc/[pid]/status
/proc/[pid]/stat虽然精确,但可读性太差。实际排查时,我更常看/proc/[pid]/status,它是stat的人性化版本,用关键词而不是数字位置来组织信息:
cat /proc/12345/status | grep -E 'Cpus_allowed|Mems_allowed'输出大致是这样:
Cpus_allowed: ff Cpus_allowed_list: 0-7 Mems_allowed_list: 0关键信息有两个:Cpus_allowed_list表示这个进程允许在哪些核上运行,Mems_allowed_list表示允许分配内存的 NUMA node 列表。通过这两个字段,可以快速判断进程有没有被 cgroup 或taskset限制了运行范围。
注意这里的限制:只告诉你有“资格”跑在哪组核上,不告诉当前具体跑在哪个核上。一个是“合法集合”,一个是“当前位置”,千万别把两者混淆。
3.3 为什么这两个信息要区分开
我见过不少新手在这个地方卡住:明明taskset -c -p 12345显示 affinity 是 0-3,但用ps -o psr一看进程却显示在 4 号核,于是以为命令出错了。
实际上这是两个完全不同的概念:
- CPU affinity(亲和性):允许进程使用哪些核,是调度器必须遵守的约束。
- PSR/processor:进程最后一次运行的核,是调度器在约束范围内做出的具体选择。
如果 affinity 是 0-3,进程只会在 0、1、2、3 里挑一个跑,永远不可能出现在 4 号核上。如果看到 PSR 不是 affinity 范围内的值,那才需要警惕,说明要么 affinity 设置没有真正生效,要么你查的不是同一个进程。
这个区分在理解调度行为时很重要。调度器会尽量尊重 affinity,但在内核里,affinity 不是“钉死在一颗核上”,而是“只允许在某个核集合里挑”。进程在集合内依然可能换核,only 那个集合的边界是刚性的。
4. 用 taskset 绑定之后,如何确认进程真的被钉在了目标核上
4.1 taskset 的两种用法
taskset是 Linux 下查看和设置进程 CPU 亲和性的标准工具。两种用法对应两种场景:启动新进程时指定绑核,或者给已运行的进程动态设置。
启动新进程时指定核:
taskset -c 0,2 ./myapp-c后面跟 CPU 列表,0,2表示只允许在 0 号核和 2 号核上运行。想给一段连续的核时用0-3,想排除某些核时用!前缀,比如taskset -c 0-7,16-23是 NUMA 节点本地核的标准写法。
给运行中的进程设置:
taskset -pc 0,2 12345-p表示操作已存在的进程,后面的数字是 PID。执行成功会输出类似:
pid 12345's current affinity list: ff pid 12345's new affinity list: 0,2第一行是旧值,第二行是新值。如果你只是想查看而不修改,直接执行:
taskset -pc 12345会输出当前 affinity list。很多文章写到这里就结束了,但我觉得一定要强调一个容易忽略的问题:这个命令输出究竟表示什么?它表示该进程可以在 0 和 2 这两个核上运行,并不是说它现在正跑在 0 或 2 上。是不是真的在目标核上,需要用第二章的方法再确认一步。
4.2 绑定前的进程可能“到处跑”
为了演示绑核前后的区别,可以做一个很简单的小实验。
开一个终端,跑一个持续占 CPU 的进程:
sha256sum /dev/zero在另一个终端找到它的 PID,然后快速循环查询几次:
for i in {1..5}; do ps -o pid,psr,comm -p $(pgrep -f sha256sum | head -1); sleep 0.1; done在没有绑核的情况下,你会看到 PSR 列的数值很可能每次都不同。比如第一次是 5,第二次是 12,第三次又变成 3。这个现象就是调度器在多个核之间做负载均衡的结果。
看到这个现象不要慌,这是 Linux 的正常行为。只有当这种迁移严重影响了性能,比如引发 cache 频繁失效时,才需要手动干预。
4.3 绑定之后如何验证
现在用taskset给刚才的sha256sum进程设置 affinity:
taskset -pc 0,2 $(pgrep -f sha256sum | head -1)再跑一次循环查询:
for i in {1..10}; do ps -o pid,psr,comm -p $(pgrep -f sha256sum | head -1); sleep 0.1; done这时的 PSR 列只会在 0 或 2 之间变化,永远不会出现其他数字。这就验证了绑定生效。
有一点要特别注意:就算绑定生效,进程也并不是固定占用其中某一个核,而是“在指定的几个核之间按调度器决定切换”。上面那个例子中,进程可以在 0 和 2 之间来回跑。如果你想让它严格钉死在某一个核上,就得把 affinity 列表收窄成一个:
taskset -pc 0 $(pgrep -f sha256sum | head -1)这样它就只能待在 0 号核上。
对于多线程进程,还有个额外坑:taskset -p默认只作用在指定的线程上。如果你对一个多线程进程执行taskset -pc 0-3 PID,受影响的是主线程,子线程的亲和性不会被自动修改。要处理所有线程,得遍历/proc/PID/task/下的每个线程 ID,或者干脆在进程启动时就用taskset -c 0-3 ./app统一设置。
遍历所有线程的命令可以这么写:
for tid in $(ls /proc/12345/task); do taskset -pc 0-3 $tid; done但更推荐的做法是:在设计阶段就把绑定逻辑放进代码里,用pthread_setaffinity_np在创建线程时设定好,避免运维阶段手工挨个设置。
4.4 用 numactl 同时处理 NUMA 绑定
如果机器是 NUMA 架构(现在多路服务器几乎都是),只看核绑不绑定不够,还得看内存访问是否跨 node。
用numactl启动进程时,可以同时约束 CPU 核和内存 node:
numactl --physcpubind=0-7 --membind=0 ./app--physcpubind限定 CPU 核范围,--membind限定内存分配来源,这里表示强制从 node0 分配内存。两者都绑在当前 node 上,就能避免远端内存访问的额外延迟。
验证方式还是老办法:先用lscpu确认 node0 对应哪些逻辑核编号,再用ps -o psr或pidstat观察进程是否落在该 node 的核范围内。如果发现进程确实在 node0 的核上,但numastat -p PID显示大量内存被分配在 node1,那内存分配策略还需要单独调整。
5. 一次真实排查:进程频繁迁移导致的性能抖动
5.1 现象:P99 延迟飙升,但负载不高
之前排查过一个 Java 网关服务,业务高峰期 P99 延迟从正常的 40ms 涨到 180ms,但看 load average 和 CPU 总使用率都不算高,基础监控看起来一片正常。这种“整体不高、局部异常”的现象,最需要怀疑的就是某个进程或线程的调度状态出了问题。
用top按1展开所有核的负载,能看到明显的不均匀:有的核使用率 80% 以上,有的核只有 10% 左右。但光看负载条还不能定位,需要知道是哪个进程造成的。
5.2 定位过程:从 pidstat 到线程级排查
第一步用pidstat锁定目标进程:
pidstat -p <PID> 1输出里的CPU列暴露了问题:进程的处理器编号一直在变,5、23、11、5、23、11……一个周期内频繁跳转,说明它在多个核之间被移来移去。
第二步用线程级视角确认:
ps -L -p <PID> -o tid,comm,psr | sort -k3发现大量业务线程交错分布在不同的核上,缺少局部性。正常情况下,如果一个服务是接在同一个 NUMA 节点上运行,线程应该集中在 node 本地的几个核附近。现在这种分布说明调度器已经把它打散了。
第三步验证 cache 受影响的程度。我用了perf stat:
perf stat -e cache-misses,cache-references -p <PID> sleep 10对比历史数据,cache miss 率明显偏高。到这里基本可以确定:进程频繁迁移,缓存命中率下降,最终导致延迟抖动。所有证据链路都指向“调度器过度迁移”这个根因。
5.3 为什么会出现频繁迁移
找到根因之后要问一句:为什么会频繁迁移?
排查后发现是机器上有其他负载在“捣乱”。机器上除了这个 Java 服务,还跑着日志采集 agent、监控脚本和一些定时备份任务。这些任务虽然 CPU 占用不高,但会周期性地把某个核的负载顶上去。调度器为了维持全局负载均衡,就会把 Java 线程迁到其他核上,等那边负载降下来了,又可能迁回来。一来一回,进程就在核间震荡。
用mpstat -P ALL 1可以很清楚地看到各核的上下文切换和%soft、%usr波动:
mpstat -P ALL 1当某个核的%soft突然升高(通常是网卡中断、软中断集中),就会触发调度器重新平衡。这个命令和pidstat搭配使用,能快速定位是哪类负载引发了迁移。
5.4 解决方案与验证
既然根因是“过度迁移导致缓存失效”,解决方案就很明确了:把核心业务线程绑定到固定的核上,同时把非核心的辅助任务挪到另一组核,避免它们干扰业务线程。
我当时的操作是:
- 用
taskset把 Java 主进程固定到 0-7 号核(单个 NUMA node 的本地核)。 - 日志采集 agent 和监控脚本通过 systemd 的
CPUAffinity限制到 24-31 号核。 - 确认
numactl --hardware里 node0 的内存访问路径最短,保证线程和内存分配在同一个 node。
绑定完继续用pidstat -p <PID> 1观察,CPU 列稳定在 0-7 之间,不再出现 11、23 这类“跨 node”的数字。同时再看numastat -p <PID>,内存访问几乎都在 node0 本地节点。P99 延迟重新回到 40ms 附近。
这个案例说明,查进程在哪个核并不只是看一个数字那么简单。当这个数字频繁变化时,背后往往藏着一个真实的性能问题。定位它、理解它、解决它,才是我们学习这个命令的真正目的。
6. 这些细节不注意,查了等于白查
6.1 PSR 列是“最后运行”而不是“正在运行”
全文最重要的提醒放在这里:ps的psr列、top的P列、/proc/[pid]/stat的processor字段,本质上都是“最后一次运行的处理器号”。
这个区别影响很大。如果一个进程最近一次被调度后一直处于睡眠状态,或者刚从一个核迁移到另一个核但还没被重新调度,那 PSR 反映的就是旧位置。解决方式是连续采样,比如用pidstat -p PID 1跑个 10 秒,或者用 shell 循环多取几次值,再分析分布情况。单看一次值就下结论,是排查中最容易犯的错误。
6.2 空闲进程会显示 -1
某些特殊场景下,ps -o psr会输出-1或空。这代表进程还没在任何 CPU 上运行过,常见于刚 fork 出来的子进程,或者某些处于 D 状态(不可中断睡眠)的内核线程。
看到-1不需要慌张。它只是告诉你“没有有效的运行位置信息”,而不是异常。通常等进程真正被调度一次之后,这个值就会变成正常的核编号。
6.3 容器环境看到的 /proc 不一定是宿主机
在 Docker 容器里执行cat /proc/<pid>/stat时,这个<pid>是容器命名空间内的 PID,不是宿主机的全局 PID。容器内看到的进程列表和宿主机看到的可能完全不同。
如果容器没有使用 host PID namespace,那么你在容器内只能看到容器自己的进程,看不到宿主机上其他负载。反过来,如果你在宿主机上执行命令查容器内进程,/proc/<pid>看到的仍然是普通进程,叫做“Pid 1”在容器里是 init 进程,在宿主机上就是另一个进程。
因此,排查跨容器、跨进程的 CPU 调度问题时,要明确自己执行命令所在的环境,最好统一在宿主机上用ps -eo pid,comm,psr获取全局视图,再结合容器的cgroup路径判断归属。
6.4 NUMA 架构下,核编号跨节点交叉
很多服务器上逻辑核编号不是按物理核心顺序排的。在双路服务器上,一个常见布局是:node0 的核为 0-7 和 16-23,node1 的核为 8-15 和 24-31。这种交叉编号经常把第一次接触 NUMA 的人搞晕。
举一个真实的例子:你发现进程跑在“16 号核”上,看起来好像没什么问题。但如果你的内存分配在 node1,而 16 号核属于 node0,那每次内存访问都变成跨节点访问。延迟增加是必然的。
所以看到核编号的时候,下意识配合lscpu看一眼 NUMA 拓扑:
lscpu | grep -A4 'NUMA node'输出会列出每个 node 对应的核列表。这样就能快速判断“进程在 node0 的核上,内存应该在 node0 分配”是否成立。在 NUMA 架构下,“在哪个 node”比“在哪个核”更重要。
6.5 绑定核之前要确认是否真的需要
最后说一个反直觉的建议:不是所有进程都值得绑核。绑核相当于把调度器的灵活性锁死了,如果一个机器上跑的任务类型复杂、各时段负载波动大,过度绑核反而会造成资源碎片化。
我的经验是:只有几类场景值得做绑核。第一类是延迟敏感型服务,比如交易系统、游戏服务器,这类服务对 P99 延迟的要求极高,哪怕一次 cache 失效引起的抖动都可能产生影响。第二类是 DPDK 收包、网卡轮询这类需要严格绑定 CPU 的高性能网络程序。第三类是虚拟机 vCPU 绑定,避免 vCPU 在物理核间迁移导致虚拟化开销增加。
其他场景,如果你只是看到某个进程换核就比较焦虑,其实没必要。调度器的默认行为在绝大多数情况下是合理的。真要限制资源,我建议优先用 cgroup 的 cpuset 子系统,而不是直接taskset。cgroup cpuset 可以管理一个进程组的整体核集合,让组内的线程在集合内自由调度,效果更灵活也更可控。单纯用taskset一个一个线程去钉,在管理大规模服务时反而会变成运维负担。