0.9 进程、作业控制与信号
ps 的 STAT 每个字母什么意思?前台、后台、进程组、会话、控制终端之间怎么组织?kill 默认发哪个信号,为什么 kill -9 不是万能药?僵尸进程占不占内存,孤儿又去了哪里?
从 ps -eo 的一行里读出父子、进程组、会话与状态;用作业控制把长任务放到后台、拉回前台、在断线后保住它;按「TERM → 等待 → KILL」的顺序安全停掉一棵进程树,并解释每一步的证据。
0.8 的脚本会启动子进程,也会被信号打断。脚本之外,系统里的每个进程都挂在一棵树上,还属于某个进程组和会话。你按 Ctrl-C 时,信号并不是发给「那个程序」,而是发给前台进程组;kill 也不是「杀死」,只是把一个异步通知放进目标进程的待处理队列。
这三个概念——进程、进程组/会话、信号——是日常排障里最容易被跳过的一层。跳过它们的代价是:kill -9 之后端口仍被占用、nohup 之后进程还是断了、ps 显示 D 却怎么都杀不掉。这些现象都不是「Linux 有 bug」,而是没看清进程的组织方式。
这一章只讲用户可见的部分:怎么读状态、怎么组织作业、怎么发信号、怎么回收子进程。信号在程序内部怎么注册处理器、怎么处理 EINTR、怎么用 signalfd,留给 2.10 信号;本章的例子全部可以用 shell 和现成命令复现。
0.9.1 先学会读 ps 的五列
ps 有两套语法:BSD 风格(ps aux,选项不带横线)和 UNIX 风格(ps -ef,选项带横线)。两者输出的列不同,混着记很容易漏掉进程组和会话。排障时用 -o 显式指定列,比背两套默认格式可靠:
$ ps -eo pid,ppid,pgid,sid,stat,etime,comm --sort=pid | head -8 PID PPID PGID SID STAT ELAPSED COMMAND 1 0 1 1 Ss 12-03:41:07 systemd 398 1 398 398 Ss 12-03:41:02 systemd-journal 612 1 612 612 Ssl 12-03:40:55 cron 1024 1 1024 1024 Ss 12-03:40:48 sshd 2048 1024 2048 2048 Ss 01:12:33 bash 2101 2048 2101 2101 R+ 00:00:00 ps 2102 2048 2101 2101 S+ 00:00:00 head $ ps -o pid,ppid,pgid,sid,tty,tpgid,stat,comm -p $$ PID PPID PGID SID TT TPGID STAT COMMAND 2048 1024 2048 2048 pts/0 2048 Ss bash $ ps -eLf | head -3 UID PID PPID LWP C NLWP STIME TTY TIME CMD root 1 0 1 0 1 10月14 ? 00:02:11 /sbin/init root 398 1 398 0 3 10月14 ? 00:01:02 /usr/lib/systemd/systemd-journald
第一段输出里,ps 和 head 的 PGID 都是 2101,SID 都是 2048,STAT 带 +。这说明它们是同一个管道、同一个进程组,并且这个进程组是当前终端的前台组。bash 的 PGID 和 SID 都等于自己的 PID,STAT 里的 s 表示它是会话首领(session leader)。
第二段把「我是谁」拆开:$$ 是 shell 的 PID,TPGID 是终端当前前台进程组的 ID。TT 是控制终端,pts/0 表示伪终端。第三段的 NLWP 是线程数,LWP 是线程 ID;systemd-journald 有 3 个线程,ps -eLf 会把每个线程单独列一行。
STAT 的第一个字母是主状态,后面的符号是修饰:
| 字母 | 含义 | 典型场景 | 能不能被信号唤醒 |
|---|---|---|---|
R | running / runnable | 正在跑或在运行队列 | 不适用 |
S | interruptible sleep | 等事件、等输入、sleep | 能 |
D | uninterruptible sleep | 等块设备 I/O、NFS、驱动 | 不能(要等 I/O 返回) |
T | stopped | SIGSTOP、Ctrl-Z、调试器 | 要 SIGCONT |
t | tracing stop | ptrace 暂停 | 调试器控制 |
Z | zombie | 已退出,等父进程 wait | 不能 |
I | idle kernel thread | 内核线程空闲 | 不适用 |
修饰符:< 高优先级,N 低优先级,L 有页锁定内存,s 会话首领,l 多线程,+ 在前台进程组。Ss 就是「可中断睡眠 + 会话首领」,R+ 是「运行中 + 前台」,D< 是「不可中断睡眠 + 高优先级」。读 ps 时先看第一个字母,再看 +,最后看 PPID,顺序不要反。
ps aux 的 %CPU 是进程生命周期内的平均 CPU 占用,不是当前瞬时值。一个刚启动时跑满、之后一直睡着的进程,%CPU 可能仍显示 90%。看瞬时值用 top、pidstat 1 或 mpstat(0.14 展开)。按名字找进程用 pgrep -a nginx,它输出 PID 和完整命令行;pidof nginx 只给 PID,适合脚本。
0.9.2 进程组、会话与控制终端
进程组(process group)是一组可以被同一个信号一起处理的进程,组 ID 等于组长进程的 PID。会话(session)是一组进程组的集合,会话 ID 等于会话首领的 PID;一个会话最多有一个控制终端(controlling terminal),终端同一时刻只有一个前台进程组(foreground process group),其余是后台组。
终端驱动把键盘产生的 SIGINT、SIGQUIT、SIGTSTP 发给前台进程组,而不是发给某一个 PID。这就是 Ctrl-C 能同时打断管道两端的原因。
session 2048(SID=2048)── 控制终端 /dev/pts/0 ├── 前台进程组 2101(TPGID=2101)── ps、head ├── 后台进程组 2210 ────────────── sleep 300 └── 后台进程组 2214 ────────────── rsync
这张图的读法是:信号按组投递,终端只认前台组。后台组里的进程去读终端,终端驱动会发 SIGTTIN 把它停住;去写终端,在 stty tostop 打开时发 SIGTTOU。这不是 shell 的策略,是终端驱动和内核的约定。也解释了为什么 nohup 之后进程仍可能被 SIGTTIN 停掉:它还在同一个会话里,还挂着同一个控制终端。
每个进程都同时属于「一个 PID、一个进程组、一个会话」:
| 标识 | 取自哪里 | 谁赋值 | 用途 |
|---|---|---|---|
PID | 进程 | 内核分配 | 精确操作单个进程 |
PPID | 父进程 | 创建时继承 | 找谁负责回收、谁启动的 |
PGID | 进程组 | setpgid,shell 为每个作业建组 | 一次向整组发信号 |
SID | 会话 | setsid | 终端、守护进程的边界 |
TPGID | 终端 | 终端驱动 / shell | 判断谁在前台 |
交互式 shell 打开作业控制(set -m)后,每启动一个作业就给它建一个新进程组,并把前台组切换过去。非交互脚本默认没有作业控制(不切前台进程组、不处理 SIGTTIN/SIGTTOU),但 shell 的作业表仍然存在:jobs 会列出后台作业,kill %1 也能解析作业号。& 只是不等待,不是「放到另一个组」;要真正脱离终端才用 setsid/nohup。
$$ 和 $BASHPID 不一样:$$ 在子 shell 里仍然是父 shell 的 PID,$BASHPID 才是当前 shell 的 PID。下面的输出能看出差别:
printf 'outer: $$=%s BASHPID=%s\n' "$$" "$BASHPID"
( printf 'sub: $$=%s BASHPID=%s\n' "$$" "$BASHPID" )
子 shell 里 $$ 不变、$BASHPID 变。写锁文件、临时文件名时用 $$ 会造成「所有子 shell 用同一个名字」;用 $BASHPID 或 mktemp 才安全。需要让一个进程彻底脱离当前会话,用 setsid command:它新建会话、成为会话首领,没有控制终端,终端关闭也收不到 SIGHUP。
0.9.3 状态字母背后的等待队列
读图:TASK_RUNNING 同时包含「正在跑」和「在运行队列里等 CPU」,所以 ps 的 R 不一定是「正在占用 CPU」。S 和 D 的区别是「能不能被信号唤醒」:S 可以,D 不行。Z 已经不再运行,只留下一个 task_struct 等父进程读取退出码。图中所有箭头都由内核调度器和等待队列推动,用户态只能通过信号、wait、I/O 完成这些事件间接影响。
D 是最容易被误判的状态。进程在等一次不能被打断的 I/O(块设备、NFS、某些驱动),此时 kill -9 只是把信号挂起,进程要等 I/O 返回才死。如果 NFS 服务端失联,D 状态可以持续很久,ps 里一堆 D,负载平均值也跟着涨——Linux 的 load average 统计的是「运行队列 + 不可中断睡眠」,不只是 CPU 队列。
Z 是僵尸。子进程退出后,内核保留它的 PID、退出码和统计信息,等父进程 wait/waitpid 取走。父进程不取,它就一直是 Z。僵尸不占内存页、不占 CPU,只占一个 PID 和少量内核结构;但它不能被 kill 掉,因为它已经死了。处理办法只有两个:让父进程 wait,或者杀掉父进程让孤儿被 PID 1 收养并回收。
排查用两条命令就够:
# 找出僵尸和 D 状态;$1 是 STAT,$2 是 PID
ps -eo stat,pid,ppid,comm | awk '$1 ~ /^[ZD]/'
# 看单个进程的内核视角
cat /proc/2048/status | grep -E '^(State|Tgid|Pid|PPid|Threads):'
cat /proc/2048/wchan; echo
/proc/PID/status 的 State 是完整状态名(如 S (sleeping)),wchan 是进程正在等的内核函数。/proc/PID/stack 需要 root 和内核符号,能看出具体卡在哪个调用栈;没有符号时它只给地址,价值有限。Z 进程的 wchan 通常是空的或 do_exit,因为已经没有可执行的上下文。
一台 NFS 客户端 uptime 显示 load average 8.0,top 的 CPU idle 是 95%,ps 里 8 个进程是 D 状态。这不是矛盾:load average 统计「可运行 + 不可中断」的任务数,8 个进程卡在 NFS 读上,每个都算一份负载,但它们既不跑 CPU,也不响应信号。vmstat 1 会看到 b 列(不可中断睡眠)是 8,r 列很小;cat /proc/PID/stack 能看到 nfs_wait_*。修 NFS 或挂载选项,而不是加 CPU。
0.9.4 作业控制:&、jobs、fg、bg、disown
作业控制是 shell、终端驱动、内核三方协作的结果。shell 给每个作业建进程组,终端驱动记录前台组,内核负责按组投递信号。
$ sleep 300 & [1] 2210 $ jobs -l [1]+ 2210 Running sleep 300 & $ sleep 300 ^Z [2]+ 2214 Stopped sleep 300 $ bg %2 [2]+ sleep 300 & $ jobs -l [1]- 2210 Running sleep 300 & [2]+ 2214 Running sleep 300 & $ fg %1 sleep 300 ^C $ jobs [2]+ Running sleep 300 & $ disown -h %2 $ kill %2 [2]+ Terminated sleep 300
& 把命令放到后台并立刻返回作业号和 PID。jobs -l 里的 + 是「当前作业」,- 是「上一个作业」,%1/%2 是作业号,%+/%- 是这两个的快捷写法。Ctrl-Z 发的是 SIGTSTP(terminal stop),不是 SIGSTOP;前者可以被程序捕获或忽略,后者不能。bg 发 SIGCONT 让作业在后台继续,fg 把它切回前台并等待。
disown -h %2 只是把作业标记为「不接收 SIGHUP」,作业仍在作业表里;disown %2 则把它从表里移除,shell 不再管理它。两者都不改变会话和控制终端。真正让进程脱离终端的是 setsid:
$ nohup sleep 300 >/tmp/nohup.out 2>&1 & [3] 2220 $ ps -o pid,ppid,sid,tty,stat,comm -p 2220 PID PPID SID TT STAT COMMAND 2220 2048 2048 pts/0 S sleep $ setsid -f sleep 300 $ pgrep -n -x sleep 2224 $ ps -o pid,ppid,sid,tty,stat,comm -p 2224 PID PPID SID TT STAT COMMAND 2224 1 2224 ? S sleep
nohup 只做两件事:忽略 SIGHUP,把 stdout/stderr 重定向到文件(如果它们连着终端)。它的 SID 和 TT 都没变,仍在原会话里;终端关闭时它不会因 SIGHUP 死,但仍可能因为读终端被 SIGTTIN 停住。setsid 新建会话、脱离控制终端,SID 变成自己的 PID,TT 变成 ?,父进程变成 1。要真正「后台常驻」,setsid 比 nohup 彻底。
| 操作 | 命令 | 发的信号 | 作用范围 |
|---|---|---|---|
| 放后台 | cmd & | 无 | 当前 shell 的作业表 |
| 暂停前台 | Ctrl-Z | SIGTSTP | 前台进程组 |
| 后台继续 | bg %1 | SIGCONT | 指定作业的进程组 |
| 拉回前台 | fg %1 | SIGCONT | 指定作业的进程组 |
| 断线保护 | disown -h %1 | 无 | 仅阻止 shell 发 SIGHUP |
| 脱离会话 | setsid cmd | 无 | 新会话、无控制终端 |
| 交给 systemd | systemd-run --unit=job1 cmd | 无 | 新 unit、独立 cgroup |
脚本里没有作业控制,但可以用 set -m 打开。打开后每个后台任务进入自己的进程组,kill -- -$! 能一次杀一组。关闭时所有子进程和脚本同组,kill 0 会连自己一起杀——这是脚本里最危险的一条命令之一。
0.9.5 信号:先 TERM,再 KILL
信号是内核投递给进程的异步通知,编号 1–64(实时信号另算)。进程可以捕获(signal/sigaction)、阻塞、或忽略大部分信号,但 SIGKILL(9) 和 SIGSTOP(19) 既不能捕获也不能忽略。信号的默认动作有四类:终止、终止并 core dump、停止、忽略。kill 命令只是 kill(2) 的包装,默认发 SIGTERM(15)。
$ kill -l | head -2 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 $ ./slow.sh & [1] 2301 pid=2301 $ kill -TERM %1 TERM at 10:21:03 [1]+ Exit 143 ./slow.sh $ ./slow.sh & [1] 2305 pid=2305 $ kill -9 %1 [1]+ Killed ./slow.sh $ timeout -s TERM -k 2 5 sleep 30; echo "rc=$?" rc=124
slow.sh 的内容如下,它把两个信号翻译成可观察的输出和约定退出码:
#!/usr/bin/env bash
trap 'echo "TERM at $(date +%T)"; exit 143' TERM
trap 'echo "INT at $(date +%T)"; exit 130' INT
echo "pid=$$"
while :; do sleep 1; done
kill -TERM 后脚本打印一行再退出 143(128+15);kill -9 没有任何输出,shell 只报告 Killed。timeout 的退出码 124 表示「超时」,-k 2 表示 TERM 之后再等 2 秒还没死就 KILL。timeout 是脚本里给外部命令加时限的标准工具,比手写 sleep + kill 可靠。
常用信号及默认动作:
| 信号 | 编号 | 默认动作 | 什么时候用 |
|---|---|---|---|
SIGHUP | 1 | 终止 | 终端断线;守护进程常捕获它重载配置 |
SIGINT | 2 | 终止 | Ctrl-C;交互式中断 |
SIGQUIT | 3 | 终止 + core | Ctrl-\;需要 core dump 时 |
SIGTERM | 15 | 终止 | 默认的「请优雅退出」 |
SIGKILL | 9 | 终止 | 最后手段,不可捕获 |
SIGSTOP | 19 | 停止 | 不可捕获的暂停 |
SIGCONT | 18 | 继续 | 恢复被停的进程 |
SIGUSR1/2 | 10/12 | 终止 | 应用自定义(如 nginx 重开日志) |
SIGCHLD | 17 | 忽略 | 子进程状态变化,通知父进程 |
SIGPIPE | 13 | 终止 | 写已关闭的管道;shell 报 141 |
kill 的三种常见形式:
kill 2210 # 发 SIGTERM 给 PID 2210
kill -TERM -- -2210 # 发给进程组 2210(负号前要 --)
kill -0 2210 # 不投递,只检查存在与权限;返回 0/1/EPERM
kill -0 成功不代表进程「健康」,只代表 PID 存在且你有权限给它发信号。PID 会被复用:你记录的 2210 可能已经是另一个程序。要避免误杀,用 starttime(/proc/PID/stat 第 22 字段)或 systemd 的 cgroup 作为身份,不要只凭 PID。
pkill -f 按完整命令行匹配,pgrep -a 先看再杀。pkill -f backup.sh 可能匹配到你的编辑器和 SSH 会话里的同名字符串,把自己踢下线;先 pgrep -af 核对,或者用 pkill -x 精确匹配进程名。killall 按名字匹配所有同名进程,同样要先确认。
停止一棵进程树,正确顺序是「整组 TERM → 有界等待 → 整组 KILL」:
stop_tree() {
local pid=$1 sig=${2:-TERM} i
kill -"$sig" -- "-$pid" 2>/dev/null || kill -"$sig" "$pid" 2>/dev/null || return 1
for i in {1..20}; do
kill -0 "$pid" 2>/dev/null || return 0
sleep 0.5
done
kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null
return 1
}
先用负 PID 打整个进程组,组不存在时退回单 PID;最多等 10 秒;还不死才 KILL,并返回 1 让调用方知道「这是强杀的」。kill -0 对僵尸仍返回 0,所以这个函数假设调用方会 wait 或在 service manager 里运行;在 shell 里对后台作业,wait 之后 PID 才真正消失。
kill -9 的边界值得单独记住:
- 不执行清理:临时文件、锁文件、socket、数据库连接都不会被释放。数据库下次启动可能要做崩溃恢复。
- 对
D状态无效:信号挂起,进程要等 I/O 返回才死;NFS 卡死时可能等几分钟到永久。 - 杀不掉内核线程:
ps里方括号包起来的[kworker/...]没有用户地址空间,kill对它们无效。 - 不保证子进程一起死:子进程会被 PID 1 收养,继续占端口、继续写文件。要连子进程一起处理,用进程组、cgroup 或 service manager。
- PID 可能被复用:脚本里先检查再
kill存在 TOCTOU;用pidfd(5.3+)或 cgroup 才能绑定身份。
- 「
kill -9一定立刻生效」——D状态的进程要等内核 I/O 返回;信号只是挂起,不是抢占。 - 「Ctrl-Z 发的是
SIGSTOP」——发的是SIGTSTP,可以被捕获或忽略;SIGSTOP才不可捕获。 - 「后台进程不能读终端」——默认会被
SIGTTIN停住,但程序可以捕获或忽略它;stty tostop还会让后台写终端也停。 - 「
nohup让进程脱离终端」——它只忽略SIGHUP,进程仍在原会话、仍挂着控制终端;要脱离用setsid。 - 「僵尸占很多内存」——僵尸只保留
task_struct和退出状态,不占用户内存页;但它占 PID,数量多了会耗尽pid_max。 - 「
ps的%CPU是当前 CPU 使用率」——它是进程整个生命周期的平均值;看瞬时值用top、pidstat 1或mpstat。
0.9.6 僵尸、孤儿与 wait
子进程退出时不会立刻消失。内核把它变成僵尸,保留退出码,等父进程调用 wait/waitpid 取走。父进程取走之后,PID 和结构才释放。父进程先死、子进程还活着,子进程就是孤儿,会被最近的 subreaper 或 PID 1 收养。
$ python3 -c 'import os,time; pid=os.fork(); os._exit(0) if pid==0 else time.sleep(60)' & [1] 2401 $ sleep 1; ps -o pid,ppid,stat,comm --ppid 2401 PID PPID STAT COMMAND 2402 2401 Z python3 $ kill 2401 [1]+ Terminated python3 -c 'import os,time; ...' $ ps -o pid,stat,comm -p 2402 PID STAT COMMAND $ echo $? 1
Python 父进程 fork 出子进程,子进程立刻 _exit(0),父进程睡 60 秒且不 wait。于是 2402 变成 Z,父进程是 2401。杀掉父进程后,2402 被 PID 1 收养并回收,ps -p 2402 没有输出,退出码 1。
wait 的返回值有约定:正常退出返回退出码;被信号杀死返回 128+信号编号。shell 里可以用 $? 读到:
| 子进程结束方式 | wait 返回 | shell 里 $? |
|---|---|---|
exit 0 | 0 | 0 |
exit 7 | 7 | 7 |
被 SIGTERM 杀死 | 143 | 143 |
被 SIGKILL 杀死 | 137 | 137 |
被 SIGINT 杀死 | 130 | 130 |
容器里的 PID 1 必须负责回收孤儿。很多镜像的入口脚本不是完整的 init,子进程退出后没人 wait,僵尸越积越多。两种修法:用 tini/dumb-init 作为 PID 1,或者在程序里显式 wait。Docker 的 --init 就是注入一个最小的 init。
SIGCHLD 的默认动作是「忽略」,但「默认忽略」和「显式设为 SIG_IGN」不同:后者在 Linux 上会让子进程自动被回收(不留僵尸),前者仍会产生僵尸,只是不通知。这也是为什么「我什么都没做,怎么还有僵尸」——默认忽略不等于自动回收。
0.9.7 进程树、/proc 与身份
ps 给的是快照,pstree 给的是关系。排「谁启动了谁」时,pstree -ap 比默认的 ps 直观:
$ pstree -ap 2048 bash,2048 └─sleep,2210 300 $ ps -o pid,ppid,stat,comm --forest -p 2048 --ppid 2048 PID PPID STAT COMMAND 2048 1024 Ss bash 2210 2048 S sleep $ tr '\0' ' ' </proc/2210/cmdline; echo sleep 300 $ readlink /proc/2210/exe /usr/bin/sleep $ ls -l /proc/2210/cwd lrwxrwxrwx 1 me me 0 Mar 2 10:30 /proc/2210/cwd -> /home/me $ awk '/^(Name|State|NSpid|Threads):/' /proc/2210/status Name: sleep State: S (sleeping) NSpid: 2210 Threads: 1
pstree(apt install psmisc)的 -p 显示 PID、-a 显示命令行参数;它默认会压缩相同的兄弟进程,-c 关闭压缩。ps --forest 用缩进画父子关系,适合已经在 ps 里加了筛选条件时补看层级。
/proc/PID 不是磁盘上的目录,是内核为每个进程现场生成的视图。几个文件最常用:
| 路径 | 内容 | 用途 |
|---|---|---|
cmdline | 启动参数,NUL 分隔 | tr '\0' ' ' 还原完整命令行 |
environ | 环境变量,NUL 分隔 | 查 PATH、代理、注入的配置;同 uid 或 root 可读 |
cwd | 当前工作目录的符号链接 | 确认进程在哪个目录下跑 |
exe | 可执行文件的符号链接 | 确认实际跑的是哪个二进制、是否 (deleted) |
fd/ | 打开的文件描述符 | 查它开着哪些文件、socket、管道 |
status | 状态、PID、线程数、内存 | State、NSpid、Threads、VmRSS |
cmdline 和 environ 用 NUL 分隔而不是换行,所以 cat 看起来是一整行。用 tr '\0' '\n' 或 xargs -0 处理。exe 指向的二进制被替换或删除后,链接后面会出现 (deleted),这是排查「升级了程序但旧进程还在跑」的直接证据。
NSpid 在容器里特别有用:它按命名空间从外到内列出 PID。宿主机上看到 NSpid: 2210 1,表示这个进程在宿主机命名空间是 2210,在容器命名空间是 1。容器里的 ps 只显示后者,所以在容器里 kill 1 和在宿主机上 kill 2210 可能是同一个进程。
PID 会被复用,单凭一个数字判断身份不可靠。/proc/PID/stat 的第 22 个字段是 starttime(开机后的时钟滴答数),把它和 PID 一起记录,才能判断「还是不是原来那个进程」。更现代的做法是用 pidfd_open(2)(Linux 5.3+)拿到一个指向进程的 fd,或者用 cgroup 路径作为身份;systemctl、容器运行时都是这么做的。
0.9.8 nice、CPU 亲和与资源限制
nice 值影响调度权重,范围 -20(最高优先级)到 19(最低),默认 0。普通用户只能把 nice 调高(降低优先级),不能调低;root 可以。nice -n 10 cmd 启动时设置,renice -n 5 -p PID 改运行中的进程。
nice -n 10 tar -czf /backup/etc.tar.gz /etc # 启动时降低优先级
renice -n 5 -p 2210 # 运行中调整
ionice -c2 -n7 -p 2210 # I/O 优先级:best-effort,最低
taskset -c 0-3 -p 2210 # 绑定到 CPU 0-3
prlimit --pid 2210 --nofile=4096:4096 # 查看/修改 fd 上限
nice 只影响 CPU 时间分配,不限制 I/O;ionice 只影响块设备调度,不限制网络。要真正隔离 CPU、内存、I/O,用 cgroup v2(5.3)。taskset 把进程绑在指定 CPU 上,常用于减少缓存迁移或复现 NUMA 问题,不是「性能优化开关」:绑得太窄会让进程和中断抢同一个核。
ulimit -n 在既不给 -S 也不给 -H 时会同时设置软、硬限制;ulimit -n 512 会把硬限制也降到 512,当前 shell 内不能再升回。只改软限制要写 ulimit -S -n 512。对已经在跑的进程无效,要用 prlimit 或 systemd 的 LimitNOFILE=。ulimit -u 限制用户进程数,ulimit -c 控制 core dump 大小。改之前先 ulimit -a 看全部,不要只改一个。
0.9.8.1 调度类:nice 管不到的另一种优先级
nice 只在普通调度类里调权重。同一个进程还可以被放进实时类、批处理类或空闲类,这些由 sched_setscheduler(2) 决定,nice 改不了。先看 ps 怎么显示:
$ ps -o pid,cls,rtprio,pri,ni,comm -p $$ PID CLS RTPRIO PRI NI COMMAND 2048 TS - 19 0 bash $ chrt -p $$ pid 2048's current scheduling policy: SCHED_OTHER pid 2048's current scheduling priority: 0 $ chrt -f 20 sleep 30 chrt: failed to set pid 0's policy: Operation not permitted
CLS 是调度类,RTPRIO 只在实时类里有值,NI 只在普通类里有意义。普通用户把 SCHED_FIFO 设到 20 会失败,因为缺少 CAP_SYS_NICE,且 RLIMIT_RTPRIO(ulimit -r)默认是 0。
Linux 用 task_struct 里的 sched_class 选择调度器,用 policy 记录策略,用 prio/normal_prio/static_prio 记录优先级,用 rt_priority 记录实时优先级。普通类(SCHED_OTHER/SCHED_BATCH/SCHED_IDLE)由 nice 值映射到权重;实时类(SCHED_FIFO/SCHED_RR)用 1–99 的实时优先级;SCHED_DEADLINE 用 (runtime, deadline, period) 三元组做准入控制。
CLS | 策略 | 抢占与时间片 | 典型用途 | 设置方式 |
|---|---|---|---|---|
TS | SCHED_OTHER | 公平类,按权重分 CPU | 绝大多数进程 | nice/renice |
B | SCHED_BATCH | 公平类,唤醒时更偏向吞吐 | 编译、批处理 | chrt -b -p PID |
IDL | SCHED_IDLE | 只在 CPU 空闲时运行 | 后台 scrub、索引 | chrt -i -p PID |
FF | SCHED_FIFO | 实时,无时间片,跑到阻塞 | 音频、机器人、IRQ 线程 | chrt -f PRIO CMD |
RR | SCHED_RR | 实时,同优先级轮转 | 软实时、周期任务 | chrt -r PRIO CMD |
DLN | SCHED_DEADLINE | EDF,按 deadline 调度 | 硬实时、带周期预算 | chrt -d --sched-runtime ... |
SCHED_FIFO 的进程一旦就绪就会一直跑,直到它阻塞、主动让出,或被更高优先级的实时任务抢占;它不靠时间片轮转。SCHED_RR 才有同优先级的时间片。把这两个设到高优先级而又写出死循环,会让同核上的普通任务完全得不到 CPU,表现为 SSH 卡死、看门狗报警。内核默认用 sched_rt_runtime_us=950000、sched_rt_period_us=1000000 给实时任务限流:每 1 秒最多跑 0.95 秒,超过就 throttled,dmesg 里出现 sched: RT throttling activated。
普通类的权重由 nice 映射,内核表 sched_prio_to_weight[](kernel/sched/core.c)只给了几个锚点,其余按 1.25 的比率递推:
| nice | 权重 | 两个任务时的 CPU 份额(对手 nice=0) |
|---|---|---|
| -20 | 88761 | 98.9% |
| -10 | 9548 | 90.3% |
| 0 | 1024 | 50.0% |
| 5 | 335 | 24.6% |
| 10 | 110 | 9.7% |
| 19 | 15 | 1.4% |
两个普通任务竞争同一个 CPU 时,份额是 w_i / (w_1 + w_2),不是「nice 每加 1 少 5%」。nice 0 对 nice 10 的权重比是 1024:110 ≈ 9.3:1,所以前者应拿到约 90% 的 CPU。0.9.14 的实验会把这个比例量出来。
调度类的内核路径是:sched_setscheduler(2) → __sched_setscheduler()(kernel/sched/core.c)→ 选择 rt_sched_class/fair_sched_class/dl_sched_class → 写入 task_struct->sched_class、policy、prio。sched_setscheduler 的典型 errno:
errno | 触发条件 | 处理 |
|---|---|---|
EPERM | 没有 CAP_SYS_NICE,或实时优先级超过 RLIMIT_RTPRIO | 用 root、加 capability,或调高 ulimit -r |
EINVAL | 策略未知,或 sched_param 越界 | 用 sched_get_priority_max/min 取范围 |
ESRCH | PID 不存在 | 先确认进程还活着,注意 PID 复用 |
EFAULT | param 指针不可读 | 检查调用方地址空间 |
chrt 是这些调用的命令行包装,排障时先读、后写:
chrt -p 2210 # 只看:策略与优先级
chrt -f 10 -p 2210 # 改成 FIFO,优先级 10(需要权限)
chrt -r 20 -p 2210 # 改成 RR
chrt -b -p 2210 # 改成 SCHED_BATCH(-o 是 SCHED_OTHER)
chrt -i -p 2210 # 改成 SCHED_IDLE
chrt 改的是单个线程,不是整棵进程树;多线程程序要对每个 TID 分别设置,或者在线程创建前用 pthread_attr_setschedpolicy 继承。nice 与 chrt 的分工可以用一句话记住:nice 调权重,chrt 换队列。两者都改不了 cgroup 的 CPU 配额,后者是下一节的内容。
桌面环境还有一个容易忽略的变量:autogroup。开启 CONFIG_SCHED_AUTOGROUP 后,内核按会话/tty 把任务分组,nice 一个终端里的编译任务不会把另一个终端的 shell 饿死。cat /proc/$$/autogroup 会显示 /autogroup-2048;setsid 新建会话时也会新建 autogroup。/proc/sys/kernel/sched_autogroup_enabled 为 0 时退化成全局公平调度。
0.9.8.2 从 /proc/PID/sched 读出谁在等 CPU
ps 的 TIME 是累计 CPU 时间,精度只到秒;要看一个进程是在跑还是在等,读 /proc/PID/sched 与 /proc/PID/schedstat:
# 三个数:累计运行 ns、在运行队列里等待 ns、获得时间片次数
awk '{printf "runtime=%.3fs wait=%.3fs slices=%d\n", $1/1e9, $2/1e9, $3}' /proc/$$/schedstat
grep -E '^(prio|nr_switches|nr_voluntary_switches|nr_involuntary_switches|se.sum_exec_runtime|se.avg.util_avg|se.avg.runnable_avg|se.avg.load_avg|clock-delta)' /proc/$$/sched
| 字段 | 含义 | 读法 |
|---|---|---|
se.sum_exec_runtime | 累计实际占用 CPU 的纳秒数 | 两次采样差值就是区间 CPU 时间 |
se.avg.util_avg | PELT 平均利用率(0–1024) | 接近 1024 说明一直忙 |
se.avg.runnable_avg | 可运行但未获得 CPU 的平均量 | 明显高于 util_avg 说明在排队 |
nr_voluntary_switches | 主动让出 CPU 的次数 | 等 I/O、等锁、sleep |
nr_involuntary_switches | 被调度器抢占的次数 | 高说明 CPU 竞争或时间片耗尽 |
clock-delta | 调度时钟与任务时钟的差值 | 调试调度延迟,不用于判断 steal |
prio | 动态优先级 | 与 ps 的 PRI 同源,跨机不可比 |
nr_involuntary_switches 是最容易被忽略的一项:一个进程「看起来不忙」但这项很高,说明它想跑却抢不到 CPU;反过来,nr_voluntary_switches 高而 util_avg 低,说明它大部分时间在等 I/O 或锁。把这两个计数和 pidstat -w -p PID 1 一起看,就能区分「CPU 不够」和「I/O 慢」。
/proc/PID/stat 的第 14–22 字段是另一组常用证据:utime、stime、cutime、cstime、priority、nice、num_threads、itrealvalue、starttime。字段按空格分隔,但第 2 字段 comm 可能含空格和括号,脚本里不能简单 awk '{print $2}';从右往左数或先处理括号才安全。第 22 字段 starttime 在 0.9.7 已经用来绑定 PID 身份,这里补一句:starttime 的单位是时钟滴答,换算成秒要除以 sysconf(_SC_CLK_TCK)(通常是 100);/proc/uptime 直接是秒(两位小数),不是滴答。
0.9.9 ps 与 top:字段、选项与排序
ps 的选项分成三套语法,混用时列的顺序会变,脚本里只应该用 -o 显式点名。下表是排障时真正会用到的部分,完整清单在 man 1 ps 的 STANDARD FORMAT SPECIFIERS 一节。
| 风格 | 写法 | 选择范围 | 输出特点 | 脚本可用性 |
|---|---|---|---|---|
| UNIX | ps -ef | 所有进程 | UID PID PPID C STIME TTY TIME CMD | 字段固定,可解析 |
| UNIX | ps -l | 当前终端相关 | F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD | 适合人看,不适合脚本 |
| BSD | ps aux | 所有进程 | USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND | %CPU 是平均值,别当瞬时值 |
| BSD | ps axjf | 所有进程 + 树 | 含 PPID PGID SID | 看进程组/会话方便 |
| GNU 长选项 | ps --ppid 1 | 按父进程筛 | 可与 -o 组合 | 推荐 |
| 自定义 | ps -eo ... | 任意 | 列由你决定 | 生产脚本首选 |
常用的选择器与修饰符:
| 选项 | 作用 | 注意 |
|---|---|---|
-e / -A | 所有进程 | -e 与 BSD 的 ax 等价 |
-p PID / -q PID | 指定 PID | -q 是「快速」模式,进程消失时不报错 |
--ppid PID | 指定父进程 | 找「谁生的」比 grep 可靠 |
-u USER / -U USER | 有效用户 / 真实用户 | 两者在 setuid 程序上不同 |
-t TTY | 指定终端 | -t ? 选无控制终端的进程 |
-C NAME | 按命令名 | 名字来自 comm,不是完整命令行 |
-L | 每个线程一行 | 配合 -o lwp,nlwp 看线程 |
-T | 显示线程 | 与 -L 的输出组织不同 |
-w / -ww | 加宽 / 不限宽 | 默认 80 列会截断 args |
--forest | 用缩进画父子 | 与 -o 组合时保留树形 |
--sort=KEY | 排序 | --sort=-%cpu,+pid,负号降序 |
-h / --headers | 去表头 / 重复表头 | 长输出分页时用后者 |
-o FIELD= | 该列不要表头 | ps -o pid= -o comm= |
-o 的字段名区分大小写,常用的这些值得背下来:
| 字段 | 含义 | 单位/格式 | 易错点 |
|---|---|---|---|
pid / ppid | 进程 / 父进程 | 整数 | PID 会复用 |
pgid / sid | 进程组 / 会话 | 整数 | 负 PID 发信号靠它 |
tty / tpgid | 控制终端 / 前台组 | pts/0、? | ? 表示无控制终端 |
stat | 状态与修饰符 | Ss、R+ | 第一个字母才是主状态 |
ni / pri | nice / 优先级 | 整数 | pri 的基准随 ps 版本变,别跨机比较 |
psr | 当前 CPU | 整数 | 迁移频繁时只能当采样 |
etime / etimes | 已运行时间 | [[DD-]hh:]mm:ss / 秒 | 脚本用 etimes |
time | 累计 CPU 时间 | [[DD-]hh:]mm:ss | 不是墙钟时间 |
vsz / rss | 虚拟 / 常驻内存 | KiB | vsz 含未映射部分 |
wchan | 等待的内核函数 | 字符串 | 需要符号;空值常见于 R/Z |
comm / args | 命令名 / 完整命令行 | 15 字符 / 原样 | comm 会被截断 |
lwp / nlwp | 线程 ID / 线程数 | 整数 | -L 才有意义 |
pcpu / pmem | CPU / 内存百分比 | 一位小数 | pcpu 是生命周期平均 |
cls / rtprio | 调度类 / 实时优先级 | TS、FF、RR | 实时进程看这两个 |
ps 的输出宽度由终端决定,不是由数据决定。管道给 awk 时没有终端宽度,args 仍可能被截断到 80 列;加 ww 才是「我要完整命令行」。反过来,comm 永远只有 15 个字符,ps -C verylongname 匹配的是这个截断后的名字。
top 的头部五行各有分工,读错行就会把「缓存」当成「泄漏」:
| 行 | 关键字段 | 怎么读 |
|---|---|---|
| 第 1 行 | load average: 1/5/15 | 可运行 + 不可中断任务数,不是 CPU 百分比 |
| 第 2 行 | Tasks: ... running, ... sleeping, ... stopped, ... zombie | zombie 非 0 要查父进程 |
| 第 3 行 | %Cpu(s): us sy ni id wa hi si st | wa 高是 I/O 等待,st 高是虚拟机被宿主抢占 |
| 第 4 行 | MiB Mem: total free used buff/cache available | 看 available,不要看 free |
| 第 5 行 | MiB Swap: total free used avail | used 长期不为 0 才是问题 |
进程区的列与 ps 对应,但多了几个比率:VIRT 是虚拟地址空间(含映射但未触页的部分),RES 是常驻物理页,SHR 是其中可共享的部分,S 是状态,%CPU 是自上次刷新以来的增量(这正是它和 ps 最大的区别),TIME+ 是累计 CPU 时间,精确到百分之一秒。
top 的交互键不必全记,先记这十个:
| 键 | 作用 | 键 | 作用 |
|---|---|---|---|
P | 按 CPU 排序(默认) | M | 按内存排序 |
T | 按累计 CPU 时间排序 | k | 给 PID 发信号 |
r | 改 nice 值 | c | 切换命令名/完整命令行 |
H | 显示线程 | 1 | 每核一行 |
u | 按用户过滤 | f / o | 选字段 / 调顺序 |
批处理采集用 top -b,它不会进入全屏,输出可直接追加到文件:
# 每 2 秒采一次,共 5 次,只看 2048 这个进程及其线程
top -b -n 5 -d 2 -H -p 2048 > /tmp/top-2048.txt
# 按内存排序取前 15 行,宽输出不截断命令行
top -b -n 1 -o %MEM -w 512 | head -15
# 记录 1 分钟内的整体负载,供事后比对
top -b -n 60 -d 1 | grep -E '^(top -|%Cpu|MiB Mem)' > /tmp/load-1m.txt
top -b 的第一帧是自开机以来的累计值,%CPU 可能异常高;从第二帧开始才是区间增量。脚本里要么丢掉第一帧,要么用 -d 1 -n 2 取第二次输出。
按名字找进程时,pgrep 比 ps | grep 少两个坑:它不会匹配到自己的 grep,也能直接按父进程、终端、用户筛选。
| 选项 | 作用 | 例子 |
|---|---|---|
-a | 输出 PID + 完整命令行 | pgrep -a nginx |
-f | 匹配完整命令行 | pgrep -af 'backup.sh --full' |
-x | 精确匹配整个名字 | pgrep -x sshd |
-u / -t / -P | 按用户 / 终端 / 父进程 | pgrep -P 1 |
-n / -o | 最新 / 最老的一个 | pgrep -n -x sleep |
-c | 只输出计数 | pgrep -c -u www-data |
-g / -s | 按进程组 / 会话 | pgrep -g 2210 |
pkill 接受同样的匹配选项,外加 -SIGNAL 和 -e(打印被杀的进程)。pkill -f 匹配的是整条命令行,你的 SSH 会话、编辑器的命令行里都可能出现同一个字符串;先 pgrep -af 核对,再考虑 pkill。这也是 0.9.5 里「先看再杀」的由来。
0.9.9.1 cgroup 视角:进程的第三重身份
进程有 PID、进程组、会话三重身份;在容器与 systemd 时代还有第四重:cgroup。PID 会复用,会话会随终端消失,cgroup 路径则决定「谁给它分资源、谁给它记账、谁能在它失控时整组终止」。
cgroup(control group)把进程组织成一棵树,每个进程在同一时刻只属于一个 cgroup。v2 是单一层级(unified hierarchy),控制器(cpu、memory、io、pids、cpuset 等)通过 cgroup.subtree_control 逐级启用;资源限制写在进程所在的叶子节点上,记账沿树向上汇总。容器 = namespace(看什么)+ cgroup(用多少)+ rootfs(有什么)。
$ stat -fc %T /sys/fs/cgroup cgroup2fs $ cat /proc/$$/cgroup 0::/user.slice/user-1000.slice/session-2.scope $ cat /sys/fs/cgroup/cgroup.controllers cpuset cpu io memory pids $ systemd-cgtop --depth=2 -b -n 1 | head -6 Control Group Tasks %CPU Memory Input/s Output/s / 312 12.4 2.1G - - user.slice 289 11.8 1.9G - - user.slice/user-1000.slice 287 11.6 1.9G - -
0:: 是 v2 的固定前缀,后面是相对 /sys/fs/cgroup 的路径。用 stat -fc %T 判断版本:输出 cgroup2fs 是 v2,tmpfs 通常是 v1 或 hybrid;ls /sys/fs/cgroup/cgroup.controllers 存在与否更直接。
cgroup v2 的文件不多,按「读什么」和「写什么」分开记:
| 文件 | 读写 | 含义 | 典型值 |
|---|---|---|---|
cgroup.procs | 读写 | 该组的线程组 PID 列表;写入会迁移整个线程组 | 2048 |
cgroup.threads | 读写 | 线程级列表;写单个 TID | 2048 |
cgroup.type | 读 | domain 或 threaded | domain |
cgroup.controllers | 读 | 父级可下放的控制器 | cpu memory pids |
cgroup.subtree_control | 读写 | 对子节点启用哪些控制器 | +cpu +memory |
cpu.weight | 读写 | 相对权重,1–10000,默认 100 | 100 |
cpu.max | 读写 | 配额 周期,单位微秒;max 100000 表示不限 | 50000 100000 |
cpu.stat | 读 | usage_usec、nr_throttled、throttled_usec | 累计值 |
memory.current | 读 | 当前计入的内存字节 | 134217728 |
memory.max | 读写 | 硬上限;max 表示不限 | 268435456 |
memory.events | 读 | low/high/max/oom/oom_kill 计数 | oom_kill 1 |
memory.pressure | 读 | PSI 压力(some/full 的 avg10) | some avg10=0.00 |
pids.current / pids.max | 读/读写 | 进程数上限 | max |
io.stat | 读 | 每设备的 rbytes/wbytes/rios/wios | 累计值 |
cgroup.freeze | 读写 | 写 1 冻结整棵子树 | 0/1 |
cgroup.kill | 写 | 写 1 原子终止整棵子树(5.14+) | 1 |
cpu.weight 与 nice 不是同一套刻度:nice 是 -20..19,权重是 1..10000;把 v1 的 cpu.shares(默认 1024,范围 2..262144)直接抄到 v2 会得到完全不同的份额。cpu.max 才是硬限额,cpu.weight 只在竞争时按比例分配。
「no internal process」是 v2 最容易踩的规则:一个 cgroup 启用了子控制器的同时,不能直接放进程;进程必须放在叶子节点。systemd 用 .slice(内部节点)和 .scope/.service(叶子)表达这棵树,所以你会看到服务在 app.slice/foo.service,而不是直接在 app.slice。手工往父节点 echo PID > cgroup.procs 会得到 EBUSY。
cgroup 的内核路径:挂载 cgroup2 → kernfs 生成控制文件;写 cgroup.procs 走 cgroup_procs_write() → cgroup_attach_task() → 更新 css_set;写 memory.max 走 memory_max_write();写 cpu.max 走 cpu_max_write()。容器运行时(runc、crun)在创建容器时做三件事:建 cgroup、把 init 放进去、把 limits 写进对应文件。
最实用的三条命令:
# 1. 看某个 PID 属于哪个 cgroup
cat /proc/2210/cgroup
# 2. 以临时 scope 跑一个带限额的命令(systemd 用户会话)
systemd-run --user --scope -p MemoryMax=256M -p CPUQuota=50% bash
# 3. 冻结并整组终止(5.14+;比遍历 PID 可靠)
echo 1 | sudo tee /sys/fs/cgroup/lab/cgroup.freeze
echo 1 | sudo tee /sys/fs/cgroup/lab/cgroup.kill
systemd-cgtop 看不到 CPU/内存数字时,通常是该 slice 没开记账;用 systemctl set-property <unit> CPUAccounting=yes MemoryAccounting=yes 打开。容器里 systemd-cgtop 往往不可用,直接读 /sys/fs/cgroup 下的文件更可靠。cgroup.kill 只终止该 cgroup 里的进程,不会杀到宿主机的其他进程;这正是「整组终止」比 kill -9 遍历 PID 更安全的原因。
0.9.10 信号的处置、阻塞与排队
0.9.5 把信号当成「发过去就完事」,这一节补上内核侧的三件事:处置方式(disposition)、阻塞掩码(blocked mask)、待处理集合(pending set)。三者组合,才能解释「为什么信号发了却没反应」。
每个线程有三组与信号相关的位图:blocked 是主动屏蔽的信号,pending 是已投递但尚未处理的信号,ignored/caught 是处置方式。标准信号(1–31)在 pending 集合里只占一位,同一信号多次到达会合并;实时信号(SIGRTMIN–SIGRTMAX)按队列排队,可以带一个 sigval 参数。
处置方式只有四种:默认动作、忽略(SIG_IGN)、用户处理函数、由内核特殊处理(如 SIGKILL/SIGSTOP 不可捕获)。fork 后子进程继承父进程的处置;exec 后,被捕获的信号恢复默认,被忽略的信号继续保持忽略——这就是 nohup 的实现原理:它把 SIGHUP 设成 SIG_IGN 再 exec,忽略属性跨过 exec 留了下来。
阻塞与忽略是两件不同的事:
| 操作 | 效果 | 信号会丢吗 | 典型用途 |
|---|---|---|---|
忽略 SIG_IGN | 投递时直接丢弃 | 会 | nohup、不关心 SIGPIPE |
阻塞 sigprocmask | 投递后留在 pending | 不会(标准信号合并) | 保护临界区、sigwait |
| 捕获 handler | 中断当前代码去执行 | 不会 | 清理、重载配置 |
| 默认动作 | 终止/停止/忽略/core | 不适用 | 不安装 handler 时 |
阻塞掩码是每线程的,不是每进程的。多线程程序里,信号只会投递给「没有阻塞它」的任意一个线程;要让某个专用线程处理信号,标准做法是在主线程创建 worker 之前阻塞信号,worker 继承掩码,最后由 sigwait 线程解除阻塞并循环接收。
Python 可以把这个过程完整演示出来:
import os, signal, time
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM})
print("blocked:", signal.pthread_sigmask(signal.SIG_BLOCK, []))
os.kill(os.getpid(), signal.SIGTERM)
print("pending:", signal.sigpending())
signal.signal(signal.SIGTERM, lambda s, f: print("handler ran"))
signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM})
time.sleep(0.1)
输出顺序是 blocked → pending 含 SIGTERM → handler ran。注意 signal.signal() 必须在解除阻塞之前安装,否则解除的瞬间默认动作就把进程终止了。
/proc/PID/status 把这三张位图暴露成十六进制掩码:
grep -E '^(SigQ|SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt):' /proc/$$/status
SigPnd 是线程私有的 pending,ShdPnd 是进程共享的 pending;SigBlk、SigIgn、SigCgt 分别是阻塞、忽略、捕获的掩码。第 N 号信号对应第 N-1 位,从右往左数。用一行 Python 解码比心算可靠:
python3 - <<'PY'
import os
for line in open(f"/proc/{os.getpid()}/status"):
if line.startswith(("SigBlk", "SigIgn", "SigCgt")):
name, mask = line.split(":")
v = int(mask.strip(), 16)
sigs = [n for n in range(1, 65) if v >> (n - 1) & 1]
print(name, sigs)
PY
信号处理的几条硬规则,写代码和读别人的代码都用得上:
| 规则 | 含义 | 后果 |
|---|---|---|
| handler 里只能调 async-signal-safe 函数 | printf、malloc 都不安全 | 死锁或堆损坏 |
SA_RESTART 只重启部分系统调用 | read/write 会重启,poll/epoll_wait/select 不会 | 必须自己处理 EINTR |
signal() 的语义随 libc 变 | glibc 的 signal 默认带 SA_RESTART | 可移植代码用 sigaction |
| 标准信号会合并 | 连发 10 个 SIGUSR1 可能只处理 1 次 | 计数型通知要用实时信号或自管道 |
SIGKILL/SIGSTOP 不可捕获、不可阻塞 | sigprocmask 会返回 EINVAL | 别把它们写进掩码 |
exec 保留忽略、清除捕获 | 守护进程常用这一点 | nohup 依赖它 |
SIGCHLD 是最容易写错的一个。它的默认动作是「忽略」,但「默认忽略」不等于「显式设为 SIG_IGN」:后者在 Linux 上会让子进程退出时自动被回收,前者仍会产生僵尸。要可靠回收,有三种写法:
/* 写法一:阻塞 SIGCHLD,fork 之后用 sigwaitinfo 收尸,避免 handler 与 wait 的竞态 */
sigset_t set, old;
sigemptyset(&set); sigaddset(&set, SIGCHLD);
sigprocmask(SIG_BLOCK, &set, &old);
pid_t pid = fork();
if (pid == 0) { /* child */ _exit(0); }
int status;
sigwaitinfo(&set, NULL); /* 等到子进程状态变化 */
waitpid(pid, &status, WNOHANG); /* 再取一次,防止漏掉 */
sigprocmask(SIG_SETMASK, &old, NULL);
另外两种是:在 handler 里用 while (waitpid(-1, &st, WNOHANG) > 0) 循环收尸;或者用 signalfd 把 SIGCHLD 变成可 poll 的 fd。前者要小心 errno 与可重入,后者适合事件循环。
siginfo 里的 si_code 能告诉你信号是谁发的,这在排障时比信号本身有用:
si_code | 含义 | 例子 |
|---|---|---|
SI_USER | kill(2)/raise(3) 发的 | 运维手动 kill |
SI_KERNEL | 内核发的 | 终端驱动发 SIGINT |
SI_TKILL | tkill/tgkill | 线程库内部 |
SI_QUEUE | sigqueue 带值 | 实时信号 |
SI_TIMER | POSIX timer 到期 | timer_create |
SI_CHLD | 子进程状态变化 | CLD_EXITED/CLD_KILLED |
退出状态与信号的关系也要背清楚,脚本里的 $? 全靠它:
| 结束方式 | waitpid 宏 | shell $? | 说明 |
|---|---|---|---|
exit(0) | WIFEXITED,WEXITSTATUS=0 | 0 | 正常 |
exit(7) | WIFEXITED,WEXITSTATUS=7 | 7 | 正常 |
被 SIGTERM 杀 | WIFSIGNALED,WTERMSIG=15 | 143 | 128+15 |
被 SIGKILL 杀 | WIFSIGNALED,WTERMSIG=9 | 137 | 128+9 |
被 SIGINT 杀 | WIFSIGNALED,WTERMSIG=2 | 130 | 128+2 |
被 SIGSTOP 停 | WIFSTOPPED,WSTOPSIG=19 | — | 不是终止,可 SIGCONT |
SIGPIPE 值得单独说:往一个读端已关闭的管道写,内核先发 SIGPIPE,默认动作是终止;如果程序忽略或阻塞了它,write 才返回 -1/EPIPE。shell 里 cmd | head -1 之后 cmd 报 141,就是被 SIGPIPE 杀了。这不是错误,是管道提前关闭的正常结果;set -o pipefail 会把它变成脚本的失败码,写管道脚本时要预料到。
最后是信号的投递对象。kill(2) 的三种形式在 0.9.5 已经出现,补充两个更精确的接口:
tgkill(2)指定线程组和线程 ID,避免「发给进程后被任意线程处理」的歧义;线程库的pthread_kill最终走它。pidfd_open(2)+pidfd_send_signal(2)(Linux 5.1+)先拿一个指向进程的 fd,再发信号。PID 被复用也不会误伤,这是 systemd、容器运行时的做法。prctl(PR_SET_PDEATHSIG, SIGTERM)让子进程在父进程死亡时收到信号;它只在父线程退出时触发,且exec后保留,适合「子进程不能比父进程活得久」的场景。
0.9.11 三条可直接运行的脚本
0.9.11.1 进程快照:一眼看出僵尸、D 状态与资源大户
ps 的默认列不适合巡检。下面这个脚本固定列、固定排序、固定 locale,输出可以直接贴进工单:
#!/usr/bin/env bash
# proc-report.sh —— 进程快照:异常状态、资源大户、按状态计数
set -euo pipefail
export LC_ALL=C
echo "== $(date -Is) host=$(hostname) kernel=$(uname -r)"
echo "== load: $(cut -d' ' -f1-3 /proc/loadavg)"
echo "-- 异常状态(Z 僵尸 / D 不可中断 / T 停止)"
ps -eo stat,pid,ppid,pgid,sid,etime,comm --sort=pid |
awk 'NR==1 || $1 ~ /^[ZDT]/'
echo "-- CPU 前 5(pcpu 为生命周期平均,仅作线索)"
ps -eo pcpu,pmem,rss,pid,comm --sort=-pcpu | head -6
echo "-- 内存前 5(RSS,单位 KiB)"
ps -eo rss,pmem,pid,comm --sort=-rss | head -6
echo "-- 按状态计数"
ps -eo stat= | cut -c1 | sort | uniq -c | sort -rn
三个细节决定它是否可靠:LC_ALL=C 保证 ps 的表头与数字格式稳定;stat= 去掉表头,cut -c1 只取主状态;--sort=-pcpu 的负号是降序。ps 的 pcpu 是平均值,所以这个脚本只用来「找线索」,确认瞬时占用要配合 top -b -n 2 或 pidstat 1。
awk 'NR==1 || $1 ~ /^[ZDT]/' 保留表头,同时匹配 Z、D、T 开头的状态;D 后面可能跟 <,T 后面可能跟 +,正则不锚定结尾是有意的。
0.9.11.2 停一棵进程树:整组 TERM、有界等待、身份校验
0.9.5 的 stop_tree 是教学版,缺少 PID 复用校验与僵尸判断。生产版要把这两件事补上:
#!/usr/bin/env bash
# stop-tree.sh PID [宽限秒数]
# 退出码:0 已停止;1 强杀;2 参数错;3 PID 不存在或身份不符
set -euo pipefail
pid=${1:?usage: stop-tree.sh PID [grace]}
grace=${2:-10}
[[ $pid =~ ^[0-9]+$ ]] || { echo "not a pid: $pid" >&2; exit 2; }
stat_of() { cat "/proc/$1/stat" 2>/dev/null || return 1; }
# /proc/PID/stat 的第 22 字段是 starttime;用它绑定“还是原来那个进程”
starttime() { stat_of "$1" | awk '{print $22}'; }
state_of() { stat_of "$1" | awk '{print $3}'; }
orig_start=$(starttime "$pid") || { echo "no such pid: $pid" >&2; exit 3; }
pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')
signal_tree() {
local sig=$1
if [[ -n ${pgid:-} && $pgid == "$pid" ]]; then
kill -"$sig" -- "-$pid" 2>/dev/null && return 0
fi
kill -"$sig" "$pid" 2>/dev/null
}
signal_tree TERM || true
for ((i = 0; i < grace * 2; i++)); do
[[ -d /proc/$pid ]] || exit 0
[[ $(state_of "$pid") == Z ]] && exit 0 # 僵尸已死,等父进程收尸
sleep 0.5
done
# 等待期间 PID 可能被复用:身份不符就不再动手
[[ $(starttime "$pid" 2>/dev/null) == "$orig_start" ]] || {
echo "pid $pid reused; refusing to kill" >&2; exit 3; }
echo "grace expired, sending SIGKILL to $pid (pgid=$pgid)" >&2
signal_tree KILL || true
sleep 1
[[ -d /proc/$pid && $(state_of "$pid") != Z ]] && exit 1
exit 0
它比教学版多做了四件事:用 starttime 确认身份;只有「PID 等于 PGID」时才打整组,避免误杀别人的组;把 Z 当成「已停止」而不是「还活着」;宽限期按 0.5 秒轮询,最多 grace 秒。kill -0 在这里被刻意避开,因为它对僵尸仍返回成功。
0.9.11.3 可靠地脱离终端:pidfile + 就绪检查
nohup cmd & 的 $! 是 shell 的子进程,但 setsid -f 会再 fork 一次,$! 可能只是短命的 wrapper。要在脚本里拿到真正的守护进程 PID,让子进程自己写 pidfile:
#!/usr/bin/env bash
# detach.sh --log FILE --pidfile FILE -- CMD [ARGS...]
set -euo pipefail
log= pidfile= cmd=()
while [[ $# -gt 0 ]]; do
case $1 in
--log) log=$2; shift 2 ;;
--pidfile) pidfile=$2; shift 2 ;;
--) shift; cmd=("$@"); break ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[[ ${#cmd[@]} -gt 0 && -n $log && -n $pidfile ]] || {
echo "usage: detach.sh --log FILE --pidfile FILE -- CMD..." >&2; exit 2; }
: >>"$log"
rm -f "$pidfile"
# 在新会话里运行;bash 先写自己的 PID,再 exec 目标程序,PID 不变
setsid -f bash -c '
echo $$ >"$1"
exec "${@:2}"
' _ "$pidfile" "${cmd[@]}" </dev/null >>"$log" 2>&1
for _ in {1..40}; do
[[ -s $pidfile ]] && break
sleep 0.05
done
[[ -s $pidfile ]] || { echo "child did not start" >&2; exit 1; }
pid=$(cat "$pidfile")
sid=$(ps -o sid= -p "$pid" | tr -d ' ')
[[ $sid == "$pid" ]] || { echo "not a session leader: pid=$pid sid=$sid" >&2; exit 1; }
echo "started pid=$pid sid=$sid log=$log"
# 停止:按 pidfile,先 TERM 再 KILL
# kill -TERM "$pid"; for i in {1..20}; do [[ -d /proc/$pid ]] || break; sleep 0.5; done
关键点是 bash -c 里的 echo $$ 与随后的 exec:$$ 是新会话首领的 PID,exec 不换 PID,所以 pidfile 里的值就是目标进程。</dev/null 断掉 stdin,避免后台读终端收到 SIGTTIN;>>"$log" 让 stdout/stderr 落到文件,避免持有已关闭的终端 fd。
如果机器上有 systemd,优先用 systemd-run,省掉 pidfile 与守护化的全部细节:
systemd-run --user --unit=job1 --collect \
--property=WorkingDirectory="$PWD" \
/path/to/job.sh
systemctl --user status job1.service
journalctl --user -u job1.service -f
它给你 cgroup、重启策略、日志收集和 systemctl stop 的整组终止;--collect 让失败的 unit 在退出后自动清理,不会堆一堆 failed。
0.9.12 两个实验:进程组与信号掩码
实验一:看清「终端只认前台组」
目的:用同一组命令对比「有作业控制」与「无作业控制」时 PGID 的差别,并验证 Ctrl-C 只打前台组。
# 终端 A:开一个交互式 bash,打开作业控制
bash --norc -i
set -m
sleep 300 &
job=$!
ps -o pid,ppid,pgid,sid,tpgid,stat,comm -p $$,"$job"
预期:sleep 的 PGID 等于它自己的 PID,SID 等于 bash 的 PID,TPGID 仍指向前台(还是 bash 的组),STAT 带 + 的是前台组。此时在终端按 Ctrl-C,只有 bash(前台组)收到 SIGINT,后台的 sleep 不受影响;jobs 里它仍是 Running。
再对比关闭作业控制的情况:
bash --norc -c 'set +m; sleep 300 & ps -o pid,ppid,pgid,sid,tpgid,stat,comm -p $$,$!'
预期:sleep 的 PGID 与 SID 都等于外层 bash 的 PID,和 shell 同组。这时若向该组发 SIGINT(kill -INT -- -$PGID),shell 和 sleep 会一起收到;在脚本里这就是 kill 0 的危险来源。
最后验证「整组 TERM」:
setsid bash -c 'sleep 300 & sleep 300 & wait' &
sleep 0.2
pgid=$(ps -o pgid= -p $! | tr -d ' ')
ps -o pid,pgid,sid,stat,comm -g "$pgid"
kill -TERM -- "-$pgid"
sleep 0.2
ps -o pid,stat,comm -g "$pgid" || echo "group $pgid gone"
预期:ps -g 列出会话首领和两个 sleep,kill 之后整组消失。注意 setsid 自己成为会话首领,SID 与 PGID 都等于它的 PID。
实验二:用 strace 看信号从投递到返回
目的:确认 SIGTERM 的 si_code、handler 的 rt_sigreturn,以及阻塞期间的 pending 状态。
准备一个带 handler 的脚本(0.9.5 的 slow.sh 即可),然后:
strace -f -tt -e trace=signal -o /tmp/sig.trace ./slow.sh &
tpid=$!
sleep 0.3
kill -TERM "$tpid"
wait "$tpid"; echo "rc=$?"
grep -E 'SIGTERM|rt_sigaction|rt_sigprocmask|rt_sigreturn' /tmp/sig.trace | tail -20
预期能看到三类行:启动时的 rt_sigaction(SIGTERM, ...) 安装 handler;--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=..., si_uid=...} --- 投递;handler 返回后的 rt_sigreturn()。si_code=SI_USER 说明是 kill(2) 发的;如果是终端 Ctrl-C,会看到 SI_KERNEL。
再看阻塞与 pending:
python3 - <<'PY' &
import os, signal, time
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM})
print("pid", os.getpid(), flush=True)
time.sleep(30)
PY
py=$!
sleep 0.3
kill -TERM "$py"
grep -E 'SigBlk|SigPnd|ShdPnd' /proc/$py/status
kill -KILL "$py"
预期:SigBlk 的第 15 位为 1,SigPnd 或 ShdPnd 的第 15 位也为 1;进程没有退出,因为信号被阻塞而不是被忽略。kill -KILL 之后才消失。这个实验解释了「为什么发了 TERM,进程还活着」的一类常见现象:不是它不听话,是它把信号挡住了。
0.9.13 十四个容易踩错的边界
| # | 现象 | 原因 | 修复/判断 |
|---|---|---|---|
| 1 | kill -9 后端口仍被占用 | 子进程继承了监听 fd,或进程处于 D 状态 | 用 ss -lptn 找持有者;整组/cgroup 终止 |
| 2 | nohup 后进程停在 T | 仍挂控制终端,后台读 stdin 触发 SIGTTIN | </dev/null,或 setsid |
| 3 | 脚本里 kill %1 报「无此作业」 | 非交互 shell 默认无作业控制 | set -m,或直接用 PID/PGID |
| 4 | kill 0 把脚本自己也杀了 | 0 表示「当前进程组」 | 永远用具体 PID 或负 PGID |
| 5 | $! 记录的 PID 与实际进程不符 | 命令是管道/子 shell/setsid -f | 让子进程写 pidfile,或 pgrep -P |
| 6 | ps aux | grep x 总有两条 | grep 自己也在进程表里 | pgrep -af x,或 grep -v grep |
| 7 | %CPU 显示 300% 但系统很闲 | 生命周期平均值,不是瞬时值 | top -b -n 2、pidstat 1 |
| 8 | ps 的 COMMAND 被截断 | 默认 80 列,comm 只 15 字符 | ps -ww -o args= |
| 9 | 杀完 PID 后误伤了别的进程 | PID 被复用 | 记录 starttime,用 pidfd 或 cgroup |
| 10 | wait 立刻返回 127 | 目标不是当前 shell 的子进程 | 只能 wait 自己启动的作业 |
| 11 | 僵尸杀不掉 | 已经退出,等父进程 wait | 让父进程收尸,或杀父进程 |
| 12 | timeout 5 cmd 后子进程还在 | 默认只杀直接子进程,不杀整组 | timeout --foreground 或自己建组 |
| 13 | 容器里 PID 1 不回收孤儿 | 入口脚本不是 init | docker run --init、tini |
| 14 | kill -0 成功就以为进程健康 | 僵尸也返回成功,且不检查状态 | 读 /proc/PID/stat 的第三字段 |
第 12 条值得展开:GNU timeout 默认把命令放进新进程组,超时后向该组发信号;加 --foreground 反而不建组,只杀直接子进程。很多人记反了。判断办法是看 timeout 的 man page 里 --foreground 一段,或者做实验:timeout 1 bash -c 'sleep 30 & wait' 之后 pgrep -a sleep 是否还有残留。
0.9.14 两个实验:nice 权重与 cgroup 记账
0.9.13 的坑多半来自「没量过」。这一节用两个实验把 nice 权重和 cgroup 记账变成可复现的数字:第一个用普通用户就能跑,第二个需要 systemd 用户会话或 root。
实验三:nice 不是标签,是权重
目的:在同一个 CPU 上放两个忙循环,一个 nice 0、一个 nice 10,用 /proc/PID/schedstat 量出实际 CPU 时间比,验证 1024:110 的权重。
#!/usr/bin/env bash
# nice-ratio.sh [秒数]:普通用户即可运行,不需要 root
set -euo pipefail
secs=${1:-5}
cpu=$(taskset -pc $$ | sed 's/.*: //' | cut -d, -f1 | cut -d- -f1)
taskset -c "$cpu" nice -n 0 bash -c 'while :; do :; done' >/dev/null 2>&1 &
p0=$!
taskset -c "$cpu" nice -n 10 bash -c 'while :; do :; done' >/dev/null 2>&1 &
p10=$!
sleep 0.2
read -r a0 _ _ <"/proc/$p0/schedstat"
read -r a10 _ _ <"/proc/$p10/schedstat"
sleep "$secs"
read -r b0 _ _ <"/proc/$p0/schedstat"
read -r b10 _ _ <"/proc/$p10/schedstat"
kill "$p0" "$p10" 2>/dev/null || true
wait "$p0" "$p10" 2>/dev/null || true
awk -v a0="$a0" -v b0="$b0" -v a10="$a10" -v b10="$b10" 'BEGIN {
r0 = (b0 - a0) / 1e9; r10 = (b10 - a10) / 1e9;
printf "cpu%d: nice0=%.2fs nice10=%.2fs ratio=%.1f\n", '"$cpu"', r0, r10, r0 / r10;
printf "share: nice0=%.1f%% nice10=%.1f%%\n", 100*r0/(r0+r10), 100*r10/(r0+r10);
}'
预期:ratio 在 8–10 之间,nice0 约 4.5 秒、nice10 约 0.5 秒(5 秒窗口)。如果 ratio 接近 1,先确认两个进程真的绑在同一个 CPU(taskset -pc)、机器上没有其他负载把 CPU 占满;如果 ratio 远大于 10,检查是否有一个进程没跑满整个窗口。这个实验也解释了「nice 10 不是把 CPU 砍半」:它只把权重降到约 1/9。
实验四:cgroup 的限额、记账与整组终止
目的:在临时 scope 里放一个进程,读它所在 cgroup 的 cpu.max、memory.max、cpu.stat,再用 cgroup.kill 一次收掉整棵子树。
#!/usr/bin/env bash
# cgroup-lab.sh:需要可用的 systemd 用户会话(systemctl --user status 成功)
set -euo pipefail
unit=cglab-$$
systemd-run --user --unit="$unit" --scope \
-p MemoryMax=64M -p CPUQuota=20% \
bash -c 'timeout 1 bash -c "while :; do :; done"; exec sleep 300' &
runner=$!
cg=
for _ in {1..50}; do
cg=$(systemctl --user show -p ControlGroup --value "$unit.scope" 2>/dev/null || true)
[[ -n $cg ]] && break
sleep 0.1
done
[[ -n $cg ]] || { echo "scope did not start" >&2; exit 1; }
base=/sys/fs/cgroup$cg
printf 'unit=%s cgroup=%s\n' "$unit" "$cg"
printf 'cpu.max=%s memory.max=%s\n' "$(cat "$base/cpu.max")" "$(cat "$base/memory.max")"
grep -E 'usage_usec|nr_throttled|throttled_usec' "$base/cpu.stat"
printf 'memory.current=%s\n' "$(cat "$base/memory.current")"
pid=$(systemctl --user show -p MainPID --value "$unit.scope")
cat "/proc/$pid/cgroup"
# 整组终止;cgroup.kill 需要 5.14+,老内核用 stop 或遍历 cgroup.procs
if [[ -w $base/cgroup.kill ]]; then
echo 1 >"$base/cgroup.kill"
else
systemctl --user stop "$unit.scope"
fi
wait "$runner" 2>/dev/null || true
预期:cpu.max 是 20000 100000(20%),memory.max 是 67108864;cpu.stat 的 usage_usec 约 1 秒,nr_throttled 可能为 0(20% 的配额对 1 秒忙循环足够);/proc/PID/cgroup 的路径与 $cg 一致;脚本结束后 scope 消失。
没有 systemd 用户会话时,用 root 走手工路径:
sudo mkdir -p /sys/fs/cgroup/lab
sleep 300 & pid=$!
echo "$pid" | sudo tee /sys/fs/cgroup/lab/cgroup.procs
cat "/proc/$pid/cgroup"
cat /sys/fs/cgroup/lab/memory.current
cat /sys/fs/cgroup/lab/cpu.stat
echo 1 | sudo tee /sys/fs/cgroup/lab/cgroup.kill
sudo rmdir /sys/fs/cgroup/lab
这条路径要求 lab 是叶子节点;如果想给 lab 再建子组,先确认 cgroup.subtree_control 已启用对应控制器,否则写 memory.max 会得到 ENOENT 或 EOPNOTSUPP。
调度与 cgroup 的八个坑
| # | 现象 | 原因 | 修复/判断 |
|---|---|---|---|
| 1 | nice -n -5 cmd 报 Permission denied | 普通用户只能调高 nice,不能调低 | 用 root,或授予 CAP_SYS_NICE;检查 ulimit -e |
| 2 | chrt -f 10 cmd 报 Operation not permitted | 缺 CAP_SYS_NICE,或 RLIMIT_RTPRIO=0 | ulimit -r 查看;生产上不要给普通用户实时权限 |
| 3 | 设了实时优先级后 SSH 卡死 | FIFO 忙循环饿死同核普通任务 | 用 chrt -f 1 起步,保留一个非实时核;知道 RT throttling 的存在 |
| 4 | renice 后多线程程序只有一个线程变了 | nice 在 Linux 上是每线程属性 | 对每个 TID renice,或在线程创建前设置 |
| 5 | 把 v1 的 cpu.shares=1024 抄到 v2 | v2 是 cpu.weight,刻度 1–10000 | 用 cpu.weight 重新换算,v2 默认 100 |
| 6 | 往父 cgroup 写 PID 报 EBUSY | v2 的 no-internal-process 规则 | 写进叶子 .scope/.service,或先关子控制器 |
| 7 | cgroup.kill 报 EOPNOTSUPP | 内核低于 5.14,或 cgroup v1 | 升级内核;v1 用 freezer + 遍历 cgroup.procs |
| 8 | memory.max 设了但进程没被杀 | 限制写在父节点,进程在别的叶子 | cat /proc/PID/cgroup 确认路径;读 memory.events 的 oom_kill |
第 4 条值得单独验证:ps -L -o pid,tid,ni,comm -p PID 列出每个线程的 nice,renice -n 10 -p PID 之后只有主线程变化,其余线程仍是旧值。要用 renice 改整组,先 pgrep -T 拿 TID 列表,或直接在程序里用 setpriority(PRIO_PROCESS, 0, nice) 对每个线程设置。
0.9.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
读出 ps -eo 里的 PGID/SID/TPGID | 能指出谁是会话首领、谁在前台 | 0.9.1–0.9.2 |
| 解释 Ctrl-C 为什么能打断管道两端 | 说出「前台进程组」 | 0.9.2 |
区分 S、D、T、Z | 说出哪个不能被信号唤醒 | 0.9.3 |
用 jobs/fg/bg/disown 管理作业 | 能在终端里完整走一遍 | 0.9.4 |
说出 nohup 与 setsid 的差别 | 指出 SID 与控制终端 | 0.9.4 |
| 按 TERM→等待→KILL 停进程树 | 能解释 143/137/124 | 0.9.5 |
| 制造并解释一个僵尸 | 说出谁该 wait | 0.9.6 |
从 /proc/PID 读出命令行、cwd、exe | 能解释 NUL 分隔与 (deleted) | 0.9.7 |
用 renice/taskset/prlimit | 知道各自边界 | 0.9.8 |
用 ps -o cls,rtprio 与 chrt -p 读出调度类 | 能说出 FIFO 与 RR 的差别 | 0.9.8.1 |
从 /proc/PID/cgroup 读出资源归属 | 能解释 v2 的 no-internal-process | 0.9.9.1 |
| 量出 nice 0 与 nice 10 的 CPU 份额 | 得到约 9:1,并能解释权重表 | 0.9.14 |
Q1 一个进程 ps 显示 D,你 kill -9 后它还在。为什么?下一步该看什么?
Q2 nohup ./job.sh & 之后关闭 SSH 终端,进程没死,但第二天发现它停在 T 状态。最可能的原因是什么?
Q3 父进程 fork 后不 wait,子进程退出。ps 里看到 Z,kill -9 无效。怎么让这个 PID 消失?
Q4 kill -TERM -- -2210 和 kill -TERM 2210 有什么区别?什么时候必须用前者?
答案
A1 D 是不可中断睡眠,进程卡在内核 I/O 里,信号要等 I/O 返回才处理。先看 cat /proc/PID/stack(root)或 cat /proc/PID/wchan,再查它等的是哪块设备/NFS;vmstat 1 的 b 列、dmesg 的 I/O 错误、nfsstat -c 都能给线索。杀不掉不是权限问题,是状态问题。
A2 它仍在原会话、仍挂着控制终端;后台读终端时终端驱动发了 SIGTTIN,默认动作是停止。日志里通常能看到它停在读 stdin 的地方。修法:重定向 stdin(</dev/null),或用 setsid/systemd 让它脱离控制终端。
A3 僵尸已经退出,信号无效;只能让父进程 wait。父进程若正常,催它回收;父进程若卡住,修父进程;父进程若死了,僵尸会被 PID 1 收养并回收。临时手段是杀掉父进程,但会留下孤儿,先确认父进程能否安全重启。
A4 前者发给进程组 2210 的所有进程,后者只发给 PID 2210。后台作业、shell 脚本启动的子进程树、timeout 包住的命令,都应该用进程组,否则子进程会漏掉。负号前加 -- 是为了让 kill 不把 -2210 当成选项。
母本对应
| 母本 | 章节 | PDF 页 | 本书落点 | 程度 |
|---|---|---|---|---|
| 鸟哥 | 第 16 章 程序管理与 SELinux 初探 | p.776–806 | 0.9.1–0.9.4、0.9.7–0.9.8 | 完整(ps、top、kill、jobs、nice) |
| Shotts | 第 10 章 进程 | p.108–119 | 0.9.1–0.9.6 | 完整(进程查看、作业控制、信号) |
| shell-book | 第 16 章 脚本控制 | p.368–397 | 0.9.4–0.9.6 | 精选(trap、后台、nice) |
| Kerrisk | 第 6 章 进程、第 20–28 章 信号 | — | 0.9.2、0.9.5–0.9.6 | 背景(用户可见语义) |
sched(7) | 调度策略与实时优先级 | man | 0.9.8.1–0.9.8.2 | 精选(SCHED_*、RLIMIT_RTPRIO) |
| 内核文档 | cgroup-v2.rst、cgroup-v1.rst | 在线 | 0.9.9.1、0.9.14 | 完整(v2 文件、no-internal-process) |
| systemd 文档 | systemd.kill(5)、systemd.exec(5)、systemd-run(1) | man | 0.9.5、0.9.7、0.9.9.1 | 一句带过(KillMode=、LimitNOFILE=、scope) |
下一站:0.10 磁盘、文件系统、挂载与 LVM/RAID把「进程在等什么」落到块设备上:
D状态背后可能是磁盘、NFS 或 RAID 重建;那一章会用 loop 设备做实验,不碰你的真盘。