系统变慢时,我们常会先看 top;命令没反应时,很多人会立刻想到 kill -9。但进程管理真正难的地方并不是记几条命令,而是先回答四个问题:我们看到的是哪一个调度实体,它在等什么,它和终端及父进程是什么关系,发出信号后又如何证明它真的退出了。
这一节会建立一条完整证据链。我们先分清程序、进程和线程,再读懂 PID、PPID、进程状态与 ps 字段;然后把会话、进程组、控制终端和 Bash 作业表连起来;最后用 TERM→有界等待→KILL 的升级链,取代“反复猜 PID”的危险做法。
程序是磁盘上可被装入的指令与数据;进程是某次正在运行的实例。同一个可执行文件可以同时有多个进程,每个进程有自己的 PID、虚拟地址空间、打开的文件描述符、当前工作目录、环境与身份凭据。所以“这个程序存在”和“这个进程正在 CPU 上执行”是两件事。
线程是进程内部的执行流。Linux 内核会调度线程,每个线程有 TID;同一进程的线程共享地址空间和多数 I/O 资源,不同进程则通常隔离得更彻底。多线程进程可在多个 CPU 上并行,因此某些工具是按进程汇总还是展开到线程,会直接改变你对资源占用的理解。
单核 CPU 上,多个可运行任务通过时间片交替执行;多核 CPU 上,多个调度实体确实可以同时执行。上下文切换会保存被换出任务的 CPU 现场,再恢复下一个任务的现场。这就是为什么大多数进程长时间处于等待状态,却不影响整个系统同时推进很多工作。

图:程序是可装入的内容,进程是运行实例,线程是内核可调度的执行流。
每个进程在当前 PID 命名空间中有 PID,还有指向直接创建者的 PPID。PID 在“当前活着的进程集合”中唯一,进程退出后数字可被重用。因此 PID 是很好的短期定位符,却不是永久身份证。
当 Bash 执行外部命令时,可以用下面的模型理解:
#说明:$$ 是当前 Bash PID;$! 是最近后台管道的 PID
sleep 20 &
child=$!
ps -o pid,ppid,pgid,sid,stat,cmd -p "$$,$child"输出中 sleep 的 PPID 应指向这个 Bash。实际 PID 每次都会变,这正是不应把示例数字当成固定值的原因。

图:fork 建立父子关系,exec 替换程序映像,wait 收走终止状态。
子进程退出时,内核要保留一小块信息,包括 PID 与终止状态,等父进程通过 wait 族收走。子进程已终止但父进程还没有 wait 的窗口,就是 zombie,ps 中的主状态为 Z,命令列常带 <defunct>。它已经不会继续执行业务代码,重点应转向“为什么父进程没有回收”。
孤儿进程不同。它是父进程先退出、自己仍在运行的子进程。Linux 会将其重新挂到当前 PID 命名空间的 init 进程,或更近的 child subreaper。这些回收者需要继续 wait 被收养后退出的子进程。
PID 1 还有命名空间边界。每个 PID 命名空间都可以有自己的 PID 1;容器内看到的 PID 1 不等于外层命名空间的 PID 1。如果让一个不会转发信号、也不会回收被收养子进程的简单脚本担任容器 PID 1,长期运行时可能积累 zombie。一个小型 init 或正确的进程监督器的价值,就在于转发信号和履行回收责任,不在于“容器只允许一个进程”。
不要对 zombie 反复发 KILL。它已终止,需要的是父进程 wait。如果 zombie 持续增长,应修复父进程的回收逻辑,或使用能正确担任 PID 1 的监督进程。
ps 的 STAT 首字符是采样时刻的主状态。一个正常进程可在很短时间内往返切换,所以不能把单次快照当成永久标签。
STAT 可有后缀,例如 s 表示会话领导者,l 表示多线程,+ 表示属于控制终端的前台进程组,N 常表示较高 nice 值。所以 Ss、Sl+ 不是多个互斥主状态,而是“主状态+附加性质”。

图:状态是瞬时证据;真正的诊断来自状态、时间和等待对象的交叉验证。
procps-ng 的 ps 接受三种选项风格:Unix 风格用单短横线,BSD 风格通常不带短横线,GNU 长选项带两个短横线。它们可以混用,却可出现语义冲突;特别是 BSD 选项会改变默认进程选择集和默认列。
我更建议用明确字段的 -o,因为它让读者看出我们正在回答什么问题:
ps -o pid,ppid,pgid,sid,stat,tty,time,etimes,pcpu,pmem,vsz,rss,args -p PID
图:先问每个字段回答什么,再组合成一个可验证的结论。
高效调查的关键是分工:
pgrep 负责筛选。pgrep -a name 显示 PID 与命令行,-u、-P、-g、-t 可继续按用户、父进程、进程组和终端缩小范围。默认名称匹配受进程 comm 长度限制;-f 改用完整命令行,同时也容易宽匹配。pstree 负责关系。pstree -ap PID 展开父子结构和参数,适合回答“谁创建了谁”。它不能代替资源趋势。top 负责重复采样。交互模式适合持续看变化;top -b -n 1 -p PID 适合生成一次可归档快照。排序靠前只说明当前采样窗口活跃,不是根因证明。/proc/PID 负责原始证据。status 人类易读,stat 字段紧凑,cmdline 记录参数,fd/ 是打开描述符,cwd 指向工作目录。许多信息受进程身份、/proc 挂载选项和命名空间可见性限制。一条实用证据链可以是:
pgrep -a -u "$USER" -f 'worker-pattern'
ps -o pid,ppid,pgid,sid,stat,tty,time,etimes,pcpu,pmem,vsz,rss,args -p PID
pstree -ap PID
grep -E '^(Name|State|Pid|PPid|Uid|Gid|Threads):' /proc/PID/status
top -b -n 2 -d 1 -p PID第一步只生成候选集;第二步对齐身份、关系、状态和资源;第三步查衍生关系;第四步对齐内核导出的原始状态;第五步才开始看趋势。

图:筛选、快照、关系、原始状态和趋势是五类不同证据。
TIME 是进程累积 CPU 时间,top 的 %CPU 是采样窗口中的活跃程度,ps 的 %CPU 又常是进程生命期内 CPU 时间比例。这些都和负载平均值不是同一个指标。
Linux /proc/loadavg 的前三个数是 1、5、15 分钟窗口的平滑负载,包括运行队列中的 R 任务和不可中断等待的 D 任务。第四个字段形如 3/412,分子是当前可运行调度实体数,分母是当前调度实体总数;第五个数是最近创建进程的 PID。
cat /proc/loadavg
getconf _NPROCESSORS_ONLN
uptime负载 4 对 4 个逻辑 CPU 和对 64 个逻辑 CPU 的意义不同。而且即使 CPU 还有空闲,持续 D 状态也可以推高负载。所以我们应依次看:核数是多少,1/5/15 分钟趋势向上还是向下,R 和 D 各有多少,CPU 的 user/system/iowait 是什么样,服务响应和队列是否真的变差。

图:负载是任务压力的时间窗口,CPU 百分比是处理器时间的分类。
一个会话(SID)可包含多个进程组(PGID),一个进程组可包含一个命令的进程,也可包含一整条管道的多个进程。Shell 把同一作业的进程放入同一 PGID,才能用一次终端信号暂停或中断整条管道,而不是只操作某一级。
会话可有一个控制终端,该终端同一时刻最多有一个前台进程组。只有前台进程组可正常从控制终端读取;后台进程组尝试读时,通常会收到 SIGTTIN 而停止。终端中的 Ctrl-C、Ctrl-Z 也是面向前台进程组,不是面向“窗口中任意一个 PID”。
ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -p "$$"PGID 是当前进程组,SID 是会话,TPGID 是该终端当前的前台进程组。这三个数能把“为什么按键会影响整个管道”说清楚。

图:会话管理多个作业进程组,控制终端只把按键信号交给当前前台组。
&、jobs、Ctrl-Z、bg、fg 和 Ctrl-Ccommand & 创建后台作业,Shell 不等它结束就返回提示符。Bash 常输出形如 [1] 208:1 是当前 Shell 作业表里的作业号,208 是相关 PID。作业号只对这个 Shell 有意义,新终端不能拿 %1 继续操作另一个 Shell 的作业表。
jobs -l 显示作业号、PID 和 Running/Stopped 等状态。Ctrl-Z 通常向前台组发 SIGTSTP,作业变成 Stopped;bg %1 发 CONT 并在后台继续;fg %1 把它放回前台;Ctrl-C 通常发 SIGINT,默认动作是终止。
sleep 60
#操作:按 Ctrl-Z
jobs -l
bg %1
jobs -l
fg %1
#操作:按 Ctrl-C
printf 'status=%s\n' "$?"在真正的 PTY 交互实测中,这条轨迹是 Stopped→Running→前台,最后 Ctrl-C 后 Bash 得到 130,也就是常见约定中的 128+SIGINT(2)。这里要牢记:Ctrl-Z 是停下、不是结束;误按后用 jobs 找出它,不要把一堆 T 状态作业留在会话中。
& 只是让 Shell 不同步等待,并不会自动保存子进程成败状态。Bash 内建 wait PID 等待特定子进程并返回其状态;无参 wait 等待所有尚未收集的后台作业;wait -n 等待任意一个先完成,-p var 可把完成者标识写入变量。
sleep 0.4 & slow=$!
bash -c 'sleep 0.1; exit 7' & fast=$!
set +e
wait -n -p finished "$slow" "$fast"
first_status=$?
set -e
printf 'finished=%s status=%s\n' "$finished" "$first_status"
if [[ $finished == "$slow"
隔离实测中,返回 7 的任务先完成,wait -n -p 得到它的 PID 和状态 7,另一个任务后来返回 0。示例特意短暂关闭 set -e,因为“子任务返回非零”是我们要收集的业务事实,不应让外层脚本在记录之前突然退出。
常见退出状态约定是 0 表示成功,1–125 由程序定义失败,因信号终止常表达为 128+信号号,例如 SIGINT 常对应 130,SIGKILL 常对应 137。它是 Shell 层的约定表达,写自动化时应记录原始状态并了解具体命令的定义。
信号由内核交付给进程或线程。它可在任意时刻到达,会打断正常控制流;处理器运行时还有可重入性、异步信号安全等限制。所以它不能类比为“给目标同步调用一个普通函数”。
对多数标准信号,进程可选择默认动作、忽略或安装 handler。SIGKILL 和 SIGSTOP 是关键例外:不能被捕获、阻塞或忽略。这正是 KILL 能强制结束、但会跳过用户态清理的原因。
kill 的目标可是单进程或进程组:
kill -TERM 4242 # 向 PID 4242 发 TERM
kill -TERM -- -5151 # 向 PGID 5151 的所有成员发 TERM
kill -CONT 0 # 向调用者当前进程组发 CONT
kill -0 4242 # 不真正发信号,只检查存在性与权限负数 PGID 之前的 -- 很重要,它结束选项解析,避免负数被误认为选项。处理一条管道、启动了多个工作子进程的命令或独立作业时,只向领导者 PID 发信号可能留下其他成员;应核对 PGID 或使用管理该作业的服务边界。
发信号也受权限检查。在 Linux 中,调用者需要有合适能力,或让发送者的真实/有效 UID 与目标的真实/保存 UID 满足规则;SIGCONT 还有同会话特例。权限不足会得到 EPERM,目标不存在或已不可见常是 ESRCH。kill -0 仍可因权限失败,因此“没权看”不能粗暴等同于“一定没有”。

图:先选对信号,再选对目标范围,最后通过权限与退出证据闭环。
一条可审计的安全终止链是:
GNU timeout 已把这条链封装成常用形式:
#说明:30 秒后发 TERM,再等 5 秒仍未退出才发 KILL
timeout --signal=TERM --kill-after=5s 30s command args...pkill 和 killall 可按名称批量发信号,也因此风险更高。pkill -f pattern 会对完整命令行做匹配,宽泛模式可命中无关进程;killall name 默认对所有同名命令发 TERM,还有长名截断、进程改名和等待期间 PID 重用等边界。先用 pgrep -a 预览完整候选集,加用户、父进程、PGID 和精确匹配约束,再执行批量信号。
&、disown、nohup 和 SIGHUP 的层次command & 只解决“当前 Shell 是否等待”,作业仍在 Bash 作业表里,也可继续使用终端文件描述符。关闭终端时,会话挂断、Shell 退出策略与 Bash 的 huponexit 设置都可影响 SIGHUP 是否转发给作业。
disown %1 从当前 Bash 作业表移除作业;disown -h %1 保留作业表项,但标记 Bash 在自己收到 HUP 时不向它转发 HUP。它解决的是 Bash 作业管理,并不自动重定向作业的标准输入、输出和标准错误。
GNU nohup command 让目标忽略 HUP,并在标准流还是终端时调整 I/O。它不自动把命令放到后台,也不改 nice 值;因此常见完整形式是:
nohup command args... </dev/null >run.log 2>&1 &
pid=$!
printf '%s\n' "$pid" >run.pid这个形式同时说清了四件事:忽略 HUP、不再从终端读、将输出与错误落盘、异步启动。但手写 PID 文件仍有竞态与 PID 重用边界,长期服务更适合交给能可靠追踪进程和日志的监督器。
nice 和 renice 只是 CPU 竞争权重nice 值通常从 -20 到 19。数值越大,进程在普通调度竞争中越“礼让”;数值越小,调度紧迫程度越高。nice -n 7 command 用调整后的 niceness 启动新命令,renice --priority 10 -p PID 改变已运行进程。
nice -n 7 sleep 60 &
pid=$!
ps -o pid,ni,pri,stat,cmd -p "$pid"
renice --priority 10 -p "$pid"
ps -o pid,ni,pri,stat,cmd -p "$pid"
kill -TERM "$pid"
wait "$pid" || true隔离实测中,第一次快照是 NI=7,renice 报告 old priority 7/new priority 10,第二次快照是 NI=10。这验证了权重变化,却没有设置“最多只用 10% CPU”之类配额。nice 是调度器的建议,不是硬 CPU 限额;它也不能直接解决内存或 I/O 资源压力。
普通用户只能修改自己的进程,且通常只能把 nice 值调大,也就是降低自己的调度紧迫程度。要把 nice 值调小需要合适权限或资源限制。无论有没有权限,都应先证明真正的竞争是 CPU,再决定是否改权重。
下面的实操都在 debian:bookworm-slim 一次性容器中进行,实验目录是 /tmp/welearn-ch10,容器名是 welearn-linux-ch10-lab。--rm 使容器退出后自动删除;仍要在退出前结束实验进程和删除目录,因为“明确清理”本身就是进程管理的一部分。
先进入环境:
docker run --rm -it \
--name welearn-linux-ch10-lab \
debian:bookworm-slim \
bash在容器中准备工具与目录:
set -u
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends procps psmisc
mkdir -p /tmp/welearn-ch10
cd /tmp/welearn-ch10bash -c 'exec -a ch10-alpha sleep 30' & alpha=$!
bash -c 'exec -a ch10-beta sleep 30' & beta=$!
ps -o pid,ppid,stat,tty,time,etimes,pcpu,pmem,vsz,rss,args -p "$alpha,$beta"
pgrep -a -P "$$" sleep
pstree -ap "$$"
top -b -n 1 -p "$alpha,$beta
实测中,ps 将两个子进程列为 S,PPID 指向当前 Bash;pgrep -P 只列直接子进程;pstree 展示 Bash→两个 sleep 的树;top 的单次批处理快照显示两者都处于睡眠,CPU 接近 0。同一个对象在四种视图中对齐,证明筛选、关系、字段和趋势工具可组成一条证据链。
这一组操作必须在交互终端中做,因为非交互 Bash 默认不启用同样的作业控制。容器是用 -it 启动的,所以可直接输入:
sleep 20
#操作:按 Ctrl-Z
jobs -l
bg %1
jobs -l
fg %1
#操作:按 Ctrl-C
printf 'foreground_status=%s\n' "$?"预期轨迹:
[1]+ Stopped sleep 20
[1]+ ... Running sleep 20 &
foreground_status=130理由是:Ctrl-Z 对前台进程组发 TSTP,bg 发 CONT 但不改为前台,fg 把进程组重新设成终端前台,Ctrl-C 才发 INT。如果 jobs 说没有当前作业,先确认当前 Shell 是交互 Bash,而不是在无 PTY 的脚本中照抄这组按键。
先创建一个会清理 marker 的脚本:
cat >graceful.sh <<'SCRIPT'
#!/usr/bin/env bash
marker=/tmp/welearn-ch10/graceful.marker
printf 'active\n' >"$marker"
on_term() {
printf 'handler=TERM cleanup=marker_removed\n'
rm -f "$marker"
exit 0
}
trap on_term TERM
printf 'ready pid=%s\n' "$$"
while :; do sleep 0.1; done
SCRIPT
chmod +x graceful.sh
./graceful.sh >graceful.log 2>&1 & pid=$!
sleep 0.2
kill -TERM "$pid"
wait
预期看到 handler 执行、wait 返回 0 且 marker 不存在。这是 TERM 比 KILL 更适合首次尝试的实证:目标有机会把状态收拢。
再验证 STOP/CONT:
sleep 30 & pid=$!
kill -STOP "$pid"
ps -o pid,stat,cmd -p "$pid" # 预期主状态 T
kill -CONT "$pid"
ps -o pid,stat,cmd -p "$pid" # sleep 继续等待,常见 S
kill -TERM "$pid"
wait "$pid" || true最后用一个忽略 TERM 的进程演示有界升级:
perl -e '$SIG{TERM}="IGNORE"; sleep 30' & pid=$!
kill -TERM "$pid"
for _ in 1 2 3 4 5; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
这个实验让 Perl 父进程 fork 一个立即以 42 退出的子进程,父进程等待 2 秒再 waitpid,因而留出稳定观测窗口:
cat >zombie.pl <<'SCRIPT'
use strict;
use warnings;
my $pid = fork();
die "fork failed: $!" unless defined $pid;
if ($pid == 0) { exit 42; }
open my $fh, '>', '/tmp/welearn-ch10/zombie.pid' or die $!;
print {$fh} "$pid\n";
close $fh;
sleep 2;
waitpid($pid, 0);
printf "reaped_pid=%d child_status=%d\n", $pid, $? >> 8;
SCRIPT
perl zombie.pl >zombie.log & parent=$!
while [[ ! -s zombie.pid ]]; do sleep 0.02; done
预期在 2 秒窗口里看到 Z 与 <defunct>,PPID 指向 Perl 父进程;父进程 waitpid 后输出 child_status=42,然后 /proc/CHILD_PID 消失。这个顺序同时证明 zombie 的原因、状态和正确收尾。
全部完成后清理并退出:
jobs -pr | xargs -r kill -TERM
sleep 0.2
jobs -pr | xargs -r kill -KILL
wait 2>/dev/null || true
cd /
rm -rf /tmp/welearn-ch10
test ! -e /tmp/welearn-ch10 && echo cleanup=ok
exit因为容器用 --rm 启动,退出后不应再看到 welearn-linux-ch10-lab。如果容器名已被占用,不要盲目删除,先用 docker ps -a --filter name=welearn-linux-ch10-lab 确认它是否属于这次实验。
进程管理可以收束成一个简单顺序:先定位对象,再解释状态;先确认是单 PID 还是整个 PGID,再发信号;先给 TERM 清理机会,再在超时且重新确认身份后升级 KILL;最后用 wait、退出状态、子进程与 /proc 复查闭环。这套方法比背一长串命令更稳,因为它把每一次操作都变成了有证据的判断。