面试 · 原理追问型
考察目标:对底层机制的理解深度 13 题
题目1:rm 命令到底做了什么?为什么删大文件很快?#
原理:rm 实际上调用 unlink() 系统调用,只做两件事:
- 从目录中删除文件名 → inode 的映射(目录项)
- inode 的链接计数减 1
当链接计数降为 0 且没有进程打开该文件时,内核异步回收 inode 和数据块。所以删大文件"看起来很快",因为只删了元数据,数据块的实际回收是内核后台完成的。
验证:strace rm testfile 2>&1 | grep unlink 可以看到调用的就是 unlink。
题目2:为什么 kill -9 不能杀 D 状态进程?#
原理:D 状态(Uninterruptible Sleep)是进程在内核态等待 IO 完成的状态。此时进程在内核空间中执行,不处理任何信号——信号被挂起到信号队列中,直到系统调用返回用户态时才处理。如果 IO 永不返回(如 NFS 服务器宕机),进程就永远卡在 D 状态。
设计意图:IO 操作必须是原子的——在执行磁盘写操作时不能被信号中断,否则会导致数据不一致。
验证:ps aux | awk '$8~/D/' 找到 D 状态进程 → cat /proc/<pid>/wchan 看等待的内核函数。
题目3:Linux 的 Page Cache 是怎么工作的?为什么 free 显示的 used 很高但系统不慢?#
原理:Linux 内存管理采用"不用白不用"策略——空闲内存自动作为 Page Cache 缓存文件内容。当应用需要内存时,系统回收冷 Page Cache 给应用使用。所以 free -h 中的 buff/cache 高是好事(说明系统充分利用了内存)。
回收优先级(从低到高):
- 未修改的 Page Cache → 直接丢弃(最快)
- 已修改的 Page Cache(脏页)→ 先写回磁盘再丢弃
- Swap 出匿名页 → 最慢
验证:echo 3 > /proc/sys/vm/drop_caches 强制释放缓存后 free 数字会变好看,但磁盘 IO 会飙升(因为缓存没了)。
题目4:fork() 和 exec() 有什么区别?进程创建的过程是怎样的?#
原理:
fork():创建子进程,拷贝父进程的内存空间(Copy-on-Write 优化,真正修改时才拷贝)、文件描述符表等。fork 一次返回两次:父进程返回子进程 PID,子进程返回 0。
exec():用新程序替换当前进程的内存空间,PID 不变。exec 成功后不返回(因为代码已被替换)。
典型流程:Shell 执行命令时先 fork 一个子进程,子进程再 exec 新程序。
运维影响:这是进程启动的基本机制。僵尸进程(Z 状态)就是因为父进程 fork 了子进程但没 wait 回收。copy-on-write 让 fork 高效,但内存大的进程 fork 仍然消耗大(需要复制页表)。
题目5:Linux 内核 OOM Killer 怎么决定杀哪个进程?#
原理:
OOM Killer 的核心是 oom_score(/proc/<pid>/oom_score),值越高越容易被杀。现代内核(2.6.36 起)算法已大幅简化,基本只看内存占比:
- 内存占用:
oom_badness≈ (RSS + Swap + Page Table) / 节点可用内存,占比越大分越高(占满约 1000 分) - 手动调整:
oom_score_adj(-1000 到 1000)直接叠加,-1000 = 免杀 - 唯一"身份"折扣:
CAP_SYS_ADMIN进程约减 3% - 别再背旧知识:2.6.36 之前那套"运行越久/nice 越低/root 进程更安全"的复杂启发式早已被删除,现在跟运行时长无关
K8s 中的 OOM 是双重机制:
- cgroup 内存超过 limit → 内核 OOM Kill 该 cgroup 内进程
- 节点内存不足 → kubelet 驱逐 BestEffort/Burstable Pod
验证:dmesg | grep -i "killed process" 看杀过谁,cat /proc/<pid>/oom_score 评估风险。
题目6:软链接和硬链接的底层实现有什么区别?#
原理:
硬链接:在目录中新增一个目录项,指向同一个 inode。inode 的链接计数 i_nlink 加 1。本质上是"同一个 inode 有了多个名字"。删除只是 unlink() 让计数减 1。stat 命令的 Links 就是链接计数。
软链接:创建了一个新的 inode,类型为 S_IFLNK(符号链接),内容是目标路径字符串(不解析)。打开软链接时内核根据路径找到真正的 inode。如果目标不存在,open() 返回 ENOENT。
为什么硬链接不能跨文件系统:因为 inode 是文件系统级别的标识,不同文件系统的 inode 编号独立管理。软链接存路径字符串,路径是全局的(相对于挂载点)。
题目7:Linux 的 IO 栈是怎样的?一次 write() 经过哪些层?#
原理:
用户空间:write(fd, buf, count)
↓ 系统调用
VFS 层(Virtual File System):统一接口
↓
文件系统层(ext4/xfs):将文件偏移转为磁盘块号
↓
Page Cache:先写内存缓存(write-back 模式)
↓ (脏页后台回写:现代内核为 per-bdi writeback 线程;pdflush/bdflush 早在 2.6.32 被移除)
Block 层:IO 调度器(mq-deadline/bfq)排序合并
↓
设备驱动:NVMe/SCSI/SATA 驱动
↓
硬件:磁盘/SSD延迟分布:Page Cache 写入(微秒级) → fsync()/O_DIRECT(毫秒级) → 磁盘寻道(如果是 HDD,10ms+)。
运维关键:fsync() 强制刷盘保证持久性但性能差。数据库通常用 O_DIRECT 绕过 Page Cache 自己做缓存。
题目8:Linux 的信号处理机制是怎样的?为什么有些信号不可捕获?#
原理:
信号处理流程:
- 内核将信号添加到进程的
pending位图 - 进程从内核态返回用户态前检查 pending 信号
- 如果有待处理信号 → 执行信号处理函数(或默认行为)
- SIGKILL(9) 和 SIGSTOP(19) 特殊:内核直接处理,不经过 pending 队列,不可被捕获/忽略
为什么 SIGKILL 不可捕获:这是一个"最后手段"的保证——如果所有信号都可被捕获,恶意或无响应的程序可以把自己变成杀不死的状态。
验证:trap "echo 'caught SIGTERM'" SIGTERM 可以捕获大部分信号,但 trap '' SIGKILL 无效。
追问 1:进程被信号杀死,退出码是多少? 约定 128 + 信号号:被 SIGKILL(9) 杀 → 137;SIGTERM(15) → 143;SIGINT(2)/Ctrl+C → 130;SIGSEGV(11) → 139。所以 shell/容器里看到 Exit Code 137 基本就是"被 KILL 了"(K8s 中常见于 OOMKilled 或超过 grace period 被强杀)。
追问 2:僵尸(Z)和孤儿进程怎么处理? 僵尸是子进程已退出、父进程没 wait() 回收退出状态的残留(只剩 task_struct,不占内存/CPU,但占用 PID)——kill -9 杀不掉僵尸本身,要杀它的父进程,让 init/systemd(PID 1) 接管并回收。孤儿是父进程先死、子进程仍活着,会被 PID 1 自动"收养"(re-parent),由 PID 1 负责最终 wait(),所以孤儿不会变僵尸。
题目9:df 和 du 数据不一致的底层原因是什么?#
原理:
df 读取文件系统的超级块(superblock),统计的是文件系统级别的空间使用(已分配的 inode 和数据块)。du 遍历目录树,只统计有路径名的文件的大小。
三个导致不一致的底层原因:
- 已删除但被打开的文件(open-but-deleted):
unlink()删了目录项,但进程持有 fd,inode 和数据块没释放。df 算,du 不算 - 挂载点覆盖:
/mnt上挂载了新文件系统,/mnt下的原有数据被"遮住",df 看当前挂载的空闲空间,du 看到的是覆盖前的内容 - 稀疏文件(sparse file):文件逻辑大小 > 实际占用块数。
du显示实际占用,ls -l显示逻辑大小
验证:创建一个 1GB 的稀疏文件,dd if=/dev/zero of=sparse bs=1 count=1 seek=1G,ls -lh 显示 1G,du -h 显示几 KB。
题目10:systemd 的 Unit 依赖关系是怎么解析的?为什么能并行启动?#
原理:
systemd 将每个 Unit 的依赖关系(Requires/Wants/After/Before)建模成有向无环图(DAG)。启动时拓扑排序,找出所有入度为 0 的节点并行启动。
关键依赖类型:
Requires=: 强依赖,被依赖的启动失败,自己也失败Wants=: 弱依赖,被依赖的启动失败不影响自己After=/Before=: 仅控制启动顺序,不创建依赖关系BindsTo=: 绑定,被依赖的停止,自己也停止
并行启动的前提是依赖 DAG 的入度为 0 的节点无顺序要求。配合 socket activation(先创建 socket 再启动服务,启动前收到的请求排队),可以实现真正的按需启动。
验证:systemd-analyze plot > boot.svg 生成启动时间线的可视化 SVG。
题目11:为什么 Linux 的 load average 包含 D 状态进程?#
原理:
load average 的计算是基于task_struct 中处于 TASK_RUNNING(R) 和 TASK_UNINTERRUPTIBLE(D) 状态的进程数,取指数加权移动平均(1/5/15 分钟)。
设计意图:D 状态进程虽然不占 CPU,但它阻塞了需要 IO 的应用线程。从用户角度看,IO 慢和应用慢体验是一样的(都会卡、会超时)。将 D 计入 load 可以反映"系统的整体忙碌程度",而不仅仅是 CPU 忙碌程度。
反例:如果 load 只看 R 状态,服务器磁盘满了 IO 卡住,load 可能是 0.1,运维看了以为系统很闲,实际用户已经炸了。
题目12:cgroup v1 和 v2 的内存控制器有什么区别?#
原理:
cgroup v1 的问题:
- memory 和 CPU 控制器完全独立,分别在不同层级树中
- "no internal process" 限制:非叶子节点不能有进程
- memory.kmem 独立计费导致 slab 内存难以追踪
cgroup v2 的改进:
- 统一层级树,所有控制器共享
- 支持 thread mode(一个 cgroup 的线程可以属于不同控制器子树)
- PSI(Pressure Stall Information):
/proc/pressure/memory提供内存压力的量化指标 - 内存控制器增强:
memory.high(软限制,允许短暂超过但会被节制)、memory.max(硬限制 OOM)
K8s 中 cgroup v2 于 1.25 GA(成为标准路径,但不是强制——v1 仍受支持);v1 已在 1.31 进入维护模式、计划 1.36 彻底移除。好处:PSI 让 kubelet 能更准确地判断节点内存压力,提前驱逐 Pod 而非等 OOM。
题目13:set -euo pipefail 各管什么?为什么 set -e 有时"不生效"?管道退出码怎么算?#
原理:
set -e(errexit):命令返回非 0 立即退出脚本。但有一堆"看起来该退出却没退"的坑(这也是它被诟病的地方):
- 出现在
if、while、||、&&条件位置的命令不触发(因为其返回值正被判断):if grep x f; then …里 grep 失败不会退出 - 函数内、命令替换
$(...)、子 shell 的失败传播规则微妙,容易漏判 a=$(false)这类赋值默认不触发退出
set -u(nounset):引用未定义变量直接报错退出,防 rm -rf "$PREFIX/"($PREFIX 拼错为空 → 删根)这类空变量灾难。
set -o pipefail:默认管道退出码只取最后一个命令——false | true 退出码是 0,前面的失败被吞掉。开启后取管道中最后一个非 0退出码,grep x f | sort 里 grep 失败才能被 set -e 抓住。
追问:cmd1 | cmd2 退出码到底是谁的? 默认 = cmd2 的;Bash 用 ${PIPESTATUS[@]} 保存每一段退出码,echo ${PIPESTATUS[0]} 看第一段。
追问:2>&1 的顺序为什么关键? 重定向从左到右生效,本质是"复制当前指向"。> file 2>&1 = 先把 stdout 指向 file、再让 stderr 复制 stdout(都进 file)✅;2>&1 > file = 先让 stderr 复制"此刻还在终端的 stdout"、再把 stdout 改到 file → stderr 仍打到终端,日志漏掉 ❌。
健壮脚本模板:
#!/usr/bin/env bash
set -euo pipefail
trap 'echo "failed at line $LINENO" >&2; cleanup' ERR # 出错时打点 + 清理
trap cleanup EXIT # 无论如何都清理临时文件
IFS=$'\n\t' # 收窄分隔符,防意外词分割小结#
原理追问考察的是"比会用更进一步"的深度。信号为什么 SIGKILL 不可捕获、load 为什么含 D 状态、inode 为什么不能跨文件系统——这些问题背后都有明确的设计意图和权衡。能说清楚"为什么这样设计"比"记得命令怎么用"高级一个档次。