用 ps、lsof 和 strace 排查 Linux 进程
最后更新:
一个进程还活着,CPU 几乎没动,也没有输出。它可能在等另一个进程写数据,也可能连输入文件都没打开。只看 ps 能发现它在睡眠,却不能区分这两种情况。可以先用 ps 找到进程和线程,再用 lsof 确认已打开的文件,最后用 strace 看它正在等哪个系统调用。
先确认进程在运行还是在等
已知 PID 时,可以显式选出需要的列;多线程程序再加 -L 看每个线程:
| |
参数可以拆开读,括号中的英文用于助记:
-p "$pid"(process,进程):只选择变量pid指定的进程;PID 是 Process ID(进程标识符)。-o ...(output,输出):显式指定输出列,多个列名用逗号分隔。-L:显示线程,名称可联系 Linux 的 LWP(Lightweight Process,轻量级进程)理解;这里的tid是线程 ID。pid,ppid:进程 ID 和父进程 ID(Parent Process ID)。stat,wchan:25:进程状态和等待位置,:25把等待位置列的宽度设为 25。etime(elapsed time):进程启动至今经过的时间。args,comm:前者显示带参数的命令行,后者显示命令名称。
STAT 的第一个字符是状态。R 表示正在运行或等 CPU,S 表示可被信号唤醒的睡眠,D 表示不可中断的睡眠,常见于等待 I/O,但不等于磁盘坏了。WCHAN 在内核提供信息且调用者可读时,显示任务睡眠时所在的内核函数。它能帮你判断在等管道、锁还是别的内核事件,函数名会随内核版本而变,也可能只显示 -。单次 ps 是快照;主线程睡眠时,其他线程可能还在忙。
找到已打开的描述符
lsof 查看进程已持有的文件:常规文件、管道和 socket 都在这个范围内。按 PID 缩小范围,再按描述符或协议过滤:
| |
lsof 是 list open files(列出已打开文件),这里的参数是:
-p "$pid"(process):按进程 ID 选择。-n(numeric,数字形式):不把网络地址解析成主机名。-P(port,端口):不把端口号转换成服务名。-nP是-n -P的合写,注意大小写。-a(AND,逻辑与):把 PID 和其余选择条件取交集;否则多个选择条件通常取并集。-d 0,1,2(descriptor,描述符):选择标准输入、标准输出和标准错误;后面的-d cwd则选择工作目录。-iTCP:选择 TCP 网络文件,再与-a -p配合限制到指定进程。
FD 列里的 0r 是以读模式打开的描述符 0,通常是标准输入;cwd 和 txt 分别是工作目录和程序文本,不是数字描述符。
lsof 只显示观察时已经打开的对象。如果 openat() 本身还在等,目标可能根本没有出现在清单里。
日志已删,空间为何还没释放
df 显示文件系统仍然很满,du 却找不到相应的文件时,常见原因是文件名已被删掉,但还有进程持有它。持有它的进程可以继续读写,空间通常要等最后一个引用释放才回收。在相应的 PID 命名空间内用 lsof -nP +L1 可以查到这类已打开但链接数小于 1 的文件。
下面在 Linux 本地文件系统的可写目录运行,用独立 Bash 子进程模拟日志轮转时直接删除旧日志。描述符 3 相当于应用早就打开的日志句柄;实验只创建 1 MiB 文件。
| |
在 Debian 12 的 tmpfs 上,lsof 显示一条 3w 的普通文件记录,大小为 1,048,576 字节,NLINK 为 0,NAME 带有 (deleted)。readlink 也指向刚才的旧路径,而目录中已经找不到这个名字。lsof +L1 依赖文件系统和工具能报告链接数;在宿主机共享目录里如果没有这一行,仍可对照 /proc/<pid>/fd,或换到 Linux 本地文件系统复现。检查服务时,看 PID、FD 和路径是否都对得上,再查服务是否有重新打开日志的机制。再删一次同名文件不会关闭旧描述符;重启也不必作为第一步。
跟踪打开文件与读取数据
下面用命名管道(FIFO)制造两种等待。只在你有权限的 Linux 环境中跟踪自己的进程,在独立的 Bash 终端 A 创建新目录。等 A 停在等待处、目录里的 pid 文件出现后,再让终端 B 进入该目录。
| |
strace 的参数分别是:
-tt(time,时间):为每次调用打印带微秒的时刻。-T:打印每次系统调用从进入到返回的用时,区别于-tt的时间戳。-yy:显示更详细的文件描述符信息,尽量解码关联的路径和 socket 信息。-e trace=openat,read(expression,筛选表达式):只跟踪openat和read;它不是 shell 中set -e的停止条件。-o ./trace.log(output):把跟踪输出写到文件,避免与目标程序的输出混在一起。sh -c '...'(command,命令):让 shell 执行引号中的命令文本;$$是该 shell 的 PID,exec cat用cat替换 shell,保留 PID。
| |
没有写端时,shell 在处理 < ./feed 的 openat() 里等待,描述符 0 还没有指向 FIFO。终端 B 打开写端后,shell 可以完成重定向,并用 exec 在同一个 PID 上运行 cat。这时再查 lsof,描述符 0 指向 feed;如果刚好赶在 shell 完成重定向之前,稍后重查。cat 此时仍可处于 S,等的已经是 read()。写入 hello 后,第一次读取返回 6 字节;写端关闭后,后一次读取返回 0,表示读到了 EOF。终端 A 退出后可删除这个实验目录。
这两步在 Debian 12.15、arm64、strace 6.1 的 Linux 容器中验证过:ps 两次都显示 S,等待位置从 wait_for_partner 变成 anon_pipe_read,lsof 则从 /dev/null 变为实验 FIFO。跟踪中与 FIFO 相关的三行如下,省略了时间戳和实验目录:
| |
跟踪文件里也会有加载动态库时的 openat() 和 read(),所以示例先保留完整日志,再用 grep 找目标路径和描述符。上面的 grep -nE 合写了 -n(line number,显示行号)和 -E(extended regexp,扩展正则表达式);-- 表示后面不再解析选项。
排查已经启动的程序时,可以从 strace -f -tt -T -e trace=openat,read,poll,epoll_wait -p "$pid" 开始,按问题选调用并缩短采集窗口。附加由 ptrace 权限、PID 命名空间和安全策略决定;同一个用户也不一定能附加。跟踪会改变运行时序,输出也可能带有文件名和缓冲区内容。在生产环境只跟踪有权限的目标,避免大范围输出。若进程一直在用户态消耗 CPU,或需要知道某个线程从哪个函数进入等待,应改用 perf 或在可暂停的环境里用 GDB 查看线程调用栈;附加 GDB 会暂停目标以供检查。
-f(follow,跟随):跟踪新建的子进程;与-p配合时也会附加到目标进程的各线程。-p "$pid"(process):附加到已经运行的进程,而不是启动一条新命令。
同一个配置文件,为什么服务说找不到
从项目根目录手动运行成功,交给服务管理器却提示找不到配置时,先确认进程实际传给内核的是哪个路径。下面两次执行使用相同的 ./config.ini,只改变工作目录:
| |
第一次跟踪里,openat(AT_FDCWD, "./config.ini", O_RDONLY) 返回 ENOENT;第二次返回一个非负的文件描述符,cat 打印 mode=test。AT_FDCWD 表示相对路径从进程当前工作目录解析。线上可用 lsof -a -p "$pid" -d cwd 核对目录,再看启动配置中的工作目录或绝对路径。若看到的是 EACCES,检查访问该文件时用的账号、各级目录的搜索权限及安全策略;文件路径正确也可能被拒绝。动态链接器尝试可选路径时同样可能返回 ENOENT,应以实际失败的资源和后续调用为准。
过滤跟踪输出时的两个坑
strace 默认把跟踪写到标准错误,而 cmd1 | cmd2 只把 cmd1 的标准输出接给 cmd2,所以 strace ... | grep ... 过滤到的通常是被跟踪程序自己的输出。上面的例子都用 -o 把跟踪写到独立文件,再对文件做 grep,两路内容就不会混在一起。
第二个坑在退出状态。已知完整路径或字面文本时用 grep -F -- "$pattern" ./trace.log,路径里的 . 和 [ 就不会被当作正则语法。没有匹配时 grep 返回 1,读取错误通常返回 2;把排查写成脚本时,要据此区分“没找到这次调用”和“日志读不了”。如果再接一个 head -n 20,Bash 默认只看管道最后一个命令的状态,grep 的失败会被掩盖;启用 set -o pipefail 可以把它传出来,但 head 提前关闭管道时,上游也可能因 SIGPIPE 返回非零状态。pipefail 是 Bash 等 shell 的选项,用 sh script 运行时不要假设它可用。
grep -F(fixed strings,固定字符串):按字面文本匹配,不解释正则语法。head -n 20(number,数量):只保留前 20 行。set -o pipefail:启用名为pipefail的 shell 选项,让管道中较早命令的失败也能体现在管道退出状态中。
参考:procps ps(1)、lsof(8)、Linux proc_pid_fd(5)、strace(1)、Linux execve(2)、GNU Bash 管道、GNU grep 退出状态。