拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux中查看进程CPU核占用:ps/top/pidstat与taskset实战解析

Linux中查看进程CPU核占用:ps/top/pidstat与taskset实战解析

前阵子排查一台编译机性能问题时,我发现 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,psr

TID列就是线程 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一个一个线程去钉,在管理大规模服务时反而会变成运维负担。

返回列表