前三章讲了内核怎么切换、怎么被打断;本章讲运维与容器时代真正卡住你的那一层:状态怎么读、信号为什么无效、限制从哪来。

学习目标

读完本章,你应当能够:

  1. 默画 Linux 进程状态机,说出 R/S/D/T/Z/X 各自的进入与退出条件,解释 S 与 D 为什么必须分开,并列举产生 D 态的三类内核路径。
  2. 论证 kill -9 对 D 态进程「无效但会挂账」的因果链,并说出另外三类杀不掉的进程;推算共享库被 N 个进程映射后的 VSZ/RSS/PSS/USS。
  3. 说明 %CPU 是均值还是区间值、top 刷新间隔如何改变读数;解读 /proc/[pid]/stat 的 utime/stime/cutime/cstime/nvcsw/nivcsw 并按用途选用 /proc/[pid] 下的关键文件。
  4. 辨析阻塞与未决、SIGRTMIN 前后的排队语义、处理函数只能用异步信号安全函数的原因,以及 SIGPIPE/SIGSEGV/SIGBUS 的触发条件。
  5. 分清 ulimit 软/硬限制与 limits.conf/systemd/容器三层的生效顺序,解释「改了 ulimit -n 仍然 too many open files」。
  6. 对比 cgroup v1 与 v2 的层次结构,用 cpu.max/memory.max/io.max 判断容器实际拥有的资源,说明容器内 CPU 核数为何与配额不一致,并说出八类 namespace 与 docker exec/nsenter 的原理差异。

前置知识检查

你需要知道在哪一节补齐
fork/exec/exit 的语义、僵尸与孤儿、wait 的回收职责见第 4 章 4.4~4.6 节
上下文切换的开销构成(本章状态切换的成本都用它)见第 5 章 5.5 节
用户态/内核态、系统调用与中断路径;信号三态与异步信号安全函数见第 9 章 9.5、9.7、9.8 节
虚拟内存、页表、缺页(本章只讲观测口径);fd 表 → 打开文件表 → inode见第 8 章 8.2、8.3 节;第 7 章 7.2 节

10.1 进程状态机:内核视角与 ps 视角的错位

ps 的状态字母是 task_struct->__state 位掩码的投影,投影丢掉了三样信息——位掩码可以叠加、睡眠可以被「杀」标记(TASK_WAKEKILL)、不同状态可能显示成同一个字母;因此读懂状态必须先看内核表示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  进程状态机(内核表示)与 ps 单字母的投影

        fork/clone ──▶ TASK_RUNNING (R)  ◀── 被抢占/时间片耗尽(仍是 RUNNING)
                          │   ▲
            等待事件       │   │ 事件到达 或 信号投递
                          ▼   │
              TASK_INTERRUPTIBLE (S) ── 只有事件能唤醒 ──▶ TASK_UNINTERRUPTIBLE (D)
                唤醒条件 = 事件 ∪ 信号                      唤醒条件 = 只有事件
                (磁盘 I/O 完成、NFS RPC、驱动私有等待都落在 D)
  TASK_STOPPED/TASK_TRACED (T)、EXIT_ZOMBIE (Z)、EXIT_DEAD (X) 见下表(ps 会出现 D+/Z+ 这类叠加写法)

表 10-1 各状态的进入/退出条件与可观测性

状态进入条件退出条件(被谁唤醒)信号能否打断典型观测
Rfork/wake_up 后入 runqueue,或正在 CPU 上运行时间片耗尽、被抢占、主动 schedule()不适用vmstat 的 r 列(见第 11 章 11.2 节)
S等待事件:read 无数据、accept 无连接、等锁事件到达,或收到未被阻塞的信号能(返回 EINTR 或自动重启)长跑服务进程的常态
D等待不可中断的内核操作完成只有事件本身(I/O 完成、锁释放、驱动回调)不能,只记录 TIF_SIGPENDINGvmstat 的 b 列
TSIGSTOP/SIGTSTP 到达,或被 ptrace 追踪SIGCONT;PTRACE_DETACH不适用gdb attach 时短暂出现
Z/XZ:do_exit 后只留 task_struct 与退出码;X:task_struct 即将 freeZ 由父进程 wait 释放(第 4 章 4.6 节)不能(已无执行流)<defunct>;X 几乎抓不到

10.1.1 为什么 S 与 D 必须分开

两者都是睡眠,唯一差别是**「信号能不能把它从睡眠里拽出来」。这个差别是三条硬约束的产物。其一,内核里存在无法安全响应的临界区:若任务已把数据交给块设备驱动、正处于「等这块 I/O 返回」的中间态,强行让它响应信号返回用户态,它就必须在操作语义未完成时退出内核路径,向上层交出「写已完成」之类的错误结论——这不是「取消慢一点」,而是正确性问题。其二,信号处理要求返回用户态,而这类睡眠路径可能不持有完整的用户态返回上下文:内核的取舍是 TASK_UNINTERRUPTIBLE 不响应任何信号,需要「可被杀的不可中断睡眠」的驱动改用 TASK_KILLABLE(只响应致命信号,且必须在安全点检查)。其三,分开才能让调度器与负载统计各自正确:平均负载把 R 与 D 一起计入,因为两者都占着本应完成的资源**(CPU 或 I/O)形成积压;S 不计入,否则等用户输入的交互进程会把负载推高。这是「高负载、低 CPU」的解释入口(见第 11 章 11.2 节)。

重要

面试要给出的一句话:S 与 D 的差别是唤醒条件的集合。S 的唤醒条件是「事件 ∪ 信号」,D 的唤醒条件只有「事件」。D 的存在是为了保护那些语义上不可半途而废的内核操作。

10.1.2 D 态的典型产生路径

路径为什么不接受信号定位手段
块层 I/O 完成等待(submit_bio_wait:sync、fsync、部分直接 I/O)必须等块设备返回才能释放资源与更新状态多个 D 同时出现且 iostat 的 await 飞涨
NFS/CIFS 等网络文件系统 RPC 等待RPC 未返回时本地无「事件」可等伴随 nfs: server not responding;intr/soft 挂载可缓解(代价是可能返错)
驱动内部不可中断等待、内核态缺页回写(存储/GPU/虚拟化驱动的 wait_event)等待硬件或内存回收完成,中途放弃会留下不一致的设备/页状态wchan//proc/PID/stack 长期为同一内核函数

警告

最常见的错误回答是把 D 解释成「深睡眠、优先级低、所以杀不掉」。准确表述是它不响应信号:SIGKILL 会被置入该任务的未决集(TIF_SIGPENDING),但因为唤醒条件里没有信号,任务不会被唤醒去处理它。一旦等待的事件完成,任务回到 R,SIGKILL 就会在返回用户态前被处理——所以 D 态进程不是「杀了没用」,而是「信号挂账,等人放行」。

10.1.3 kill -9 杀不掉的四种进程

「杀不掉」不止一种成因,面试答全四种才算过关:

对象为什么 kill -9 无效正确处理
D 态进程唤醒条件不含信号,信号只挂账(10.1.2)等 I/O 恢复;修根因(磁盘/NFS/驱动);极端情况下重启主机
Z 态进程已终止、无执行流,不接收信号(见第 4 章 4.6 节)让父进程 wait;父进程长跑不回收时修父进程代码或重启父进程
PID 1(容器内 1 号进程)内核为 PID 1 的 SIGKILL/SIGSTOP 加保护:只有它自己安装过处理函数的信号才会投递,默认动作被替换为忽略docker stop(先 SIGTERM 再超时 SIGKILL 也靠 PID 1 配合);或 docker kill --signal;--init 注入 tini 转发信号
全部线程都在 D 态的多线程进程kill 投递给进程,实际由未阻塞该信号的任一线程处理;所有线程都在 D 时没有接收者ps -eLo pid,tid,stat,wchan,comm 找出 D 的具体线程等它出栈
1
2
3
4
5
# 1) 一眼看出哪些进程卡在 D;wchan 是它正在等待的内核函数名
ps -eo pid,stat,wchan:24,comm --sort=stat | grep -E '^ *[0-9]+ .* D'
echo w > /proc/sysrq-trigger && dmesg -T | tail -n 200   # 2) 打印所有 D 任务的内核栈,定位 I/O 路径
# 3) 多线程场景:kill 投递给进程,须先找出具体卡住的线程;并交叉验证块设备指标
ps -eLo pid,tid,stat,wchan:20,comm | awk '$3 ~ /D/'; iostat -xz 1 5 | grep -E 'Device|nvme|sd'

10.2 ps/top 字段的真实含义

10.2.1 内存四指标:VSZ、RSS、PSS、USS

这四个数不是四个精度级别的同一个量,而是对同一批物理页的四种分摊规则。理解它们的关键是问一句:一个被 3 个进程映射的共享库页,在谁的账上记多少?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  虚拟地址空间 VSZ(= statm 的 size,单位页)——把以下全部计入,且含未触碰的页:
  私有匿名映射(堆、线程栈、brk 段)、文件映射(MAP_PRIVATE 只有代码与只读数据,
  写时复制后转私有;MAP_SHARED 如共享库与 mmap 文件)、共享内存段(shmget、tmpfs)
  ──────────────────────────────────────────────────────────────
  物理内存侧(只有真的装了东西才算),恒有 USS ≤ PSS ≤ RSS:
    RSS = 该进程页表中"已驻留"的页数(含与他人共享的页,每人各记满一份)
    PSS = Σ(该进程映射的每个驻留页 ÷ 映射它的进程数)    USS = 只有本进程映射的驻留页
  ⟹ Σ(所有进程 RSS) 会远超物理内存,而 Σ PSS ≈ 物理内存占用

  算例:libc.so 2000 驻留页(其中 500 页另被 2 个进程映射,共 3 个进程)+ 300 私有堆页 + 100 私有栈页,页 4 KiB
    RSS = 2000 + 300 + 100 = 2400 页 × 4 KiB = 9600 KiB ≈ 9.4 MiB(共享页各记满)
    PSS = (1500 + 500/3) + 300 + 100 ≈ 2066.7 页 ≈ 8.1 MiB      (500 页三分摊)
    USS = 300 + 100 = 400 页 = 1600 KiB ≈ 1.6 MiB                (只有独占页)
  ⟹ 排查"谁吃了内存"用 PSS(跨进程不重复),"杀掉它能释放多少"看 USS
1
2
3
4
5
6
# smaps_rollup(4.14+)直接给 Pss/Private_*;逐个读 smaps 单次可达数十毫秒,不适合高频采集
cat /proc/1234/smaps_rollup
#   Rss: 9600 kB(与上面算例一致)  Pss: 8270 kB ≈ 8.1 MiB
#   Private_Clean: 1200 kB   Private_Dirty: 400 kB   Shared_Clean: 7600 kB
# Σ Pss 应接近 free 的 used(差值主要是内核 slab/页表/驱动占用)
grep -H Pss /proc/[0-9]*/smaps_rollup 2>/dev/null | awk '{s+=$2} END{printf "Σ Pss = %d MiB\n", s/1024}'

表 10-2 四个内存指标的统计口径与用途

指标物理 or 虚拟共享页如何分摊何时用常见误解
VSZ(statm 的 size)虚拟全额计入,且含未分配物理页与保留地址判断地址空间泄漏(大量 mmap 未 munmap)「VSZ 大就是泄漏」——64 位下几十 GB 通常无害
RSS/PSS物理(驻留)RSS 完全重复(每个映射者各记满一份);PSS 按映射者数量均摊快速看触达了多少物理页 / 容器多进程场景判断真实占用「Σ RSS = 内存占用」(多进程显著超出);「PSS 就是独占」(仍含分摊到自己的共享部分)
USS物理(驻留)完全排除共享页判断「杀掉它能真正释放多少」以为它包含共享库(不含)

10.2.2 %CPU 的统计口径与刷新间隔

top 默认显示的 %CPU 是自进程启动以来的平均值(utime + stime 除以进程存活时间),不是瞬时值;在 top 中以 I 切换 Irix 模式才是「距上次刷新区间」的占用。口径搞错会得到完全相反的结论。

1
2
3
4
5
6
  两种口径的差异(同一进程:启动后先忙 1 s,之后空闲 59 s)
    均值口径(top 默认):   1 s / 60 s × 100% ≈ 1.7%   ← 看不出它曾吃满一个核
    区间口径(top -I / pidstat):忙的那 1 s 内 = 100%,其余 ≈ 0%
  刷新间隔 -d 的影响(逐秒突发 100 ms、其余空闲):-d 1 可能读到 0%(恰好落在
  空闲窗口),也可能读到约 10%(落在突发窗口)⟹ 间隔越大,短时尖峰越容易被
  平均掉;只看瞬时值的工具甚至完全错过尖峰
1
2
3
4
# 区间口径:pidstat 明确给出"距上次采样"窗口内的 %usr/%system/%CPU
pidstat -p 1234 1 5      # Average:  0 1234  0.20  0.00  0.20  2  java
# top 默认把线程聚合成进程,-H 展开成线程后才能定位是哪条线程在烧 CPU
top -b -H -p 1234 -n 1 | head -n 20

提示

排障话术:「服务 CPU 不高但延迟抖动」时,先确认你读的是哪种 %CPU。均值口径会系统性掩盖短时尖峰,正确做法是用区间口径(pidstat 1、top -I)配 -H 看线程级分布,再用瞬时快照(perf top、火焰图)定位热点。见第 11 章 11.6 节。

10.3 /proc/[pid]:把 stat 计数器读成时间线

10.3.1 /proc/[pid]/stat 的关键字段

stat 是单行、以空格分隔的 52+ 个字段,编码紧凑(因此比 status 便宜得多,高频监控应当读它)。

字段序号名称含义与口径解读要点
3state状态字母(同 10.1)D 需立刻与 I/O 指标交叉验证
14/15utime/stime用户态/内核态消耗的时钟节拍数除以 getconf CLK_TCK(通常 100,1 tick = 10 ms)才是秒;stime 高指向 syscall 密集或锁竞争(见第 9 章 9.7 节)
16/17cutime/cstime已回收子进程的 utime/stime 累计值只在父进程 wait 的那一刻并入;长期不 wait 则恒为 0
19/22nice/starttime静态优先级(-20~19)/启动时刻(节拍数)nice 与权重的换算见第 5 章 5.4 节;「uptime − starttime/HZ」即进程存活时间
24/41/42rss/nvcsw/nivcsw驻留页数(单位是页)/自愿/非自愿上下文切换次数页数乘 getpagesize() 才是字节;自愿=主动睡眠让出(等锁、等 I/O),非自愿=被抢占(时间片耗尽、更高优先级唤醒)

nvcsw/nivcsw 的增量比例是最被低估的诊断信号(必须取窗口内增量,累计值没有意义):

1
2
3
4
  nvcsw 大、nivcsw 小:CPU 没被抢,是进程自己在等 —— 等锁 / 等 I/O / 等下游
    同时 stime 高 → 锁竞争或频繁 syscall(futex 等待也计入 stime);同时 wa 高 → I/O 等待
  nvcsw 与 nivcsw 都大:既被抢又在让 —— 典型"线程数远多于 CPU 核数"(第 5 章 5.6 节)
  nivcsw 大、nvcsw 小:纯粹的 CPU 争抢 —— 查 CPU 配额(10.6.1 节的 cpu.max)与亲和性
1
2
3
4
5
6
# 取两次增量再相减:进程可能已跑了几十天,累计值无法反映当下
read_stat() { awk '{print $1, $14, $15, $41, $42}' /proc/$1/stat; }
read_stat 1234; sleep 5; read_stat 1234
#   1234 812 340 15210 890  →  1234 815 342 15367 921
# 5 秒内:utime +3 tick(30 ms)、stime +2 tick(20 ms)、nvcsw +157、nivcsw +31
# 解读:CPU 只用了 50 ms 却自愿让出 157 次 → 瓶颈在等待,不在计算

10.3.2 /proc/[pid] 关键文件速查

表 10-3 /proc/[pid] 关键文件用途与采集代价

文件内容代价典型用途 / 陷阱
stat紧凑单行数值(10.3.1)最低高频监控首选;解析时 comm 可含空格与括号,须找最后一个 ) 之后再切字段
status键值对:State、VmRSS、Threads、SigPnd、ShdPnd、Seccomp低SigPnd/ShdPnd 位图直接看「信号是不是挂账了」
statm/smaps/smaps_rollupstatm 给 7 个页数;smaps 逐映射区给 Rss/Pss/Private_*/Shared_*、VmFlagssmaps 极高(可达几十 ms)statm 只有总量无分类、shared 不等于 PSS;内存归属分析用 smaps_rollup,容器里可能被 seccomp 拦截
limits/cgroup/io当前生效的软/硬 RLIMIT_*;所属 cgroup 路径(v1 多行、v2 单行);rchar/read_bytes 等 I/O 计数低limits 是确认限制的唯一权威(10.5 节),cgroup 判断 v1/v2(10.6 节);io 区分逻辑 I/O 与落盘 I/O(见下方警告)
fd//task/每个 fd 的符号链接;每线程一个子目录与 fd/线程数成正比ls -l /proc/PID/fd 查泄漏;task/TID/stat 才是线程的真实 CPU 账
environ/cmdline启动时的环境变量块与参数(\0 分隔)低排查注入的配置;environ 不反映 setenv 的后续修改
stack/wchan/maps内核栈符号、等待中的内核函数名、地址映射表中D 态定位卡点;wchan 在关闭 CONFIG_KALLSYMS 时显示 0

小心

/proc/[pid]/io 的计数经常被读错:rchar/wchar 是经过 read/write 系统调用的字节数(含命中 page cache),read_bytes/write_bytes 才是真正提交给块层的字节数。完全读 page cache 的进程 rchar 很大而 read_bytes 为 0;用 rchar 推断磁盘压力,结论会错得离谱。

10.4 信号机制:工程视角的三个坑

信号的语义(产生/阻塞/未决、可靠与不可靠、SIGCHLD 合并、异步信号安全)归第 9 章 9.8 节,这里只补工程侧的关键延伸。

10.4.1 处理函数的可重入约束到了工程里意味着什么

「只能用异步信号安全函数」(signal-safety(7))在工程上有三个直接后果:其一,不要在处理函数里做任何需要锁或分配的事——printf 会拿 stdio 的 FILE 锁,malloc 会拿分配器锁,而被中断的代码可能正持有同一把锁(同一线程内非递归锁 → 自锁);正确形态是只设置 volatile sig_atomic_t 标志,或走 self-pipe/signalfd 把异步通知转成「主循环可读的 fd」(与 epoll 的配合见第 7 章 7.5 节)。其二,必须保存并恢复 errno——处理函数里任何失败调用都会覆盖 errno,返回后原代码用错的 errno 走错分支,典型的「偶发、难复现」bug。其三,SA_RESTART 是取舍而非优化——它自动重启被打断的慢系统调用、省掉 EINTR 分支,代价是「用信号打断阻塞调用」这一惯用法失效。

10.4.2 常见信号速查

表 10-4 高频信号语义与处置

信号默认动作语义与触发场景工程处置要点
SIGTERM(15)终止可捕获的优雅停止请求;kill/docker stop/systemctl stop 先发它handler 内做「停止接收新请求 → 等在途请求 → 刷缓冲 → exit(0)」;超时后对方升级为 SIGKILL
SIGKILL(9)终止不可捕获、不可阻塞、不可忽略,内核直接终止不能用于优雅停机;只作 SIGTERM 超时后的兜底(docker stop -t、systemd TimeoutStopSec)
SIGHUP(1)终止终端挂断(发给会话首进程);守护进程约定为重载常用它重载配置,但必须显式忽略,否则 shell 退出会误杀守护进程
SIGUSR1/2终止语义完全由应用定义(重开日志、dump 统计、触发 GC)选它因为无系统约定、不会被误发;不装 handler 则默认终止
SIGPIPE(13)终止写一个读端已关闭的管道/socket;write 先产生该信号,被忽略才返回 EPIPE显式 signal(SIGPIPE, SIG_IGN),改用 EPIPE 判断对端关闭,避免进程被一次正常断连杀死
SIGSEGV(11)/SIGBUS(7)终止 + coreSIGSEGV:地址有合法映射但访问违规(空指针、写只读页、越界);SIGBUS:映射本身不存在或不满足硬件要求(mmap 文件被截断后访问空洞、未对齐访问)排查思路:判据是页表里有没有该地址的合法映射;有映射但违规 → SIGSEGV,映射不存在/不满足对齐 → SIGBUS
SIGINT(2)/SIGSTOP(19)/SIGTSTP(20)终止/停止Ctrl+C;SIGSTOP 不可捕获,SIGTSTP 由 Ctrl+Z 产生可捕获交互程序用 SIGINT 做「第一次优雅退出、第二次强退」;SIGCONT 恢复停止态

10.4.3 SIGCHLD 之外:为什么推荐把信号「去异步化」

SIGCHLD 的合并语义与循环 waitpid 的写法见第 9 章 9.8.2 节与第 4 章 4.6.3 节,这里只补两条结论:

  • 信号不是可靠的计数机制。 标准信号(SIGRTMIN 之前,编号 131)在未决位图上按位合并,「产生过几次」无法还原;实时信号(SIGRTMINSIGRTMAX,通常 34~64)按值排队,同种信号可多次未决,sigqueue 还能携带 union sigval 载荷。需要计数就用实时信号,或干脆不用信号。
  • 生产代码倾向于把信号转成 fd。 signalfd 让信号成为可 read 的事件源,与 epoll 统一处理;pidfd(5.3+)把「等待子进程」变成对一个 fd 的 poll,绕开 SIGCHLD 合并与 PID 复用问题。
1
2
3
4
5
6
7
# 三个十六进制位图,位 n 对应信号 n+1;判定:掩码 & (1 << (N-1)) 非零 ⟹ 第 N 号信号在集合中
# 例:ShdPnd 的 bit 8(值 0x100)置位 ⟹ 第 9 号信号 SIGKILL 已挂账未投递
grep -E 'SigQ|ShdPnd|SigBlk|SigCgt' /proc/1234/status
#   SigQ: 0/63028           ← 未决+排队信号数 / 该用户上限(RLIMIT_SIGPENDING)
#   ShdPnd: 0000000000000100 ← 进程级未决:有信号挂账没投递(D 态时常见)
#   SigCgt: 0000000180004a03 ← 已装 handler 的信号集合(SIGKILL/SIGSTOP 永不在列)
kill -l                 # 信号名与编号的映射(实时信号显示为 RTMIN+n)

10.5 资源限制的第一层:ulimit 与 RLIMIT_*

10.5.1 软限制、硬限制与 RLIMIT_* 全表

每个进程对每类资源持有两个值:软限制(soft limit)是当前强制值,硬限制(hard limit)是软限制的上界。非特权进程可以降低硬限制但不可提高(提高需 CAP_SYS_RESOURCE),这解释了一个高频困惑:为什么 ulimit -n 很小而 ulimit -Hn 很大?因为 shell 启动时只把软限制设到默认值(常见 1024),硬限制保留为系统允许的上限,所以「提高软限制」在 shell 里允许,「提高硬限制」不行。

表 10-5 与后端服务最相关的六项 RLIMIT_*

限制含义默认值(典型)典型故障与联动
RLIMIT_NOFILE单进程可打开的 fd 数软 1024、硬 1048576(systemd 默认 1024:524288)「too many open files」,accept/open/socket 返回 EMFILE;同时受 fs.file-max 与 nr_open 约束
RLIMIT_NPROC该真实用户 ID 可拥有的进程/线程数分布差异大(常见 4096 或 63488)口径是按 UID 而非按 cgroup:同一 UID 的其他进程会挤占额度,容器里同一 UID 跑多个服务极易互相挤占;fork 失败返回 EAGAIN
RLIMIT_AS/RLIMIT_STACK虚拟地址空间总上限 / 主线程栈大小unlimited / 通常 8192 KiBRLIMIT_AS 限的是 VSZ,设小了 JVM 直接起不来;栈溢出报 SIGSEGV,且线程栈不受 RLIMIT_STACK 限制(由 pthread_attr_setstacksize 决定)
RLIMIT_CORE/RLIMIT_MEMLOCKcore dump 上限(块数,512 B/块)/ 可 mlock 的内存量常为 0(关闭)/ 常为 64 KiB 或 8 MiB排查崩溃要打开 core 并配合 core_pattern;io_uring、AF_XDP、eBPF map 会遇到 ENOMEM(CAP_IPC_LOCK 可绕过)

10.5.2 ulimit -n 改了为什么还是 too many open files

ulimit 只改当前 shell 及其后续子进程的限制,真实生效值取决于服务的启动路径;父进程传给子进程时软/硬限制一并继承。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  fd 限制的五层来源(从系统到进程,逐层收窄)
  ① 内核全局上限:/proc/sys/fs/nr_open(单进程,默认 1048576)、
                  /proc/sys/fs/file-max(全系统 fd 总量,按内存自动计算)
  ② 系统级默认(PAM 登录路径):limits.conf 与 limits.d/*.conf
        "appuser soft nofile 65535" ⟹ 只对【经由 PAM 登录】的会话生效(ssh/su/login),
        对 systemd 服务、容器、cron 默认【不生效】
  ③ systemd 服务:unit 里的 LimitNOFILE=65535(直接 fork/exec,不经过 PAM,② 对它无效)
  ④ 容器:docker run --ulimit nofile=65535:65535(在③之后由 runtime 设置)
  ⑤ 进程自身:setrlimit(),或 shell 里 ulimit -n 65535(只能降低硬限制)
  ⟹ 唯一能在运行期确认以上全部结果的地方:/proc/PID/limits
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 1) 先看"实际生效值"而不是你设过的值——这是唯一权威来源
grep -E 'Max open files|Max processes' /proc/$(pgrep -f myapp | head -1)/limits
#   Max open files            65535                65535                files
# 2) 已被 systemd 拉起 → 查 unit 覆盖而不是 limits.conf
systemctl show myapp -p LimitNOFILE -p LimitNPROC
# 3) 容器 → 容器内 /proc 看到的就是当前进程的限制
docker exec myapp sh -c 'cat /proc/1/limits | grep -i "open files"'
# 4) 排除系统级上限阻塞:nr_open 必须 ≥ 你想设的值
cat /proc/sys/fs/nr_open; cat /proc/sys/fs/file-nr   # 单进程上限;已分配/空闲/file-max
# 5) 区分"限制"与"泄漏":fd 数单调增长 ⟹ 泄漏;长期贴上限 ⟹ 限制
ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head

注意

一个易被忽略的联动:RLIMIT_NOFILE 之外,select 还受 FD_SETSIZE(glibc 默认 1024)约束。即使把 ulimit -n 提到 65535,select 能监控的 fd 数也不会超过 FD_SETSIZE——这是「高并发必须换 epoll」的一个纯工程理由(epoll 的实现见第 7 章 7.5 节)。

10.6 资源限制的第二层:cgroup 与 namespace

ulimit 是按进程的限制,无法回答「一组进程总共能用多少 CPU/内存」。cgroup(control group)补上这一层,namespace 则解决「看见什么」。

10.6.1 cgroup v1 与 v2 的差异

1
2
3
4
5
6
7
8
9
  v1:每个控制器一棵独立树(一个进程可同时属于多棵树)
    /sys/fs/cgroup/cpu/mygrp/tasks 、/sys/fs/cgroup/memory/mygrp/tasks ……
    后果:层级不一致,无法原子地对"一个容器"统一限额;同一进程可能在 cpu 树里
          属于 A 组、在 memory 树里属于 B 组
  v2:所有控制器共用一棵统一树(一个进程只属于一条路径)
    /sys/fs/cgroup/mygrp/   ← 一个目录就是"一个容器",内含 cgroup.controllers
                              (本层可用控制器)、cpu.max、memory.max、io.max 等
    优点:一个层次、一套文件、可原子迁移;"no internal process" 约束强制只有
          叶子节点能放进程,语义更干净

表 10-6 cgroup v1/v2 关键文件对照与语义

资源v1 路径/文件v2 文件语义要点(面试重点)
CPU 带宽cpu.cfs_quota_us + cpu.cfs_period_uscpu.max("$MAX $PERIOD")cpu.max = 50000 100000 表示每 100 ms 最多用 50 ms CPU,即 0.5 核;max 100000 为不限。它限的是带宽(总额),不是核数
CPU 权重cpu.shares(默认 1024)cpu.weight(默认 100,范围 1~10000)只在争抢时按比例分配,空闲时可用超配额;只影响相对份额
内存上限/当前memory.limit_in_bytes/memory.usage_in_bytesmemory.max/memory.current/memory.events触顶即触发该 cgroup 内的 OOM killer(memory.oom.group=1 时整组一起杀);current 含 page cache,故「逼近上限」不一定危险;判断「是否曾经触顶」要看 events 里的 max/oom/oom_kill
块设备 I/Oblkio.throttle.read_bps_device 等io.max("MAJ:MIN rbps= wbps= riops= wiops=")v2 用设备号定位;限的是该设备上的吞吐/IOPS,不区分优先级
进程数pids.maxpids.max + pids.current防 fork bomb;注意它计的是任务数(含线程)
1
2
3
4
5
6
# 先确认在哪棵树:单行 = v2,多行 = v1;由此决定后面读哪些文件
cat /proc/self/cgroup        # 0::/  ← 0:: 是 v2 单一层次的标志
cat /sys/fs/cgroup/cpu.max   # 例如 150000 100000,换算成"几核":
awk '{if ($1=="max") print "不限"; else printf "%.2f 核\n", $1/$2}' /sys/fs/cgroup/cpu.max
grep nr_throttled /sys/fs/cgroup/cpu.stat   # 非 0 说明应用确实撞过配额墙
grep -E '^(max|oom|oom_kill) ' /sys/fs/cgroup/memory.events   # 是否触顶过

10.6.2 容器里的 CPU 核数为什么与配额不一致

这是容器化部署最经典的坑:配额(cpu.max)与可见核数(nproc、/proc/cpuinfo、sysconf(_SC_NPROCESSORS_ONLN))是两个独立来源。前者来自 cgroup 的带宽限制,后者来自内核暴露的 CPU 拓扑(默认不隔离 CPU 集合,因此通常等于宿主机核数)。于是 nproc 在 64 核宿主机、配额 2 核的容器里仍输出 64;JVM 在 JDK 8u191 之前完全忽略 cgroup 配额,按宿主机核数与物理内存设置默认 GC 线程数、ForkJoinPool 并行度与 -Xmx(默认物理内存 1/4),于是「一个 Java 进程起手几十个 GC 线程、几个 G 堆」,随即被 memory.max OOM 或被 cpu.max 持续限流;JDK 10+ 默认 -XX:+UseContainerSupport,JDK 8 需 8u191+ 并显式开启,或直接给 -XX:ActiveProcessorCount=N。

1
2
3
nproc; python3 -c 'import os; print(os.cpu_count())'   # 复现"核数 ≠ 配额":都是 64(拓扑口径)
cat /sys/fs/cgroup/cpu.max             # 200000 100000 → 实际只有 2 核的带宽
grep nr_throttled /sys/fs/cgroup/cpu.stat   # 跑满线程后该计数持续增长即为撞配额

重要

面试标准答法分三层:其一,配额与核数是两个独立机制(带宽限制 vs CPU 集合视图);其二,运行时读取的是后者,所以线程池/GC 线程数会被放大;其三,修法是显式声明(-XX:ActiveProcessorCount、线程池固定大小、Kubernetes 里配 GOMAXPROCS/automaxprocs 等自动适配手段),而不是把配额调大——配额本来就是要限流的。

10.6.3 namespace 八类隔离与「容器是被限制的进程」

表 10-7 八类 namespace(man 7 namespaces)

namespaceclone 标志隔离对象典型可见效果
MountCLONE_NEWNS挂载点视图容器内 / 是镜像的根文件系统
PIDCLONE_NEWPIDPID 编号空间容器内进程看到自己是 PID 1;宿主机上另有真实 PID
NetworkCLONE_NEWNET网络设备、协议栈、端口、路由、iptables容器有自己的 eth0 与端口空间
IPC/UTSCLONE_NEWIPC/CLONE_NEWUTSSystem V IPC、POSIX 消息队列 / hostname、domainname容器间不能通过共享内存段互访;主机名独立
UserCLONE_NEWUSERUID/GID 映射容器内 root 在宿主机上是普通 UID(rootless 的基础)
Cgroup/TimeCLONE_NEWCGROUP/CLONE_NEWTIMEcgroup 根路径视图 / CLOCK_MONOTONIC、BOOTTIME 偏移容器只看到自己的 cgroup 路径;支持「每容器不同启动时间」的快照恢复

这八类解决「看见什么」,cgroup 解决「能用多少」。合起来就是容器的完整定义:容器不是轻量虚拟机,而是一组共享同一个内核、被打上 namespace 视图与 cgroup 配额的普通进程。由此推出三个高频结论:容器内没有独立内核,内核漏洞即逃逸面;/proc/cpuinfo、free、nproc 这类宿主视角的数据源不会自动被配额修正;容器内 PID 1 承担 init 职责(回收孤儿、转发信号),所以 --init 不是可选项而是健壮性要求。

10.6.4 docker exec 与 nsenter 的原理差异

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  docker exec:经守护进程 + 运行时,走完整的执行与安全栈
    客户端 → dockerd → containerd → runc 的 "exec" 路径
      ├─ 目标:容器 init 的 namespace 集合(从容器元数据读取)
      ├─ 进入:setns() 逐类进入 + 设置 cgroup 归属
      ├─ 附加:应用容器的 seccomp/AppArmor/SELinux、注入配置的环境变量、选用户
      └─ 结果:受容器安全策略约束;依赖守护进程;无法用于非容器进程
  nsenter:直接对已有 PID 调用 setns(),工具本身做主
    nsenter -t <PID> -m -u -i -p -n [-U --setuid --setgid]
      ├─ 只做两件事:setns() 进入指定 namespace + exec 目标程序;不读容器元数据、
      │  不应用 seccomp/AppArmor、环境变量默认取调用者的
      └─ 结果:宿主 root(或 CAP_SYS_ADMIN)即可用、不依赖 dockerd,因而在容器
                【没有】调试工具的 distroless 镜像里特别有用

警告

用 nsenter 排障最常见的误判:只加了 -n(网络)却以为进了完整容器,于是 ps 看到的全是宿主机进程,得出「容器里没有进程」的荒谬结论——必须显式列出所需的 -m -u -i -p -n。另外 -t 要给宿主机视角的 PID(docker inspect -f '{{.State.Pid}}' <容器>),容器内看到的 1 在宿主上不是 1。

本章小结

关键结论一句话表述常见错误
S 与 D 的本质差别唤醒条件不同:S = 事件 ∪ 信号,D = 只有事件答成「深度不同 / 优先级不同」
kill -9 与 D 态信号只挂账(TIF_SIGPENDING),事件完成后才被处理答成「信号杀不死不可中断睡眠」而不说清挂账机制
%CPU 口径top 默认是自启动以来的均值;区间口径要用 -I/pidstat拿均值否认短时尖峰的存在
RSS/PSS 的分摊RSS 对共享页重复计账;PSS 按映射者数均摊,Σ PSS ≈ 物理占用直接累加 RSS 当作内存占用
stat 计数器utime/stime 是节拍数需除 HZ;nvcsw/nivcsw 必须取增量用累计值判断当下瓶颈
信号可靠性与 SIGPIPESIGRTMIN 之前按位合并、之后按值排队;SIGPIPE 应忽略并改查 EPIPE答「不可靠信号会丢」;只在 write 外面加重试不管信号
ulimit 生效层次PAM 会话(limits.conf)≠ systemd(LimitNOFILE)≠ 容器(--ulimit)改了 limits.conf 就以为 systemd 服务生效
配额 vs 核数cpu.max 是带宽限制,nproc 来自拓扑视图,两者无关认为「限制 2 核后 nproc 就是 2」
容器本质namespace 决定看得见什么,cgroup 决定能用多少,共享同一内核答「容器是轻量虚拟机」

自测题

Q1(判断+推导)某服务的进程在 ps 中显示状态 D,运维执行 kill -9 <pid> 后进程仍然存在,且 kill 命令本身不报错。请解释这两件事为什么同时成立,并说明这个进程最终会怎样结束。

答案与解析

两件事同时成立,是因为 kill 只负责投递,不负责唤醒。kill -9 成功返回只表示信号已被记入目标任务的未决集(TIF_SIGPENDING,可在 /proc/PID/status 的 ShdPnd 位图上看到对应位被置位),并不表示任务已终止。D 态的唤醒条件是事件本身(I/O 完成、锁释放、驱动回调),信号不在集合里,任务不会被唤醒去执行终止动作;事件完成后状态回到 R,挂账的信号在返回用户态前被处理——即「信号挂账,等人放行」。错误回答:把 D 解释成「深睡眠、优先级低」;说「kill -9 对 D 态无效」却不解释挂账(面试官立刻追问「那信号去哪了」);把 D 与 Z 混为一谈(Z 已终止、根本没有执行流,见第 4 章 4.6 节)。面试官追问三层:其一,怎么确认它真的在 D 而非采样恰好落在窗口内(wchan//proc/PID/stack 是否持续为同一内核函数);其二,磁盘 I/O 卡住怎么办(修根因:换盘、修 NFS 挂载参数、看 iostat 的 await);其三,内核为什么不干脆让 SIGKILL 打断一切(存在语义上不可半途而废的内核操作,强行打断会让上层拿到错误的「完成」结论;内核为此另设 TASK_KILLABLE,只响应致命信号且在安全点检查)。

Q2(计算)某进程映射了一个 4000 页的共享库,该库被另外 4 个进程同时映射(共 5 个进程);该进程另有 600 页私有匿名页、200 页私有文件映射页(仅自己映射)。页面大小 4 KiB。求 VSZ 的下界、RSS、PSS、USS,并说明哪一个指标回答「杀掉它能释放多少内存」。

答案与解析

RSS = 4000 + 600 + 200 = 4800 页 = 19200 KiB ≈ 18.75 MiB(共享页每个映射者各记满一份);PSS = 4000/5 + 600 + 200 = 1600 页 = 6400 KiB ≈ 6.25 MiB(共享页按映射者数量均摊,5 个进程合计仍只记 4000 页);USS = 600 + 200 = 800 页 = 3200 KiB ≈ 3.125 MiB(只算独占页)。VSZ 只能给下界:≥ 上述映射之和,因为它还包含已映射但从未触碰、尚无物理页的区域(malloc 预留未写的大块、线程栈保留区)。「杀掉它能释放多少」应看 USS;更严谨的说法是「立即可释放量 ≈ Private_Dirty + Private_Clean」(脏页还要等回写)。常见错误:用 RSS 回答(含 5 方共享的库页,高估);用 PSS 回答(仍含分摊给自己的共享部分,且杀不杀都不释放);把分摊分母写成「所有进程数」而非「映射它的进程数」。面试官会追问:为什么容器里 Σ RSS 能超过 memory.max 很多倍(RSS 重复计账,OOM 判定看的是 cgroup 计费内存);为什么 smaps 不该进高频监控(单次读取可达几十毫秒)。

Q3(辨析)某 Java 服务的容器被限制为 cpu.max = 200000 100000(2 核带宽),但容器内 nproc 输出 64,应用线程池默认开了 64 × 2 个线程,且 cpu.stat 里的 nr_throttled 持续增长。请解释这条因果链,并给出两种修法。

答案与解析

因果链是三层机制错配:cpu.max = 200000 100000 表示每 100 ms 周期最多消费 200 ms CPU 时间,即 2 核的带宽配额;而 nproc 读的是内核暴露的 CPU 拓扑视图(默认不隔离 CPU 集合),在 64 核宿主机上就是 64。运行时(线程池、GC 线程数、ForkJoinPool.commonPool)按后者决定并发度,于是申请了远超配额的并行工作;这些线程每周期很快耗尽 200 ms 带宽,其余时间被强制**节流(throttle)**到下个周期,表现为 nr_throttled 持续增长、P99 恶化而平均 CPU 使用率看起来「没跑满」。两种修法:其一,显式声明并发度——线程池按配额固定(如 核数 × (1 + 等待时间/计算时间)),JVM 用 -XX:ActiveProcessorCount=2、Go 用 GOMAXPROCS 或 automaxprocs;其二,让拓扑视图本身变小——cpuset 固定核、--cpuset-cpus 或 Kubernetes 静态 CPU 管理策略。错误回答:「把 cpu.max 调大就好」——配额本就是为了限流,调大是回避问题;或认为「限制 2 核后 nproc 自然就是 2」。面试官会追问:为什么 nr_throttled 比 %CPU 更能说明问题(%CPU 是周期平均值,会掩盖节流造成的延迟尖峰);JDK 8 与 JDK 10+ 的差异(前者默认忽略 cgroup 配额,8u191 起引入 UseContainerSupport);内存配额是否有同类陷阱(有:默认 -Xmx 取物理内存 1/4,容器里不设就会超过 memory.max 被 OOM,见第 12 章 12.7 节)。

Q4(设计)现有一个由 systemd 拉起的服务,代码里 open 日志文件时偶发 EMFILE。运维已经在 /etc/security/limits.conf 里为服务账号设置了 nofile 65535,重启服务后问题依旧。请给出完整的排查顺序,并说明为什么 limits.conf 没有生效。

答案与解析

limits.conf 通过 PAM 的 pam_limits 模块应用,只对经由 PAM 登录的会话(ssh、login、su -)生效。systemd 是 PID 1,自己 fork/exec 服务进程,不经过 PAM,因此服务继承的是 systemd 的限制(常见 1024:524288,或 unit 里显式写的 LimitNOFILE)。排查顺序是「先看事实,再看来源,最后看是不是泄漏」:① grep 'Max open files' /proc/$(pgrep -f myapp)/limits 拿到软/硬两个数;② systemctl show myapp -p LimitNOFILE -p LimitNPROC 定位来源(含 drop-in);③ 改在正确的层:systemctl edit myapp 加 LimitNOFILE=65535 后 daemon-reload && restart;④ 确认 /proc/sys/fs/nr_open ≥ 目标值;⑤ 区分限制与泄漏:ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head 看 fd 类型分布,ls /proc/PID/fd | wc -l 单调增长即泄漏,长期贴上限则确为限制;⑥ 容器场景读 /proc/1/limits 与 --ulimit。错误回答:反复重启、在启动脚本里加 ulimit -n 65535(只影响该 shell 的子进程,且无法提高硬限制,硬限制本身就是 1024 时这行完全无效)。面试官会追问:为什么 ulimit -n 能改小不能改大(非特权进程不可提高硬限制,需 CAP_SYS_RESOURCE);select 的 FD_SETSIZE 与 RLIMIT_NOFILE 是什么关系(前者是编译期常量,与后者无关,见第 7 章 7.5 节)。

Q5(设计)宿主上有一个 distroless 镜像启动的容器(镜像内没有 sh/bash),现在需要排查容器内的网络问题。请说明你会用 docker exec 还是 nsenter,写出可执行命令,并指出这个选择在权限与可见性上的代价。

答案与解析

必须用 nsenter:docker exec 需要一个能在容器内执行的程序,而 distroless 镜像里没有 shell 与常用工具,docker exec myapp sh 会直接失败。

1
2
3
4
# 取容器 init 在【宿主机视角】的真实 PID(容器内看到的 1 不是它)
PID=$(docker inspect -f '{{.State.Pid}}' myapp)
# 进入目标进程的 mount/user/ipc/pid/net 视图后,再执行宿主上的诊断工具
nsenter -t "$PID" -m -u -i -p -n ss -lntp

代价有两处:权限上,nsenter 不继承容器的 seccomp/AppArmor/capabilities,是在宿主机权限下进入视图,必须由持有宿主 root 的人执行并留审计;可见性上,必须显式列出所需 namespace,只给 -n 会得到「宿主的进程 + 容器的网络」这种混合视图,极易误判。

错误回答:说「用 docker exec 装一个 curl 进去」(distroless 无法安装);或说「nsenter 更方便所以优先用它」而不提权限放大与审计要求。面试官会追问:-t 该给哪个 PID(宿主机视角的 PID,不是容器内的 1);容器 PID 1 已崩溃只剩僵尸怎么办(namespace 仍在,只要有存活的同 namespace 进程即可进入);为什么 nsenter -p 之后还要 exec 新进程才能看到容器的 PID 视图(PID namespace 只影响之后创建的子进程,与第 4 章 4.4 节 CLONE_NEWPID 语义一致)。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
kill -9 一定能杀死进程吗?杀不掉的情况有哪些?进程状态机与特权进程语义只答「一定能,杀不掉就是权限问题」。须答出四类:D 态信号挂账、Z 态无执行流、PID 1 受内核保护(只投递它自己装过 handler 的信号)、所有线程都在 D 态则无接收者10.1.3
为什么 D 状态不能和 S 状态合并成一个「睡眠态」?睡眠语义与负载统计答「深睡眠/浅睡眠、优先级不同」。须答出唤醒条件集合不同(事件 ∪ 信号 vs 只有事件)、内核存在不可半途而废的关键路径、负载统计把 D 计入而 S 不计10.1.1
ps 里的 VSZ 有几十 GB,是不是内存泄漏了?多个进程共享一个 libc.so,它的内存该算在谁头上?虚拟/物理内存口径与共享页分摊答「是泄漏,赶快重启」或「算共享内存,不占进程内存」。VSZ 含未触碰的映射,判断泄漏要看 RSS/PSS 的趋势;共享页规则是 RSS 重复计账、PSS 按映射者数均摊、USS 完全排除,故 Σ RSS 会超过物理内存10.2.1
top 显示的 %CPU 是瞬时值还是平均值?/proc/PID/stat 里的 utime 单位又是什么?统计口径与计数器口径答「当然是瞬时的」「毫秒或秒」。top 默认是自启动以来的均值,Irix 模式(I)或 pidstat 才是区间值;utime 是时钟节拍数,须除以 getconf CLK_TCK(通常 100)10.2.2、10.3.1
为什么信号处理函数里不能用 printf 和 malloc?异步信号安全与重入答「不安全,可能崩」。须说清机制:handler 可打断任意代码,被中断者可能正持 stdio/分配器的锁,同线程非递归锁自锁 → 永久挂起;正确形态是设 sig_atomic_t 标志或 self-pipe/signalfd10.4.1
往一个已经被对端关闭的 socket 写数据会发生什么?SIGSEGV 和 SIGBUS 又有什么区别?SIGPIPE 机制与映射有效性答「直接返回错误」「都是内存访问错误,差不多」。实际是先产生 SIGPIPE,默认动作是终止进程,应忽略它并改用 EPIPE 判断;SEGV/BUS 的判据是页表里有没有合法映射:有映射但违规 → SIGSEGV,映射不存在或不满足对齐 → SIGBUS10.4.2
我已经把 ulimit -n 调到 65535 了,为什么服务还是报 too many open files?RLIMIT_NPROC 又是按什么统计的?限制的生效层次与统计口径答「ulimit 没重启生效」「按容器的进程数」。真实层次是 limits.conf(只对 PAM 会话)→ systemd LimitNOFILE → 容器 --ulimit,且非特权进程不能提高硬限制;RLIMIT_NPROC 统计同一真实 UID 的进程/线程总数,不按 cgroup10.5.1、10.5.2
cgroup v1 和 v2 最大的区别是什么?cpu.max = 50000 100000 这个容器有几核?容器里跑 Java,为什么线程池和 GC 线程数会开得过多?层次模型、CPU 配额语义与拓扑视图错配答「v2 更新更快」「0.5 核所以 nproc 是 0.5」「Java 不知道容器限制」。核心差异是 v1 每控制器一棵独立树、v2 共用一棵统一树;cpu.max 是带宽配额而与 CPU 集合视图无关,nproc 仍显示宿主机核数,运行时读的正是后者(JDK 8u191 前完全忽略 cgroup),故线程数被放大,修法是 -XX:ActiveProcessorCount 或 cpuset10.6.1、10.6.2
docker exec 和 nsenter 有什么区别?什么时候必须用后者?容器到底是虚拟机还是进程?namespace、运行时栈与容器本质答「一样,nsenter 就是底层」「轻量级虚拟机」。docker exec 走运行时并继承 seccomp/AppArmor,nsenter 直接 setns 且不继承安全策略;容器是共享宿主内核的普通进程,namespace 决定看见什么、cgroup 决定能用多少10.6.3、10.6.4

延伸阅读

  • Linux 手册页:proc(5)(/proc/[pid] 全字段权威定义)、namespaces(7)、cgroups(7)、signal(7)、signal-safety(7)(异步信号安全函数清单)、getrlimit(2)、setns(2)、clone(2);systemd.exec(5) 的 LimitNOFILE= 等指令与 pam_limits(8)(limits.conf 的适用会话)。
  • 内核文档 Documentation/admin-guide/cgroup-v2.rst(v2 文件语义与 cpu.max/io.max 格式)与 Documentation/admin-guide/cgroup-v1/(v1 对照)。
  • 内核源码路径:kernel/sched/core.c(TASK_* 状态与 signal_pending 检查点)、include/linux/sched.h(TASK_KILLABLE 定义)、fs/proc/array.c(stat 字段拼装顺序,用于校验字段序号)、fs/proc/task_mmu.c(smaps 与 Pss 计算)。
  • 第 4 章 4.6 节(僵尸与 SIGCHLD 的回收责任)、第 9 章 9.8 节(信号三态与异步信号安全)、第 11 章 11.2 节(vmstat 的 r/b 与平均负载)、第 12 章 12.7 节(cgroup 内存限制下的 OOM)。