第 10 章 进程、信号与资源限制
前三章讲了内核怎么切换、怎么被打断;本章讲运维与容器时代真正卡住你的那一层:状态怎么读、信号为什么无效、限制从哪来。
学习目标
读完本章,你应当能够:
- 默画 Linux 进程状态机,说出
R/S/D/T/Z/X各自的进入与退出条件,解释S与D为什么必须分开,并列举产生D态的三类内核路径。 - 论证
kill -9对D态进程「无效但会挂账」的因果链,并说出另外三类杀不掉的进程;推算共享库被 N 个进程映射后的VSZ/RSS/PSS/USS。 - 说明
%CPU是均值还是区间值、top刷新间隔如何改变读数;解读/proc/[pid]/stat的utime/stime/cutime/cstime/nvcsw/nivcsw并按用途选用/proc/[pid]下的关键文件。 - 辨析阻塞与未决、
SIGRTMIN前后的排队语义、处理函数只能用异步信号安全函数的原因,以及SIGPIPE/SIGSEGV/SIGBUS的触发条件。 - 分清
ulimit软/硬限制与limits.conf/systemd/容器三层的生效顺序,解释「改了ulimit -n仍然 too many open files」。 - 对比 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)、不同状态可能显示成同一个字母;因此读懂状态必须先看内核表示。
| |
表 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 节)。
重要
面试要给出的一句话: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 的具体线程等它出栈 |
| |
10.2 ps/top 字段的真实含义
10.2.1 内存四指标:VSZ、RSS、PSS、USS
这四个数不是四个精度级别的同一个量,而是对同一批物理页的四种分摊规则。理解它们的关键是问一句:一个被 3 个进程映射的共享库页,在谁的账上记多少?
| |
| |
表 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 模式才是「距上次刷新区间」的占用。口径搞错会得到完全相反的结论。
| |
| |
提示
排障话术:「服务 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 的增量比例是最被低估的诊断信号(必须取窗口内增量,累计值没有意义):
| |
| |
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 |
小心
/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之前,编号 131)在未决位图上按位合并,「产生过几次」无法还原;实时信号(SIGRTMINSIGRTMAX,通常 34~64)按值排队,同种信号可多次未决,sigqueue还能携带union sigval载荷。需要计数就用实时信号,或干脆不用信号。 - 生产代码倾向于把信号转成 fd。
signalfd让信号成为可read的事件源,与 epoll 统一处理;pidfd(5.3+)把「等待子进程」变成对一个 fd 的poll,绕开SIGCHLD合并与 PID 复用问题。
| |
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 及其后续子进程的限制,真实生效值取决于服务的启动路径;父进程传给子进程时软/硬限制一并继承。
| |
| |
注意
一个易被忽略的联动: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 的差异
| |
表 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;注意它计的是任务数(含线程) |
| |
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。
| |
重要
面试标准答法分三层:其一,配额与核数是两个独立机制(带宽限制 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 的原理差异
| |
警告
用 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 命令本身不报错。请解释这两件事为什么同时成立,并说明这个进程最终会怎样结束。
答案与解析
两件事同时成立,是因为 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 会直接失败。
| |
代价有两处:权限上,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/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)。