# 第 10 章 进程、信号与资源限制


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

## 学习目标

读完本章，你应当能够：

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`）、不同状态可能显示成同一个字母；因此读懂状态必须先看内核表示。

```text
  进程状态机（内核表示）与 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 各状态的进入/退出条件与可观测性**

| 状态 | 进入条件 | 退出条件（被谁唤醒） | 信号能否打断 | 典型观测 |
|---|---|---|---|---|
| `R` | `fork`/`wake_up` 后入 runqueue，或正在 CPU 上运行 | 时间片耗尽、被抢占、主动 `schedule()` | 不适用 | `vmstat` 的 `r` 列（见第 11 章 11.2 节） |
| `S` | 等待事件：`read` 无数据、`accept` 无连接、等锁 | 事件到达，**或**收到未被阻塞的信号 | **能**（返回 `EINTR` 或自动重启） | 长跑服务进程的常态 |
| `D` | 等待**不可中断**的内核操作完成 | 只有事件本身（I/O 完成、锁释放、驱动回调） | **不能**，只记录 `TIF_SIGPENDING` | `vmstat` 的 `b` 列 |
| `T` | `SIGSTOP`/`SIGTSTP` 到达，或被 `ptrace` 追踪 | `SIGCONT`；`PTRACE_DETACH` | 不适用 | `gdb attach` 时短暂出现 |
| `Z`/`X` | `Z`：`do_exit` 后只留 `task_struct` 与退出码；`X`：`task_struct` 即将 `free` | `Z` 由父进程 `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 节）。

> [!IMPORTANT]
> 面试要给出的一句话：`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` 长期为同一内核函数 |

> [!WARNING]
> 最常见的错误回答是把 `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` 的具体线程等它出栈 |

```bash
# 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 个进程映射的共享库页，在谁的账上记多少？**

```text
  虚拟地址空间 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
```

```bash
# 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 模式才是「距上次刷新区间」的占用。口径搞错会得到完全相反的结论。

```text
  两种口径的差异（同一进程：启动后先忙 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%（落在突发窗口）⟹ 间隔越大，短时尖峰越容易被
  平均掉；只看瞬时值的工具甚至完全错过尖峰
```

```bash
# 区间口径：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
```

> [!TIP]
> 排障话术：「服务 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` 便宜得多，高频监控**应当读它**）。

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

`nvcsw`/`nivcsw` 的增量比例是最被低估的诊断信号（**必须取窗口内增量，累计值没有意义**）：

```text
  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）与亲和性
```

```bash
# 取两次增量再相减：进程可能已跑了几十天，累计值无法反映当下
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_rollup` | `statm` 给 7 个页数；`smaps` 逐映射区给 `Rss/Pss/Private_*/Shared_*`、`VmFlags` | `smaps` 极高（可达几十 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` |

> [!CAUTION]
> `/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)` | 终止 + core | `SIGSEGV`：地址**有合法映射但访问违规**（空指针、写只读页、越界）；`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` 之前，编号 1~31）在未决位图上**按位合并**，「产生过几次」无法还原；实时信号（`SIGRTMIN`~`SIGRTMAX`，通常 34~64）按值排队，同种信号可多次未决，`sigqueue` 还能携带 `union sigval` 载荷。需要计数就用实时信号，或干脆不用信号。
- **生产代码倾向于把信号转成 fd。** `signalfd` 让信号成为可 `read` 的事件源，与 epoll 统一处理；`pidfd`（5.3+）把「等待子进程」变成对一个 fd 的 `poll`，绕开 `SIGCHLD` 合并与 PID 复用问题。

```bash
# 三个十六进制位图，位 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` KiB | `RLIMIT_AS` 限的是 `VSZ`，设小了 JVM 直接起不来；栈溢出报 `SIGSEGV`，且**线程栈不受 `RLIMIT_STACK` 限制**（由 `pthread_attr_setstacksize` 决定） |
| `RLIMIT_CORE`/`RLIMIT_MEMLOCK` | core 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 及其后续子进程**的限制，真实生效值取决于服务的启动路径；父进程传给子进程时**软/硬限制一并继承**。

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

```bash
# 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
```

> [!NOTE]
> 一个易被忽略的联动：`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 的差异

```text
  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_us` | `cpu.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_bytes` | `memory.max`/`memory.current`/`memory.events` | 触顶即触发该 cgroup 内的 OOM killer（`memory.oom.group=1` 时整组一起杀）；`current` 含 page cache，故「逼近上限」不一定危险；判断「是否曾经触顶」要看 `events` 里的 `max`/`oom`/`oom_kill` |
| 块设备 I/O | `blkio.throttle.read_bps_device` 等 | `io.max`（`"MAJ:MIN rbps= wbps= riops= wiops="`） | v2 用设备号定位；限的是**该设备上的吞吐/IOPS**，不区分优先级 |
| 进程数 | `pids.max` | `pids.max` + `pids.current` | 防 fork bomb；注意它计的是**任务数（含线程）** |

```bash
# 先确认在哪棵树：单行 = 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`。

```bash
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   # 跑满线程后该计数持续增长即为撞配额
```

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

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

**表 10-7 八类 namespace（`man 7 namespaces`）**

| namespace | `clone` 标志 | 隔离对象 | 典型可见效果 |
|---|---|---|---|
| Mount | `CLONE_NEWNS` | 挂载点视图 | 容器内 `/` 是镜像的根文件系统 |
| PID | `CLONE_NEWPID` | PID 编号空间 | 容器内进程看到自己是 PID 1；宿主机上另有真实 PID |
| Network | `CLONE_NEWNET` | 网络设备、协议栈、端口、路由、iptables | 容器有自己的 `eth0` 与端口空间 |
| IPC/UTS | `CLONE_NEWIPC`/`CLONE_NEWUTS` | System V IPC、POSIX 消息队列 / `hostname`、`domainname` | 容器间不能通过共享内存段互访；主机名独立 |
| User | `CLONE_NEWUSER` | UID/GID 映射 | 容器内 root 在宿主机上是普通 UID（rootless 的基础） |
| Cgroup/Time | `CLONE_NEWCGROUP`/`CLONE_NEWTIME` | cgroup 根路径视图 / `CLOCK_MONOTONIC`、`BOOTTIME` 偏移 | 容器只看到自己的 cgroup 路径；支持「每容器不同启动时间」的快照恢复 |

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

### 10.6.4 `docker exec` 与 `nsenter` 的原理差异

```text
  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 镜像里特别有用
```

> [!WARNING]
> 用 `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` 必须取增量 | 用累计值判断当下瓶颈 |
| 信号可靠性与 `SIGPIPE` | `SIGRTMIN` 之前按位合并、之后按值排队；`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` 命令本身不报错。请解释这两件事为什么同时成立，并说明这个进程最终会怎样结束。

<details><summary>答案与解析</summary>

两件事同时成立，是因为 `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`，只响应致命信号且在安全点检查）。
</details>

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

<details><summary>答案与解析</summary>

`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` 不该进高频监控（单次读取可达几十毫秒）。
</details>

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

<details><summary>答案与解析</summary>

因果链是三层机制错配：`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 节）。
</details>

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

<details><summary>答案与解析</summary>

`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 节）。
</details>

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

<details><summary>答案与解析</summary>

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

```bash
# 取容器 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` 语义一致）。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| `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`/`signalfd` | 10.4.1 |
| 往一个已经被对端关闭的 socket 写数据会发生什么？`SIGSEGV` 和 `SIGBUS` 又有什么区别？ | `SIGPIPE` 机制与映射有效性 | 答「直接返回错误」「都是内存访问错误，差不多」。实际是先产生 `SIGPIPE`，**默认动作是终止进程**，应忽略它并改用 `EPIPE` 判断；`SEGV`/`BUS` 的判据是页表里有没有合法映射：有映射但违规 → `SIGSEGV`，映射不存在或不满足对齐 → `SIGBUS` | 10.4.2 |
| 我已经把 `ulimit -n` 调到 65535 了，为什么服务还是报 too many open files？`RLIMIT_NPROC` 又是按什么统计的？ | 限制的生效层次与统计口径 | 答「`ulimit` 没重启生效」「按容器的进程数」。真实层次是 `limits.conf`（只对 PAM 会话）→ systemd `LimitNOFILE` → 容器 `--ulimit`，且非特权进程不能提高**硬限制**；`RLIMIT_NPROC` 统计**同一真实 UID 的进程/线程总数**，不按 cgroup | 10.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` 或 cpuset | 10.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）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch10/  

