Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 的第一个字母是主状态,后面的符号是修饰:

字母含义典型场景能不能被信号唤醒
Rrunning / runnable正在跑或在运行队列不适用
Sinterruptible sleep等事件、等输入、sleep能
Duninterruptible sleep等块设备 I/O、NFS、驱动不能(要等 I/O 返回)
TstoppedSIGSTOP、Ctrl-Z、调试器要 SIGCONT
ttracing stopptrace 暂停调试器控制
Zzombie已退出,等父进程 wait不能
Iidle 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 进程组、会话与控制终端

定义 0.9.1 · 进程组、会话与控制终端

进程组(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 状态字母背后的等待队列

进程状态与迁移 运行、可中断睡眠、不可中断睡眠、停止、僵尸与退出。 进程状态机(Linux 6.x,task_struct->__state)TASK_RUNNING可运行 / 正在运行TASK_INTERRUPTIBLE可被信号唤醒TASK_UNINTERRUPTIBLE不可被信号唤醒TASK_STOPPEDSIGSTOP / 调试TASK_ZOMBIE已退出,等待 waitTASK_TRACED被 ptrace 暂停EXIT_DEAD彻底释放等待事件事件就绪 / 唤醒等待 I/OI/O 完成SIGSTOPSIGCONTexit()wait() 回收ptrace就绪队列只收 TASK_RUNNING;睡眠状态通过等待队列唤醒;ZOMBIE 占的是 task_struct,不是内存页
读图:重点看两条环:可运行 ↔ 可中断睡眠(等待队列 + 唤醒),以及运行 → 僵尸 → 回收。不可中断睡眠(D 状态)不响应信号,常见于磁盘 I/O;大量 D 状态通常意味着 I/O 或文件系统卡住。

读图: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,因为已经没有可执行的上下文。

例 0.9.1 · 负载 8.0,CPU 却 95% 空闲

一台 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-ZSIGTSTP前台进程组
后台继续bg %1SIGCONT指定作业的进程组
拉回前台fg %1SIGCONT指定作业的进程组
断线保护disown -h %1无仅阻止 shell 发 SIGHUP
脱离会话setsid cmd无新会话、无控制终端
交给 systemdsystemd-run --unit=job1 cmd无新 unit、独立 cgroup

脚本里没有作业控制,但可以用 set -m 打开。打开后每个后台任务进入自己的进程组,kill -- -$! 能一次杀一组。关闭时所有子进程和脚本同组,kill 0 会连自己一起杀——这是脚本里最危险的一条命令之一。

0.9.5 信号:先 TERM,再 KILL

定义 0.9.2 · 信号(signal)

信号是内核投递给进程的异步通知,编号 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 可靠。

常用信号及默认动作:

信号编号默认动作什么时候用
SIGHUP1终止终端断线;守护进程常捕获它重载配置
SIGINT2终止Ctrl-C;交互式中断
SIGQUIT3终止 + coreCtrl-\;需要 core dump 时
SIGTERM15终止默认的「请优雅退出」
SIGKILL9终止最后手段,不可捕获
SIGSTOP19停止不可捕获的暂停
SIGCONT18继续恢复被停的进程
SIGUSR1/210/12终止应用自定义(如 nginx 重开日志)
SIGCHLD17忽略子进程状态变化,通知父进程
SIGPIPE13终止写已关闭的管道;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 才能绑定身份。
关于进程与信号的六个误解
  1. 「kill -9 一定立刻生效」——D 状态的进程要等内核 I/O 返回;信号只是挂起,不是抢占。
  2. 「Ctrl-Z 发的是 SIGSTOP」——发的是 SIGTSTP,可以被捕获或忽略;SIGSTOP 才不可捕获。
  3. 「后台进程不能读终端」——默认会被 SIGTTIN 停住,但程序可以捕获或忽略它;stty tostop 还会让后台写终端也停。
  4. 「nohup 让进程脱离终端」——它只忽略 SIGHUP,进程仍在原会话、仍挂着控制终端;要脱离用 setsid。
  5. 「僵尸占很多内存」——僵尸只保留 task_struct 和退出状态,不占用户内存页;但它占 PID,数量多了会耗尽 pid_max。
  6. 「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 000
exit 777
被 SIGTERM 杀死143143
被 SIGKILL 杀死137137
被 SIGINT 杀死130130

容器里的 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。

定义 0.9.4 · 调度类与静态优先级

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策略抢占与时间片典型用途设置方式
TSSCHED_OTHER公平类,按权重分 CPU绝大多数进程nice/renice
BSCHED_BATCH公平类,唤醒时更偏向吞吐编译、批处理chrt -b -p PID
IDLSCHED_IDLE只在 CPU 空闲时运行后台 scrub、索引chrt -i -p PID
FFSCHED_FIFO实时,无时间片,跑到阻塞音频、机器人、IRQ 线程chrt -f PRIO CMD
RRSCHED_RR实时,同优先级轮转软实时、周期任务chrt -r PRIO CMD
DLNSCHED_DEADLINEEDF,按 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)
-208876198.9%
-10954890.3%
0102450.0%
533524.6%
101109.7%
19151.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 取范围
ESRCHPID 不存在先确认进程还活着,注意 PID 复用
EFAULTparam 指针不可读检查调用方地址空间

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_avgPELT 平均利用率(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 一节。

风格写法选择范围输出特点脚本可用性
UNIXps -ef所有进程UID PID PPID C STIME TTY TIME CMD字段固定,可解析
UNIXps -l当前终端相关F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD适合人看,不适合脚本
BSDps aux所有进程USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND%CPU 是平均值,别当瞬时值
BSDps 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 / prinice / 优先级整数pri 的基准随 ps 版本变,别跨机比较
psr当前 CPU整数迁移频繁时只能当采样
etime / etimes已运行时间[[DD-]hh:]mm:ss / 秒脚本用 etimes
time累计 CPU 时间[[DD-]hh:]mm:ss不是墙钟时间
vsz / rss虚拟 / 常驻内存KiBvsz 含未映射部分
wchan等待的内核函数字符串需要符号;空值常见于 R/Z
comm / args命令名 / 完整命令行15 字符 / 原样comm 会被截断
lwp / nlwp线程 ID / 线程数整数-L 才有意义
pcpu / pmemCPU / 内存百分比一位小数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, ... zombiezombie 非 0 要查父进程
第 3 行%Cpu(s): us sy ni id wa hi si stwa 高是 I/O 等待,st 高是虚拟机被宿主抢占
第 4 行MiB Mem: total free used buff/cache available看 available,不要看 free
第 5 行MiB Swap: total free used availused 长期不为 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 路径则决定「谁给它分资源、谁给它记账、谁能在它失控时整组终止」。

定义 0.9.5 · cgroup v2

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读写线程级列表;写单个 TID2048
cgroup.type读domain 或 threadeddomain
cgroup.controllers读父级可下放的控制器cpu memory pids
cgroup.subtree_control读写对子节点启用哪些控制器+cpu +memory
cpu.weight读写相对权重,1–10000,默认 100100
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)。三者组合,才能解释「为什么信号发了却没反应」。

定义 0.9.3 · 信号处置与三张位图

每个线程有三组与信号相关的位图: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_USERkill(2)/raise(3) 发的运维手动 kill
SI_KERNEL内核发的终端驱动发 SIGINT
SI_TKILLtkill/tgkill线程库内部
SI_QUEUEsigqueue 带值实时信号
SI_TIMERPOSIX timer 到期timer_create
SI_CHLD子进程状态变化CLD_EXITED/CLD_KILLED

退出状态与信号的关系也要背清楚,脚本里的 $? 全靠它:

结束方式waitpid 宏shell $?说明
exit(0)WIFEXITED,WEXITSTATUS=00正常
exit(7)WIFEXITED,WEXITSTATUS=77正常
被 SIGTERM 杀WIFSIGNALED,WTERMSIG=15143128+15
被 SIGKILL 杀WIFSIGNALED,WTERMSIG=9137128+9
被 SIGINT 杀WIFSIGNALED,WTERMSIG=2130128+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 十四个容易踩错的边界

#现象原因修复/判断
1kill -9 后端口仍被占用子进程继承了监听 fd,或进程处于 D 状态用 ss -lptn 找持有者;整组/cgroup 终止
2nohup 后进程停在 T仍挂控制终端,后台读 stdin 触发 SIGTTIN</dev/null,或 setsid
3脚本里 kill %1 报「无此作业」非交互 shell 默认无作业控制set -m,或直接用 PID/PGID
4kill 0 把脚本自己也杀了0 表示「当前进程组」永远用具体 PID 或负 PGID
5$! 记录的 PID 与实际进程不符命令是管道/子 shell/setsid -f让子进程写 pidfile,或 pgrep -P
6ps aux | grep x 总有两条grep 自己也在进程表里pgrep -af x,或 grep -v grep
7%CPU 显示 300% 但系统很闲生命周期平均值,不是瞬时值top -b -n 2、pidstat 1
8ps 的 COMMAND 被截断默认 80 列,comm 只 15 字符ps -ww -o args=
9杀完 PID 后误伤了别的进程PID 被复用记录 starttime,用 pidfd 或 cgroup
10wait 立刻返回 127目标不是当前 shell 的子进程只能 wait 自己启动的作业
11僵尸杀不掉已经退出,等父进程 wait让父进程收尸,或杀父进程
12timeout 5 cmd 后子进程还在默认只杀直接子进程,不杀整组timeout --foreground 或自己建组
13容器里 PID 1 不回收孤儿入口脚本不是 initdocker run --init、tini
14kill -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 的八个坑

#现象原因修复/判断
1nice -n -5 cmd 报 Permission denied普通用户只能调高 nice,不能调低用 root,或授予 CAP_SYS_NICE;检查 ulimit -e
2chrt -f 10 cmd 报 Operation not permitted缺 CAP_SYS_NICE,或 RLIMIT_RTPRIO=0ulimit -r 查看;生产上不要给普通用户实时权限
3设了实时优先级后 SSH 卡死FIFO 忙循环饿死同核普通任务用 chrt -f 1 起步,保留一个非实时核;知道 RT throttling 的存在
4renice 后多线程程序只有一个线程变了nice 在 Linux 上是每线程属性对每个 TID renice,或在线程创建前设置
5把 v1 的 cpu.shares=1024 抄到 v2v2 是 cpu.weight,刻度 1–10000用 cpu.weight 重新换算,v2 默认 100
6往父 cgroup 写 PID 报 EBUSYv2 的 no-internal-process 规则写进叶子 .scope/.service,或先关子控制器
7cgroup.kill 报 EOPNOTSUPP内核低于 5.14,或 cgroup v1升级内核;v1 用 freezer + 遍历 cgroup.procs
8memory.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/1240.9.5
制造并解释一个僵尸说出谁该 wait0.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-process0.9.9.1
量出 nice 0 与 nice 10 的 CPU 份额得到约 9:1,并能解释权重表0.9.14
即时自测 · 0.9

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–8060.9.1–0.9.4、0.9.7–0.9.8完整(ps、top、kill、jobs、nice)
Shotts第 10 章 进程p.108–1190.9.1–0.9.6完整(进程查看、作业控制、信号)
shell-book第 16 章 脚本控制p.368–3970.9.4–0.9.6精选(trap、后台、nice)
Kerrisk第 6 章 进程、第 20–28 章 信号—0.9.2、0.9.5–0.9.6背景(用户可见语义)
sched(7)调度策略与实时优先级man0.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)man0.9.5、0.9.7、0.9.9.1一句带过(KillMode=、LimitNOFILE=、scope)

下一站:0.10 磁盘、文件系统、挂载与 LVM/RAID把「进程在等什么」落到块设备上:D 状态背后可能是磁盘、NFS 或 RAID 重建;那一章会用 loop 设备做实验,不碰你的真盘。