

一个进程还活着，CPU 几乎没动，也没有输出。它可能在等另一个进程写数据，也可能连输入文件都没打开。只看 `ps` 能发现它在睡眠，却不能区分这两种情况。可以先用 `ps` 找到进程和线程，再用 `lsof` 确认已打开的文件，最后用 `strace` 看它正在等哪个系统调用。

<!--more-->

## 先确认进程在运行还是在等

已知 PID 时，可以显式选出需要的列；多线程程序再加 `-L` 看每个线程：

```bash
ps -p "$pid" -o pid,ppid,stat,wchan:25,etime,args
ps -L -p "$pid" -o pid,tid,stat,wchan:25,comm
```

参数可以拆开读，括号中的英文用于助记：

- `-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 缩小范围，再按描述符或协议过滤：

```bash
lsof -nP -p "$pid"
lsof -a -p "$pid" -d 0,1,2
lsof -nP -a -p "$pid" -iTCP
```

`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 文件。

```bash
(
  set -e
  lab=$(mktemp -d "$PWD/.deleted-log.XXXXXX")
  trap 'rm -rf -- "$lab"' EXIT
  owner=$BASHPID
  exec 3>"$lab/worker.log"
  dd if=/dev/zero bs=1M count=1 status=none >&3
  rm -- "$lab/worker.log"

  test ! -e "$lab/worker.log"
  readlink "/proc/$owner/fd/3"
  lsof -nP -a -p "$owner" +L1

  exec 3>&-
  test ! -L "/proc/$owner/fd/3"
)
```

在 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 进入该目录。

```bash
# 终端 A：打印实验目录，再启动被跟踪的进程。
lab=$(mktemp -d "$PWD/.process-lab.XXXXXX") || exit 1
cd "$lab" || exit 1
printf '%s\n' "$lab"
mkfifo ./feed
strace -tt -T -yy -e trace=openat,read -o ./trace.log \
  sh -c 'printf "%s\n" "$$" > ./pid; exec cat < ./feed'
```

`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。

```bash
# 终端 B：先 cd 到终端 A 打印的目录，再执行。
[[ -p ./feed && -f ./pid ]] || exit 1
pid=$(cat ./pid) || exit 1
ps -p "$pid" -o pid,stat,wchan:25,comm
lsof -a -p "$pid" -d 0

# 让读端打开成功，但暂时不写数据。
exec 3> ./feed
lsof -a -p "$pid" -d 0
ps -p "$pid" -o pid,stat,wchan:25,comm

printf 'hello\n' >&3
exec 3>&-
# 等终端 A 的 strace 返回后再查看日志。
grep -nE -- 'openat.*feed|read\(0<' ./trace.log
```

没有写端时，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 相关的三行如下，省略了时间戳和实验目录：

```text
openat(AT_FDCWD<…>, "./feed", O_RDONLY) = 3<…/feed>
read(0<…/feed>, "hello\n", 131072) = 6
read(0<…/feed>, "", 131072) = 0
```

跟踪文件里也会有加载动态库时的 `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](/posts/linux-perf-ebpf-cpu-tracing/) 或在可暂停的环境里用 [GDB 查看线程调用栈](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Threads.html)；附加 GDB 会暂停目标以供检查。

- `-f`（follow，跟随）：跟踪新建的子进程；与 `-p` 配合时也会附加到目标进程的各线程。
- `-p "$pid"`（process）：附加到已经运行的进程，而不是启动一条新命令。

### 同一个配置文件，为什么服务说找不到

从项目根目录手动运行成功，交给服务管理器却提示找不到配置时，先确认进程实际传给内核的是哪个路径。下面两次执行使用相同的 `./config.ini`，只改变工作目录：

```bash
(
  set -e
  lab=$(mktemp -d "$PWD/.cwd-lab.XXXXXX")
  trap 'rm -rf -- "$lab"' EXIT
  mkdir "$lab/start"
  printf 'mode=test\n' >"$lab/config.ini"

  if (cd "$lab/start" && strace -e trace=openat -o "$lab/failed.log" cat ./config.ini); then
    echo '本应找不到配置' >&2
    exit 1
  fi
  (cd "$lab" && strace -e trace=openat -o "$lab/ok.log" cat ./config.ini)
  grep -F '"./config.ini"' "$lab/failed.log" "$lab/ok.log"
)
```

第一次跟踪里，`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)`](https://man7.org/linux/man-pages/man1/ps.1.html)、[`lsof(8)`](https://man7.org/linux/man-pages/man8/lsof.8.html)、[Linux `proc_pid_fd(5)`](https://man7.org/linux/man-pages/man5/proc_pid_fd.5.html)、[`strace(1)`](https://man7.org/linux/man-pages/man1/strace.1.html)、[Linux `execve(2)`](https://man7.org/linux/man-pages/man2/execve.2.html)、[GNU Bash 管道](https://www.gnu.org/software/bash/manual/html_node/Pipelines.html)、[GNU grep 退出状态](https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html)。

