Linux 基础体系 · 第 33/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux procfs 与进程观测:状态、文件描述符、内存映射和线程

procfs(process filesystem,进程文件系统)是 Linux 内核提供的伪文件系统,通常挂载在 /proc。它不像 ext4 一样把普通文件持久化到磁盘,而是把内核中的进程、线程、内存、文件描述符、内核参数等对象,以目录和文件的形式导出。

因此,下面的命令:

cat /proc/$$/status
ls -l /proc/$$/fd
cat /proc/$$/maps

并不是在读取某个用户态数据库,而是在读取内核当前生成的观测视图。

这一区别决定了 procfs 的三个基本性质:

  1. 数据可能随时变化:进程可以在读取前退出,线程可以创建或消失,映射可以被 mmap()munmap() 修改。
  2. 读取通常不是事务快照:依次读取 statusfdmaps,不保证它们对应同一个瞬间。
  3. 访问受权限和命名空间影响:不同用户、不同 PID namespace、不同 /proc 挂载选项,可能看到不同内容。

一、先建立观察对象:PID、TGID、TID 和 procfs 路径

Linux 内核同时维护“进程组”和“线程”两个层面的标识。

1. 进程 ID 和线程 ID

在 Linux 用户空间中:

  • getpid() 返回线程组 ID,即通常所说的进程 ID;
  • gettid() 返回当前线程的线程 ID;
  • 一个多线程进程中的所有线程共享同一个 TGID,但每个线程有独立的 TID;
  • 线程组组长的 TID 通常等于 TGID。

例如,一个进程可能具有:

TGID = 4200
TID  = 4200   主线程
TID  = 4201   工作线程
TID  = 4202   工作线程

procfs 中常见的路径关系是:

/proc/4200/              进程视图
/proc/4200/task/4200/    主线程视图
/proc/4200/task/4201/    工作线程视图
/proc/4200/task/4202/    工作线程视图

/proc/4200/task/ 下的每个数字目录代表一个线程。进程级文件通常位于 /proc/4200/,线程级文件位于 /proc/4200/task/4201/

Linux 还允许通过 /proc/<tid>/ 访问某些线程目录,但这种目录的枚举行为和可见性有特殊规则。编写诊断程序时,应优先使用明确的 /proc/<tgid>/task/<tid>/ 路径。

两个特殊路径很重要:

/proc/self/        发起访问的进程自身
/proc/thread-self/ 发起访问的当前线程

例如:

cat /proc/self/status
cat /proc/thread-self/status

当程序通过 open()readlink() 等系统调用访问这些路径时,内核会根据执行该系统调用的进程或线程解析它们,而不是根据 shell 的 PID 固定展开。

2. PID namespace 会改变“看见的 PID”

容器中的进程可能同时拥有多个 PID:

  • 在容器 PID namespace 中看到的 PID;
  • 在宿主机 PID namespace 中看到的 PID。

/proc/<pid>/status 中的 NSpid 字段可以展示该进程在嵌套 PID namespace 中的 ID,例如:

NSpid:  4200 13752

具体列数取决于 namespace 层级。容器内的 /proc 如果正确挂载并关联到容器的 PID namespace,通常只会显示容器视角下可见的进程。

因此,排查宿主机进程时,不能把容器内看到的 PID 直接当作宿主机 PID 使用。


二、procfs 不是快照:观察过程中的一致性问题

假设执行:

pid=4200
cat /proc/$pid/status
ls -l /proc/$pid/fd
cat /proc/$pid/maps

这三个动作之间可能发生以下事件:

sequenceDiagram
    participant O as 观察者
    participant K as 内核对象
    participant P as 目标进程

    O->>K: 读取 /proc/4200/status
    K-->>O: 进程仍为运行状态

    P->>K: close(fd)
    P->>K: munmap(region)
    P->>K: exit()

    O->>K: 枚举 /proc/4200/fd
    K-->>O: 目录不存在或内容已变化

    O->>K: 读取 /proc/4200/maps
    K-->>O: ENOENT,或得到不同时间点的视图

因此:

  • status 显示进程存在,并不保证下一次访问 /proc/<pid>/fd 时它仍然存在;
  • ls /proc/<pid>/fd 成功,不代表随后读取某个 fd 仍然成功;
  • maps 中某个地址存在,不代表随后对该地址执行 process_vm_readv() 或调试操作时仍存在;
  • PID 退出后可能很快被复用,单纯保存一个数字不能证明后续访问的仍是同一进程。

Linux 提供 pidfd_open() 和 pidfd 相关 API 来稳定地引用一个进程对象。诊断工具若需要在长时间运行中跟踪进程,应考虑使用 pidfd,而不是只保存 PID 字符串。pidfd 不能消除 procfs 内容的动态性,但可以降低 PID 复用导致“跟错进程”的风险。


三、查看进程总体状态:statusstatstatm

3.1 /proc/<pid>/status:适合人和脚本阅读

执行:

pid=$$
cat /proc/$pid/status

常见输出片段如下:

Name:   bash
Umask:  0022
State:  S (sleeping)
Tgid:   4200
Ngid:   0
Pid:    4200
PPid:   3991
TracerPid:      0
Uid:    1000    1000    1000    1000
Gid:    1000    1000    1000    1000
Threads:        1
VmPeak:  10532 kB
VmSize:  10532 kB
VmRSS:   4096 kB
RssAnon:         2048 kB
RssFile:         2048 kB
RssShmem:           0 kB
voluntary_ctxt_switches:        12
nonvoluntary_ctxt_switches:     1

关键字段可以分成几组。

生命周期与关系

  • State:当前任务状态;
  • Pid:在当前 PID namespace 中的进程或线程 ID;
  • PPid:父进程 ID;
  • Tgid:线程组 ID;
  • TracerPid:正在跟踪它的进程 ID,未被跟踪时通常为 0
  • Threads:线程组中的线程数量。

身份与权限

  • UidGid:真实、有效、保存的 set-user-ID,以及文件系统 UID/GID 等;
  • CapInhCapPrmCapEff 等:Linux capability 集合;
  • NoNewPrivs:是否设置了 PR_SET_NO_NEW_PRIVS
  • Seccomp:是否启用了 seccomp。

内存

  • VmSize:虚拟地址空间总量;
  • VmRSS:当前驻留在物理内存中的页的大致总量;
  • RssAnon:匿名页 RSS;
  • RssFile:文件映射页 RSS;
  • RssShmem:共享内存相关 RSS;
  • VmSwap:被换出的匿名页等信息,具体含义依内核版本而异。

VmSizeVmRSS 的差异很重要:

VmSize:进程“声明或保留”的虚拟地址空间
VmRSS :当前部分驻留在 RAM 中的页

一个进程可以通过 mmap() 预留数百 GiB 的稀疏地址空间,却只实际触碰其中几 MiB,此时 VmSize 很大而 VmRSS 很小。

VmRSS 也不是精确的“独占物理内存”。共享库、共享内存和共享匿名页可能被多个进程共同计入 RSS。需要分析共享页归属时,应进一步查看 smaps 中的 Pss

调度与上下文切换

  • voluntary_ctxt_switches:进程主动阻塞或让出 CPU 导致的上下文切换;
  • nonvoluntary_ctxt_switches:被调度器抢占导致的上下文切换。

这些字段可以帮助判断一个进程是否频繁阻塞、是否存在调度竞争,但不能单独证明性能瓶颈。系统调用耗时、运行队列、锁竞争和 I/O 等仍需要结合其他工具观察。

3.2 状态字符的含义

State 常见值包括:

字符 含义
R Running,正在运行或可运行
S Interruptible sleep,可中断睡眠,通常在等待事件
D Uninterruptible sleep,不可中断睡眠,常见于某些 I/O 等待
T Stopped,被信号或调试器停止
t Tracing stop,处于跟踪停止状态
Z Zombie,僵尸进程
X Dead,已死亡,通常很短暂
I Idle,主要用于部分内核线程状态

S 并不等于“异常”。例如阻塞在 poll()futex() 或管道读取上的正常服务线程,通常会处于可中断睡眠。

D 也不必然表示磁盘故障,但如果一个任务长时间处于 D,常见方向包括:

  • 块设备或网络文件系统 I/O;
  • 内核驱动等待;
  • NFS 等远程文件系统;
  • 持有某些内核资源时等待不可中断事件。

不能通过向 D 状态任务发送普通 SIGKILL 就保证它立即消失。信号通常要等任务返回到可处理信号的执行路径后才生效。

3.3 /proc/<pid>/stat:适合机器读取,但解析容易出错

stat 是一行字段,适合获取调度统计和生命周期计数。例如:

cat /proc/$$/stat

其中重要字段包括:

  • 第 1 项:PID;
  • 第 2 项:comm,进程名,包含在括号中;
  • 第 3 项:状态字符;
  • 第 4 项:父进程 PID;
  • 第 14、15 项:用户态和内核态 CPU 时间,单位通常为 clock ticks;
  • 第 22 项:进程启动时间,单位通常为 clock ticks,自系统启动以来;
  • 第 23 项:虚拟内存大小;
  • 第 24 项:驻留集页数。

这里有一个经常导致监控错误的边界:第二项 comm 可以包含空格,甚至包含右括号。不能简单地使用:

awk '{print $3}' /proc/$pid/stat

来可靠解析所有字段,因为空格会改变列切分,括号也会干扰简单解析。健壮解析器应先找到第一个 ( 和与其匹配的最后一个 ),将中间内容作为 comm,再从右括号之后解析剩余字段。

CPU 时间的计算也需要区分单位。若两个采样时刻的用户态 tick 差为 du,内核态 tick 差为 ds,系统配置的每秒 tick 数为 CLK_TCK,则进程在这段时间消耗的 CPU 秒数近似为:

Tcpu=du+dsCLK_TCKT_{\text{cpu}} = \frac{du + ds}{\text{CLK\_TCK}}

在采样间隔为 Δt\Delta t 秒、机器有 NN 个逻辑 CPU 时,单进程相对总机器容量的 CPU 百分比近似为:

Pmachine=TcpuN×Δt×100%P_{\text{machine}} = \frac{T_{\text{cpu}}}{N \times \Delta t} \times 100\%

若想表示“单核满载”为 100%,则不除以 NN

Pone-core=TcpuΔt×100%P_{\text{one-core}} = \frac{T_{\text{cpu}}}{\Delta t} \times 100\%

这解释了为什么某些工具中的多线程进程 CPU 使用率可能超过 100%。

3.4 /proc/<pid>/statm:便宜但不适合精确内存归因

cat /proc/$$/statm

它返回若干页数,例如总程序大小、驻留集大小、共享页等。该接口读取成本低,但字段粒度和统计语义有限,不能代替 smaps 进行详细内存归因。需要注意页大小可通过:

getconf PAGE_SIZE

查询,不能把字段直接当作字节。


四、父子关系、退出和 Zombie

进程退出并不意味着对应的 /proc/<pid> 目录立刻完全消失。

一个子进程调用 _exit() 后,内核会:

  1. 释放其大部分用户态资源,例如地址空间和文件描述符;
  2. 保存退出码、资源使用统计等少量信息;
  3. 将任务标记为 Z,即 Zombie;
  4. 通知父进程;
  5. 等待父进程调用 wait()waitpid() 或相关接口回收它。

父进程完成 wait 后,内核才释放剩余的进程描述信息,PID 也可以被重新分配。

观察僵尸进程:

ps -o pid,ppid,stat,cmd -p "$pid"
cat /proc/"$pid"/status

可能看到:

State:  Z (zombie)

僵尸进程通常已经没有可用的用户态地址空间和正常文件描述符资源,因此:

ls -l /proc/$pid/fd

可能为空,或者目标进程在访问过程中已经完成回收而返回 ENOENT。对 Zombie 发送 SIGKILL 没有意义,因为它已经不再执行用户代码;真正需要处理的是其父进程没有正确调用 wait。

如果父进程先退出,子进程会被重新托管给某个子收割者,现代 Linux 中也可能由 subreaper 接管。PPid 的变化不能简单解释为“进程自己改变了父进程”,而是进程生命周期和收割关系发生了变化。


五、文件描述符:进程打开了什么,以及“打开文件描述”是什么

5.1 /proc/<pid>/fd

查看当前 shell 的文件描述符:

ls -l /proc/$$/fd

典型输出:

lrwx------ 1 user user 64 ... 0 -> /dev/pts/2
lrwx------ 1 user user 64 ... 1 -> /dev/pts/2
lrwx------ 1 user user 64 ... 2 -> /dev/pts/2
lr-x------ 1 user user 64 ... 3 -> /etc/hosts
lrwx------ 1 user user 64 ... 4 -> socket:[123456]
lrwx------ 1 user user 64 ... 5 -> anon_inode:[eventpoll]

目录中的每个数字是一个文件描述符编号,它是进程文件描述符表中的索引,不是文件本身。

符号链接目标可能表示:

  • 普通文件:/etc/hosts
  • 目录:/tmp
  • 管道:pipe:[inode]
  • Unix socket:socket:[inode]
  • 匿名内核对象:anon_inode:[eventpoll]
  • 终端:/dev/pts/2
  • 已删除但仍被打开的文件:/tmp/data (deleted)

(deleted) 只说明目录项已经从文件系统命名空间中删除,不能说明文件内容立刻消失。只要仍有打开引用,磁盘空间通常仍被占用,直到最后一个引用关闭。

5.2 三层对象关系

理解 fd 需要区分三层:

进程 fd 表
    fd 3 ───────┐
                ▼
        open file description
        文件偏移、状态标志、引用计数
                │
                ▼
        inode / socket / pipe 等内核对象

调用:

int fd1 = open("data", O_RDONLY);
int fd2 = dup(fd1);

会得到两个 fd 表项,但它们指向同一个 open file description,因此共享当前文件偏移。

相反:

int fd1 = open("data", O_RDONLY);
int fd2 = open("data", O_RDONLY);

通常会创建两个独立的 open file description,它们的当前偏移彼此独立,即使最终指向同一个 inode。

fork() 会复制父进程的 fd 表,但父子进程的表项仍指向相同的 open file description。因此父子进程通常共享文件偏移和文件状态标志。execve() 是否保留某个 fd,则取决于该 fd 是否设置了 FD_CLOEXEC

这一区别解释了许多并发写文件、日志交错和管道关闭不及时的问题。

5.3 /proc/<pid>/fdinfo/<fd>

cat /proc/$$/fdinfo/0

普通文件或终端常见输出:

pos:    0
flags:  0100002
mnt_id: 23

字段含义通常包括:

  • pos:当前文件偏移;
  • flags:打开文件状态标志,使用八进制表示;
  • mnt_id:挂载点 ID;
  • 某些特殊对象还会出现额外字段,例如 epoll、eventfd、timerfd、signalfd 等关联信息。

fdinfo 展示的是内核对象视图,不同内核版本和文件类型的扩展字段可能不同。不要假设所有 fd 都具有完全相同的字段集合。

flags 中的访问模式和状态标志,例如 O_RDONLYO_NONBLOCKO_APPEND,属于 open file description 的状态。FD_CLOEXEC 属于 fd 表项的描述符标志,不应仅依赖 fdinfo 推断它。程序需要精确判断时,应对本进程 fd 使用:

int flags = fcntl(fd, F_GETFD);

并检查 FD_CLOEXEC

5.4 用 lsof 和 procfs 交叉验证

lsof -p "$pid"

lsof 通常通过 procfs 以及其他系统接口,把 fd、映射和网络对象整理成更易读的表格。但它不是内核的另一份真相,目标进程快速变化时同样会遇到竞态。

如果某个 socket 显示为:

socket:[123456]

可以在 /proc/net/tcp/proc/net/unix 等接口中寻找对应内核对象,但这些文件的字段和网络 namespace 有关。容器中的 /proc/net 通常反映当前网络 namespace,不一定是宿主机网络视角。


六、内存映射:虚拟地址空间不是物理内存

6.1 /proc/<pid>/maps

cat /proc/$pid/maps

典型行:

55b7c0000000-55b7c0021000 r--p 00000000 08:01 123456 /usr/bin/example
55b7c0021000-55b7c0049000 r-xp 00021000 08:01 123456 /usr/bin/example
7f2c1a000000-7f2c1a021000 rw-p 00000000 00:00 0
7f2c1b000000-7f2c1b021000 rw-s 00000000 00:01 98765  /dev/shm/buffer
7ffd12000000-7ffd12021000 rw-p 00000000 00:00 0      [stack]

每行描述一个 VMA(Virtual Memory Area,虚拟内存区域)。字段依次是:

起始地址-结束地址 权限 文件偏移 设备号 inode 路径

地址范围

start-end 是半开区间:

[start,end)[start, end)

区域大小为:

size=endstart\text{size} = end - start

因此,地址等于 end 的字节不属于该 VMA。

权限

四个字符含义:

  • r:可读;
  • w:可写;
  • x:可执行;
  • p:私有映射(private,写时复制语义);
  • s:共享映射(shared)。

p 并不表示“物理页一定私有”。对于 MAP_PRIVATE,多个进程可以先共享同一物理页,某个进程第一次写入时才触发 COW(Copy-on-Write,写时复制)。

rw-p 的匿名区域可能是堆、线程栈、运行时分配区或匿名 mmap();不能只凭权限确定用途。

文件偏移、设备号和 inode

文件映射中的 offset 是映射对应的文件偏移,通常必须按页大小对齐。设备号和 inode 可以帮助把不同路径映射关联到同一个底层文件。

没有路径的区域并不一定是“没有对象”。常见匿名区域包括:

  • [heap]
  • [stack]
  • [vdso]
  • [vvar]
  • [vsyscall]
  • 匿名 mmap()
  • 动态链接器和线程运行时创建的区域

6.2 映射、驻留和提交不是同一个概念

一个 VMA 只代表虚拟地址区间。某页是否已经分配物理页、是否驻留内存,还要看实际访问行为和内存回收状态。

三个概念必须分开:

  1. 虚拟地址空间:页表可能尚未建立,或者页面尚未分配;
  2. 驻留集:当前在物理内存中的页;
  3. 提交承诺:系统是否承诺未来能为该虚拟内存提供资源,受 overcommit 策略影响。

例如:

void *p = mmap(NULL, 1UL << 30, PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

这可能创建约 1 GiB 的虚拟映射,但如果程序只触碰第一张页,实际 RSS 可能远小于 1 GiB。若系统启用了严格 overcommit,映射操作本身也可能因资源承诺不足而失败。

6.3 /proc/<pid>/smaps:按 VMA 分解内存

cat /proc/$pid/smaps

它在每个映射后面提供更详细的统计,例如:

7f2c1a000000-7f2c1a021000 rw-p ... 
Size:                132 kB
KernelPageSize:       4 kB
MMUPageSize:          4 kB
Rss:                 128 kB
Pss:                 128 kB
Private_Clean:         0 kB
Private_Dirty:       128 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Swap:                  0 kB
Anonymous:           128 kB
VmFlags: rd wr mr mw me ac

常见字段:

  • Size:VMA 虚拟地址范围大小;
  • Rss:当前驻留页大小;
  • Pss:按共享程度分摊后的驻留集;
  • Private_CleanPrivate_Dirty:只归属于该进程的干净页和脏页;
  • Shared_CleanShared_Dirty:与其他映射共享的干净页和脏页;
  • Swap:已换出部分;
  • Anonymous:匿名内存;
  • AnonHugePages:透明大页等匿名大页统计;
  • VmFlags:映射的内核标志。

PSS 的基本直觉是:若一个物理页被 nn 个进程共享,则每个进程对该页贡献约:

PSS contribution=1n×page size\text{PSS contribution} = \frac{1}{n} \times \text{page size}

例如:

  • 私有页 100 页;
  • 与另一个进程共享 40 页;
  • 页大小为 4 KiB。

则该进程的 PSS 约为:

100×4 KiB+40×4 KiB2=480 KiB100 \times 4\text{ KiB} + 40 \times \frac{4\text{ KiB}}{2} = 480\text{ KiB}

这比直接加 RSS 更适合估算多个进程共同使用的共享库成本。

读取 smaps 可能比读取 maps 成本高,因为内核需要统计大量页面。高频轮询所有进程的 smaps 会增加系统开销,生产环境中应限制频率和目标范围。

6.4 pagemap:页表级信息受限制

/proc/<pid>/pagemap 允许按虚拟页查询更底层的页表信息,例如页是否存在、是否被写入、是否属于交换等。它常用于内存取证和页级分析。

但 PFN(物理页帧号)等敏感信息受到权限限制,现代内核通常要求更高权限,例如相关 capability。不同内核版本的可见字段和限制也可能变化。不能把无权限读取到的 pagemap 内容当作完整物理地址信息。


七、线程观测:进程级资源与线程级状态

7.1 /proc/<pid>/task

先运行一个带线程的程序。下面的 C 示例会:

  • 打开并写入 /tmp/procfs-demo.data
  • 通过 dup() 创建第二个 fd,展示共享文件偏移;
  • 创建一个 pthread;
  • 分配并触碰一页匿名内存;
  • 输出 TGID、TID 和 fd;
  • 保持运行 300 秒,便于观察。
#define _GNU_SOURCE
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>

static void *worker(void *arg)
{
    (void)arg;
    printf("worker: tgid=%ld tid=%ld\n",
           (long)getpid(), (long)syscall(SYS_gettid));
    fflush(stdout);
    sleep(300);
    return NULL;
}

int main(void)
{
    int fd1 = open("/tmp/procfs-demo.data",
                   O_RDWR | O_CREAT | O_TRUNC, 0600);
    if (fd1 == -1) {
        perror("open");
        return 1;
    }

    if (write(fd1, "abcdef\n", 7) != 7) {
        perror("write");
        return 1;
    }

    if (lseek(fd1, 0, SEEK_SET) == (off_t)-1) {
        perror("lseek");
        return 1;
    }

    int fd2 = dup(fd1);
    if (fd2 == -1) {
        perror("dup");
        return 1;
    }

    char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) {
        perror("mmap");
        return 1;
    }
    p[0] = 'X';  /* 触碰页面,使其更可能出现为驻留匿名页 */

    printf("main:   tgid=%ld tid=%ld fd1=%d fd2=%d map=%p\n",
           (long)getpid(), (long)syscall(SYS_gettid),
           fd1, fd2, (void *)p);
    fflush(stdout);

    pthread_t th;
    int rc = pthread_create(&th, NULL, worker, NULL);
    if (rc != 0) {
        fprintf(stderr, "pthread_create: %s\n", strerror(rc));
        return 1;
    }

    sleep(300);
    pthread_join(th, NULL);
    munmap(p, 4096);
    close(fd2);
    close(fd1);
    return 0;
}

编译和运行:

cc -O2 -Wall -Wextra -pthread procfs-demo.c -o procfs-demo
./procfs-demo

可能输出:

main:   tgid=4200 tid=4200 fd1=3 fd2=4 map=0x7f2c1a000000
worker: tgid=4200 tid=4201

另开终端观察:

pid=4200

ls -1 /proc/$pid/task
cat /proc/$pid/task/4201/status
cat /proc/$pid/task/4201/comm
cat /proc/$pid/task/4201/syscall

/proc/<tgid>/task/<tid>/status 中的 Pid 会是线程 TID,而 Tgid 会是共同的进程 TGID。

7.2 哪些资源是线程独立的

线程共享:

  • 地址空间;
  • 全局变量和堆;
  • 大多数文件描述符;
  • 信号处理 disposition;
  • 当前工作目录、根目录等进程级属性。

线程独立:

  • TID;
  • 栈;
  • 寄存器和程序计数器;
  • 调度状态和调度统计;
  • 线程局部存储;
  • pending signal 的线程部分;
  • comm
  • 某些调度、亲和性和 namespace 相关属性。

所以:

cat /proc/$pid/status

看到的是进程聚合视图,而:

cat /proc/$pid/task/$tid/status

用于观察具体线程。

7.3 线程状态和卡在哪里

批量查看线程状态:

for d in /proc/$pid/task/*; do
    tid=${d##*/}
    printf '%s ' "$tid"
    awk '/^(Name|State|voluntary_ctxt_switches|nonvoluntary_ctxt_switches):/ {
        printf "%s=%s ", $1, substr($0, index($0, $2))
    }' "$d/status"
    echo
done

当线程处于睡眠状态时,可以查看:

cat /proc/$pid/task/$tid/wchan
cat /proc/$pid/task/$tid/stack
  • wchan 通常给出线程当前睡眠等待的内核函数符号;
  • stack 给出内核栈,访问可能受权限限制;
  • 符号名受内核配置、符号导出和地址隐藏策略影响,不能保证始终可读。

wchan 只能说明线程在某个内核等待点附近,不能直接说明根因。例如多个线程都显示在 futex 相关函数,可能是正常锁竞争,也可能是某个线程持锁后卡在 I/O。

7.4 /proc/<pid>/task/<tid>/syscall

cat /proc/$pid/task/$tid/syscall

在支持的架构和权限条件下,它可以显示线程当前正在执行的系统调用及寄存器参数。线程不一定正处于系统调用中;如果它在用户态运行,输出可能是特殊值或不能提供有用信息。

它适合做瞬时诊断,不是系统调用审计工具。需要完整调用序列、返回值和耗时时,应使用 strace、审计、eBPF 等更合适的工具。


八、文件描述符与内存映射的可运行观察

继续使用前面的示例程序:

pid=4200

8.1 查看 fd 及其共享偏移

ls -l /proc/$pid/fd
cat /proc/$pid/fdinfo/3
cat /proc/$pid/fdinfo/4

预期会看到 fd 3 和 fd 4 都指向同一个文件:

3 -> /tmp/procfs-demo.data
4 -> /tmp/procfs-demo.data

两份 fdinfo 中的 pos 通常相同,因为 fd2 = dup(fd1) 共享同一个 open file description。若程序随后通过 fd 3 读取 1 字节,fd 4 的 pos 也会随之改变。

这不是因为两个编号相同,而是因为两个 fd 表项指向同一个内核打开文件对象。

8.2 查看匿名映射

grep -E 'heap|stack|procfs-demo|rw-p' /proc/$pid/maps
grep -A25 -B1 'Anonymous|procfs-demo' /proc/$pid/smaps

映射的匿名页可能出现在没有路径的 rw-p 区域中。示例中写入 p[0] 后,内核更可能为相应虚拟页建立物理页;但具体 RSS 统计可能受到内核延迟统计、透明大页和回收策略影响,不能把一次观察结果当成严格的同步事件证明。

8.3 找出“已删除但仍打开”的文件

find /proc/$pid/fd -lname '* (deleted)' -printf '%f -> %l\n'

如果看到:

7 -> /var/log/app.log (deleted)

说明进程仍持有该文件的打开引用。恢复磁盘空间通常需要让对应 fd 关闭,或者让持有它的进程退出。直接删除目录项不能回收仍被打开的 inode 数据块。


九、权限、隐藏策略和安全边界

9.1 进程所有权不是唯一限制

访问 procfs 可能受以下因素共同影响:

  • 目标进程和观察者的 UID/GID;
  • ptrace 访问检查;
  • hidepid 挂载选项;
  • Yama 的 ptrace_scope
  • Linux capability;
  • PID、user、mount 等 namespace;
  • /proc 中敏感文件的内核版本策略。

例如:

mount | grep ' on /proc '

可能看到:

proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)

某些系统使用:

hidepid=1
hidepid=2

其效果大致是限制其他用户查看进程目录或完全隐藏不属于自己的进程。生产系统不应为了方便诊断而随意修改全局 /proc 挂载选项,因为这会改变整台机器上的进程可见性。

9.2 /proc/<pid>/fd 不是无条件的文件访问绕过

即使能看到:

/proc/4200/fd/3 -> /secret/data

也不代表当前用户一定能成功:

cat /proc/4200/fd/3

/proc/<pid>/fd 的读取和跟随符号链接会进行额外权限检查,通常需要满足进程访问和目标文件访问条件。对另一个用户的进程,readlink()open()ptrace 检查可能分别失败。

因此,“路径可见”和“内容可读”是两个不同问题。

9.3 生产风险

以下操作可能改变目标进程行为或泄露敏感信息:

  • 读取 memstacksyscall
  • 访问 /proc/<pid>/fd/<n>
  • 读取完整 smaps
  • 对其他进程执行调试或注入;
  • 把命令行参数、环境变量和映射路径发送到日志系统。

命令行参数和环境变量可能包含密码、token、连接串和租户信息。采集系统应进行脱敏,并以最小权限运行。


十、从现象到诊断:一个有边界的排查流程

10.1 CPU 高:先区分进程级和线程级

pid=4200

grep -E '^(State|Threads|voluntary_ctxt_switches|nonvoluntary_ctxt_switches):' \
    /proc/$pid/status

ps -L -p "$pid" -o pid,tid,psr,stat,pcpu,comm

如果只有一个线程持续消耗 CPU,优先观察该 TID 的用户态栈、系统调用和锁情况。如果多个线程共同运行,进程级 CPU 百分比可能超过 100%,这通常只是多核并行的结果,不代表统计错误。

10.2 内存高:不要只看 VmRSS

依次检查:

grep -E '^(VmSize|VmRSS|RssAnon|RssFile|RssShmem|VmSwap):' \
    /proc/$pid/status

grep -E '^(Pss|Private_Clean|Private_Dirty|Shared_Clean|Shared_Dirty|Swap):' \
    /proc/$pid/smaps_rollup 2>/dev/null

smaps_rollup 在现代内核中提供聚合统计,但不是所有较老发行版都存在。若不存在,再按需分析 smaps

判断方向:

  • RssFile 高:可能是共享库、文件映射或文件缓存相关;
  • RssAnon 高:可能是堆、匿名映射、线程栈或运行时内存;
  • Pss 明显低于 RSS:大量内存与其他进程共享;
  • Swap 高:匿名页有换出,需结合系统整体内存压力判断;
  • VmSize 高而 RSS 低:可能只是大量预留的虚拟地址空间。

10.3 fd 数量增长:区分泄漏类型

ls -U /proc/$pid/fd | wc -l
find /proc/$pid/fd -maxdepth 1 -type l -printf '%f -> %l\n' | sort

可能的现象:

  • fd 数量持续增长:可能是 fd 泄漏;
  • 大量 socket:[...]:可能是连接关闭不及时;
  • 大量 pipe:[...]:可能是管道端未关闭,导致读端长期看不到 EOF;
  • 大量 (deleted):文件日志轮转后仍被进程持有;
  • fd 数量正常但连接异常:还要检查 socket 状态、协议和对端行为。

如果是同一进程中的多线程,所有线程通常共享进程 fd 表,所以不能通过线程目录分别相加 fd 数量。/proc/<pid>/task/<tid>/fd 主要用于线程访问视图,但资源本质上属于共享的进程文件表。

10.4 进程卡住:先看状态,再看等待点

ps -o pid,ppid,stat,wchan:32,cmd -p "$pid"

for d in /proc/$pid/task/*; do
    tid=${d##*/}
    printf 'TID %s: ' "$tid"
    cat "$d/status" | grep '^State:'
    printf 'wchan: '
    cat "$d/wchan" 2>/dev/null
done

解释必须结合状态:

  • S + futex:可能在用户态锁、条件变量或线程池等待;
  • S + ep_poll:通常在等待 epoll 事件;
  • S + do_wait:可能在等待子进程;
  • D + 块设备相关等待:可能是 I/O 路径;
  • T:先确认是否被调试器、作业控制或停止信号暂停。

这些只是证据链中的中间结果。要建立因果关系,还需要查看调用栈、系统调用返回值、资源使用情况和应用日志。


十一、procfs 与 strace 的边界

procfs 更像是“当前状态面板”:

现在是什么状态?
有哪些线程?
有哪些 fd?
有哪些地址映射?
累计执行了多少 CPU 时间?

strace 更像是“系统调用事件流”:

何时调用了 read()?
传入了什么参数?
阻塞了多久?
返回了什么错误?

例如 /proc/<pid>/fd 显示某个 socket 存在,但不能说明它最近是否成功发送数据;strace 可以看到 sendto()recvfrom() 的返回值。反过来,strace 显示线程正在 futex() 中等待,而 /proc/<pid>/task/<tid>/status 可以提供线程状态、上下文切换等当前或累计信息。

两者结合时仍要注意观测开销:

strace -p "$pid" -f -tt -T -e trace=file,network,futex
  • -f:跟踪线程和子进程;
  • -tt:输出更细的时间;
  • -T:显示系统调用耗时;
  • -e trace=...:限制调用类别。

生产环境中附加 strace 可能引入 ptrace 停顿和额外系统调用开销,尤其是高频系统调用进程。先限制调用集合和时间窗口,再验证业务延迟是否发生变化。


十二、常见误解和失败模式

1. “/proc/<pid>/maps 中的区域就是已经占用的内存”

错误。maps 展示 VMA,不代表每个虚拟页都有物理页。需要结合 RssPss、缺页统计和系统内存状态。

2. “RSS 就是这个进程独占的内存”

错误。共享库和共享内存会被多个进程共同计入 RSS。跨进程归因通常应参考 PSS,同时明确是否要把文件缓存、共享页和内核内存纳入成本。

3. “fd 3 和 fd 4 指向同一路径,所以它们共享偏移”

不一定。两个 open() 可能创建两个独立的 open file description;dup()fork() 继承的 fd 才通常共享同一个打开文件对象。应查看程序的 fd 创建方式,而不是只看符号链接目标。

4. “进程显示 S 就是挂死”

错误。可中断睡眠是正常状态,事件循环、锁等待和阻塞 I/O 都可能使用它。需要检查 wchan、系统调用、线程栈和等待对象。

5. “杀掉 Zombie 就能解决僵尸”

错误。Zombie 不再运行,SIGKILL 不会让它执行清理逻辑。应定位其父进程,确认父进程是否正确调用 wait;父进程若无法修复,才考虑让父进程退出并由合适的子收割者接管。

6. “一次 ls /proc/$pid 成功就能保证后续读取成功”

错误。procfs 是动态视图,目标进程可能在任意两个系统调用之间退出或改变资源。工具必须处理 ENOENTEACCESESRCH 等正常竞态结果,不能把它们一律当作工具故障。


十三、把 procfs 视为内核对象的观测接口

理解 procfs 的最短路径不是背目录,而是把每类文件对应回内核对象:

/proc/<pid>/status
    └── 任务身份、生命周期、资源统计和权限视图

/proc/<pid>/stat
    └── 机器可解析的调度与生命周期计数

/proc/<pid>/fd/
    └── 进程 fd 表到 open file description 的引用

/proc/<pid>/fdinfo/
    └── 打开文件对象的偏移、状态和特殊对象信息

/proc/<pid>/maps
    └── 地址空间中的 VMA 列表

/proc/<pid>/smaps
    └── 每个 VMA 的驻留、共享和脏页统计

/proc/<pid>/task/<tid>/
    └── 线程组中每个线程的独立状态

/proc/<pid>/task/<tid>/syscall
    └── 某一瞬间的系统调用和寄存器视图

当观察结果与预期不符时,应先问三个问题:

  1. 这个文件描述的是进程、线程、fd 表、打开文件对象,还是地址空间 VMA?
  2. 这个值是当前瞬时状态、累计计数,还是近似统计?
  3. 读取期间目标对象是否可能退出、复用或发生并发变化?

回答这三个问题后,/proc 中看似零散的数字和路径就能组成一条完整证据链:进程处于什么生命周期状态,哪个线程正在等待,进程持有哪些内核对象,以及虚拟地址空间中哪些区域真正消耗了驻留内存。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。