硬核挑战:如果说精通 Linux 有段位,这份文档直接拉满宗师级
第一卷:混沌初开 —— 启动、工具链与构建系统 (深度展开版)
本卷目标:在代码还没跑起来之前,理解代码是如何变成二进制,以及二进制是如何被加载并控制 CPU 的。
第一章:从按下电源到 Login Prompt (The Boot Process)
大多数工程师只知道“BIOS -> MBR -> Bootloader -> Kernel”。宗师级需要掌握每一步的寄存器状态和内存布局。
1.1 固件层 (Firmware Phase)
UEFI (Unified Extensible Firmware Interface):
- PE/COFF 格式:UEFI 应用其实就是 Windows 的 PE 格式。
- GPT (GUID Partition Table):放弃 MBR 的 512 字节限制。理解 LBA0 (Protective MBR) 和 LBA1 (GPT Header) 的结构。
- SEC/PEI/DXE/BDS 阶段:理解 UEFI 的初始化阶段,特别是 DXE (Driver Execution Environment),这是为什么现代 BIOS 可以支持鼠标和网络的原因。
- 深度思考:为什么 Secure Boot 需要签名?公钥存在哪?(PK, KEK, db, dbx)。
1.2 引导加载层 (Bootloader - GRUB2)
GRUB2 架构:
- Stage 1 (boot.img):写入 MBR/GPT 前 440 字节,除了加载 Stage 1.5/2 啥也干不了。
- Stage 1.5 (core.img):位于 MBR 和第一个分区之间的空隙。包含文件系统驱动 (ext4/xfs driver),否则无法读取
/boot。 - Stage 2:读取
/boot/grub/grub.cfg,加载内核镜像 (vmlinuz) 和初始内存盘 (initrd/initramfs) 到内存。
1.3 内核初始化层 (Kernel Initialization)
这是源码分析的起点:arch/x86/boot/header.S。
- 实模式到保护模式:CPU 从 16-bit Real Mode 切换到 32-bit Protected Mode,再到 64-bit Long Mode。开启 A20 地址线,设置 GDT (Global Descriptor Table)。
- 解压内核:
vmlinuz是压缩的 (zImage/bzImage)。内核先自解压 (arch/x86/boot/compressed/head_64.S)。 start_kernel():
init/main.c中的上帝函数。setup_arch(): 探测 CPU 特性,解析内存分布 (e820 map)。mm_init(): 初始化内存分配器 (Buddy System)。sched_init(): 初始化调度器。rest_init(): 创建 PID 1 (init) 和 PID 2 (kthreadd)。
1.4 用户空间初始化 (User Space Init)
Initramfs (Initial RAM Filesystem):
- 它是一个 CPIO 格式的归档文件。
- 目的:加载挂载根文件系统 (RootFS) 所需的驱动 (如 LVM, RAID, SCSI 模块)。
- 实验:使用
lsinitrd或解压initramfs查看里面的 shell 脚本 (init)。
Systemd (PID 1):
- 并发启动:Socket Activation 原理。
- 依赖树:Requires, Wants, After, Before。
- Cgroups 挂载:Systemd 将系统资源划分为 slice (System, User, Machine)。
第二章:工具链与 ELF 格式 (The Toolchain)
Linux 系统中没有任何魔法,一切皆由工具链构建。
2.1 编译全链路 (GCC Internals)
命令:gcc -v -save-temps main.c
- CPP (C Pre-Processor):处理
#include,#define。所有宏在这一步消失。 CC1 (C Compiler):最核心部分。
- AST (Abstract Syntax Tree):将 C 代码解析为树状结构。
- GIMPLE/RTL:GCC 的中间表示 (IR)。优化 (O2/O3) 主要发生在这里(死代码消除、循环展开)。
- Assembly:输出
.s汇编文件。
- AS (Assembler):将汇编转为机器码 relocatable object (
.o)。 - Collect2 / LD (Linker):将多个
.o和库文件链接成最终可执行文件。
2.2 ELF 文件深度解剖 (Executable and Linkable Format)
这是理解操作系统加载程序的基石。必须精通 readelf 和 objdump。
- ELF Header:Magic Number (
7F 45 4C 46), 入口地址 (Entry Point)。 Program Headers (Segments):告诉内核如何把文件映射到内存。
LOADSegment: 哪些部分需要加载 (R, RW, RX)。
Section Headers (Sections):链接时使用。
.text: 代码段 (Read-Exec)。.data: 已初始化的全局变量 (Read-Write)。.bss: 未初始化的全局变量 (占位符,不占磁盘空间)。.rodata: 只读数据 (字符串常量)。
符号表 (Symbols):
readelf -s。理解 Function, Object, Weak, Strong 符号。- Name Mangling:C++ 中函数重载如何在符号表中区分。
2.3 动态链接机制 (Dynamic Linking)
这是 Linux 运行时的核心机制。
PLT & GOT (延迟绑定技术):
- 为什么调用
printf时,程序不知道printf的真实地址? - GOT (Global Offset Table):存放绝对地址的数据表。
- PLT (Procedure Linkage Table):一段跳转代码。
- 过程:第一次调用 -> 查 PLT -> 跳到 GOT (此时为空) -> 回跳 PLT -> 呼叫 Dynamic Linker (
_dl_runtime_resolve) -> 找到真实地址填入 GOT -> 执行。第二次调用 -> 查 PLT -> 跳到 GOT (已有地址) -> 直接执行。
- 为什么调用
- Interpreter:
/lib64/ld-linux-x86-64.so.2。是它在程序运行前把依赖库 (libc.so) 加载进内存的。
第三章:Shell 解释器与系统编程基石
Shell 不是一个简单的应用,它是用户与内核交互的最简接口。
3.1 进程创建模型
Fork-Exec 悖论:
fork(): 复制当前进程(包括打开的文件描述符、内存、环境变量)。execve(): 丢弃当前进程内存,加载新的 ELF。- 为什么不直接
CreateProcess?因为 Fork 允许在 Exec 之前修改子进程配置(如重定向文件描述符、改变信号掩码)。
- 写时复制 (COW):解释为什么
fork一个 64GB 内存的进程只需要几毫秒。(只复制了页表,并将 PTE 标记为 Read-Only)。
3.2 管道与重定向的内核实现
文件描述符 (FD):
- 它只是
task_struct -> files_struct -> fd_array数组的索引。
- 它只是
重定向 (
>):- 本质是
dup2(fd_file, 1)。将文件描述符 1 (Stdout) 的指针指向了新打开的文件结构体。
- 本质是
管道 (
|):- 命令:
cmd1 | cmd2 - 内核创建了一个无名管道 (Pipe),本质是环形缓冲区 (Ring Buffer)。
- Shell 执行
fork两次。 - 子进程 1:
dup2(pipe_write_end, 1)(Stdout -> Pipe)。 - 子进程 2:
dup2(pipe_read_end, 0)(Stdin -> Pipe)。 - Pipe Capacity:默认 64KB。如果 cmd1 写太快,cmd2 读太慢,cmd1 会被阻塞 (Blocked) 在
write()系统调用上。
- 命令:
3.3 信号 (Signals)
- 信号是软中断。
- 注册:
sigaction()系统调用。 - 传递:内核在从内核态返回用户态前(如系统调用返回、时钟中断返回),检查
task_struct的pending信号位图。 处理:如果已注册 Handler,内核会修改用户栈,压入信号处理函数栈帧,强行让 CPU 跳转到 Handler 执行,执行完再调用
sigreturn回到原断点。第二卷:乾坤挪移 —— 内存管理子系统 (MM)
本卷目标:
- 祛魅:理解为什么
top看到的 VIRT 很大但 RES 很小。 - 溯源:理解一次
malloc到底触发了多少内核机制。 - 优化:掌握如何通过 HugePages、NUMA 绑定和 Cache 亲和性榨干硬件性能。
第一章:硬件基石 (The Hardware Layer)
内核的内存管理代码,本质上是在伺候硬件。不懂硬件特性,就看代码如看天书。
1.1 MMU 与 TLB (地址转换的性能瓶颈)
MMU (Memory Management Unit):
- CPU 核心发出的地址都是 虚拟地址 (Virtual Address)。
- MMU 负责查表(页表)将虚拟地址转换为 物理地址 (Physical Address),然后通过总线发给 RAM。
TLB (Translation Lookaside Buffer):
- 本质:页表存在内存里,查页表太慢(需多次访存)。TLB 是 CPU 内部的高速缓存,专门存“虚拟页->物理页”的映射。
- TLB Miss:性能杀手。一旦 Miss,CPU 必须触发 Page Walk(硬件遍历页表),产生几十个时钟周期的停顿。
- TLB Shootdown:多核 CPU 的噩梦。当一个核修改了页表(如
munmap),它必须通过 IPI (核间中断) 通知其他核刷新 TLB,这会引起巨大的锁争用和延迟。
PCID (Process-Context ID):
- 在旧 CPU 上,进程切换 (Context Switch) 必须清空 TLB(因为 A 进程的地址 0x1000 和 B 进程的 0x1000 对应的物理页不同)。
- PCID 允许 TLB 中同时存在不同进程的条目,大幅降低了上下文切换的开销。
1.2 CPU Cache 与 Cache Line
Cache Line (缓存行):
- CPU 不是按字节读取内存,而是按行。x86_64 通常是 64 Bytes。
- 性能启示:结构体定义时,如果两个高频竞争的锁放在一起(< 64B),会导致 False Sharing (伪共享),导致多核性能断崖式下跌。需使用
__cacheline_aligned对齐。
MESI 协议:
- 多核之间保证缓存一致性的协议 (Modified, Exclusive, Shared, Invalid)。理解为什么
volatile关键字在 C 语言中不足以保证并发安全(它不管缓存一致性,只管编译器不优化)。
- 多核之间保证缓存一致性的协议 (Modified, Exclusive, Shared, Invalid)。理解为什么
1.3 NUMA (Non-Uniform Memory Access)
- 拓扑结构:在双路/四路服务器上,CPU 0 访问插在 CPU 1 旁边的内存,比访问自己的内存要慢 30% 以上。
- Zone Reclaim Mode:理解为什么有时候系统还有内存,但却频繁触发 Swap?因为本地节点内存不足,且内核配置为不愿去远程节点借内存。
第二章:虚拟内存 (Virtual Memory - The Illusion)
每个进程都以为自己独占 48 位 (256TB) 的内存空间。这是操作系统编织的最大的谎言。
2.1 内存布局 (Memory Layout)
User Space (0 - 0x00007FFFFFFFFFFF):
- Text/Data/BSS:ELF 加载段。
- Heap:
brk指针控制的区域,向上增长。 - Mmap Region:动态库、大块内存分配区,向下增长(通常在栈下方)。
- Stack:主线程栈,向下增长,默认 8MB (
ulimit -s)。
Kernel Space (0xFFFF800000000000 - ...):
- Direct Mapping (直接映射区):物理内存的 0-64TB 直接线性映射到这里(phys_addr + PAGE_OFFSET)。内核访问物理内存极其容易。
- Vmalloc Area:用于
vmalloc,虚拟地址连续但物理地址不连续(用于加载内核模块)。
2.2 核心结构体:mm_struct & vm_area_struct
mm_struct:进程的内存描述符。
pgd_t * pgd:指向页全局目录(页表的根)。CR3 寄存器存的就是这个物理地址。
vm_area_struct (VMA):
- 内核不记录每一页的状态,而是记录“一段连续的虚拟内存区域”。
- 例如:一段 VMA 是只读的代码段,一段 VMA 是可写的堆。
- 数据结构演进:链表 -> 红黑树 (Red-Black Tree) -> Maple Tree (Linux 6.1+)。随着内存越来越大,红黑树的查找也嫌慢了,最新的 Maple Tree 对范围查找进行了极致优化。
2.3 缺页中断 (Page Fault) —— 内存管理的引擎
当 CPU 访问一个虚拟地址,而页表中 P (Present) 位为 0 时,触发 #PF 异常,陷入内核 do_page_fault。
- 合法性检查:查找 VMA 红黑树。如果地址不在任何 VMA 内 -> SIGSEGV (段错误)。
- 权限检查:如果 VMA 是只读,你试图写 -> SIGSEGV。
Handling (处理):
- Anonymous Page (匿名页):如
malloc新申请的内存。内核分配一个物理页,全填 0 (Security),修改页表。这叫 Minor Fault。 - File-backed Page (文件页):如
mmap一个文件。内核启动磁盘 I/O,将文件内容读入 Page Cache,再映射到用户空间。这叫 Major Fault (此时进程会进入 Sleep 状态)。 - COW (Copy-On-Write):Fork 产生的只读页被写入。内核申请新物理页,拷贝数据,将页表改为可写。
- Anonymous Page (匿名页):如
第三章:物理内存管理 (Physical Memory - The Reality)
内核不仅要管理虚拟的幻象,还要管理真实的硅片。
3.1 物理寻址与 Zones
PFN (Page Frame Number):物理页号。内核用
struct page结构体(每个结构体 64字节)管理每一个物理页。- 计算:如果你的机器有 128GB 内存,
struct page数组本身就要占用约 2GB 内存 (128GB / 4KB * 64B)。这叫 Vmemmap 开销。
- 计算:如果你的机器有 128GB 内存,
Zones:
- ZONE_DMA: 也就是低 16MB,给老旧硬件 DMA 用。
- ZONE_NORMAL: 正常的内存。
- ZONE_MOVABLE: 可热插拔或可迁移(用于减少碎片)。
3.2 伙伴系统 (Buddy System)
- 解决问题:外部碎片 (External Fragmentation)。
- 原理:维护 11 个链表,分别存 1, 2, 4, 8 ... 1024 个连续页块 (Order 0 - Order 10)。
- 分配:申请 3 页 -> 找 Order 2 (4页) -> 拆分成 2+2 -> 拿走一个,剩下一个挂入 Order 1 链表。
- 查看:
cat /proc/buddyinfo。
3.3 Slab/Slub 分配器
- 解决问题:内部碎片。内核中大量小对象(如
task_struct,inode,dentry)只有几百字节,给一整页 (4KB) 太浪费。 Slub (默认):
- 从 Buddy System 申请一整页。
- 切成无数小块。
- Per-CPU Cache:每个 CPU 有自己的空闲对象链表,分配时无锁,极快。
- 实战:
slabtop命令查看内核都在缓存什么结构体。如果dentry占用极高,说明文件系统缓存太多。
第四章:内存回收与 OOM (Reclaim & OOM)
出来混,迟早要还的。当物理内存耗尽时,内核必须做出残酷的决定。
4.1 LRU 链表与页面置换
内核维护四条 LRU (Least Recently Used) 链表:
- Active Anon (活跃匿名页)
- Inactive Anon (不活跃匿名页)
- Active File (活跃文件页 - Page Cache)
- Inactive File (不活跃文件页)
kswapd0:内核线程,当水位线 (Watermark) 低于
low时唤醒,扫描 Inactive 链表。- 如果是 File 页:直接释放(Clean)或写回磁盘(Dirty)。
- 如果是 Anon 页:必须写入 Swap 分区 才能释放。
Swappiness:
/proc/sys/vm/swappiness(0-100)。- 60 (默认):倾向于回收 File 页,也适度使用 Swap。
- 0:只有当 File 页很难回收时才用 Swap。
- 误区:设为 0 不代表禁用 Swap!只要物理内存耗尽,依然会 Swap。
4.2 反向映射 (Reverse Mapping - Rmap)
- 难题:当内核决定回收一个物理页 P 时,可能有 10 个进程映射了它(共享库)。内核如何找到这 10 个进程的页表并把 PTE 设为 Invalid?
- Obj_rmap / Anon_rmap:
struct page中有指针指向映射了它的 VMA 链表。这是内存回收能工作的基石,但也消耗了大量元数据空间。
4.3 OOM Killer (终极审判)
当 kswapd 拼命回收也无法满足新内存请求时,触发 Direct Reclaim;如果还不行,触发 OOM。
- Scores:每个进程初始分 = 内存占用 (RSS)。
OOM Score Adj:
/proc/<pid>/oom_score_adj(-1000 到 +1000)。- 设为 -1000:免死金牌(如
sshd,systemd)。 - 设为 +1000:优先处决。
- 设为 -1000:免死金牌(如
第五章:用户态分配器 (glibc malloc)
内核只提供 brk 和 mmap,是 glibc 里的 malloc 帮我们管理了堆。
5.1 Arena 与锁竞争
- Single Heap:早期的
malloc只有一块堆,多线程申请内存需要争抢一把大锁 (mutex)。 Arenas:现在的
malloc为每个线程(或核心)维护独立的内存池 (Arena)。- Thread A 申请 -> Arena A (无锁)。
- Thread B 申请 -> Arena B (无锁)。
内存泄漏的假象:有时候进程
free了内存,但 OS 没看到 RSS 下降。- 原因:
free的内存归还给了 Arena 的 Bin (空闲链表),复用给下次malloc,并没有munmap还给内核(为了避免频繁系统调用)。
- 原因:
5.2 性能调优实战
HugePages (大页):
- 标准页 4KB。大内存应用(Oracle, Redis, DPDK)页表条目太多,导致 TLB Miss。
- Transparent Huge Pages (THP):内核自动尝试合并为 2MB 大页。但有时会引起延迟抖动(合并过程需要锁)。
- Explicit Huge Pages (hugetlbfs):手动预留 1GB 大页,TLB 命中率 100%。
TCMalloc / Jemalloc:
- Google 和 Facebook 为了解决 glibc malloc 的碎片和锁问题开发的替代品。高并发服务器必备。
第三卷:万物生息 —— 进程管理与调度 (SCHED)
本卷目标:
- 解剖:深入
task_struct,看到进程的血肉。 - 调度:理解 CFS 算法如何用一颗红黑树实现“绝对公平”。
- 并发:看清自旋锁、互斥锁、RCU 背后的原子操作。
第一章:众生相 —— 进程描述符 (The Process Descriptor)
在内核眼中,没有“QQ音乐”或“Nginx”,只有 task_struct。这是 Linux 内核中最大的结构体之一(在 x86_64 上有几千字节),记录了一个生命体的一切。
1.1 task_struct 解构
源码位置:include/linux/sched.h
标识符:
pid_t pid:进程 ID。pid_t tgid:线程组 ID。注意:在用户态你看到的 PID,其实是内核里的 TGID。而在多线程程序中,主线程的 PID = TGID,子线程有自己独立的 PID,但共享 TGID。
状态 (
state):TASK_RUNNING:正在跑,或者排队等着跑。TASK_INTERRUPTIBLE:浅睡眠(等待 IO 或 信号),能被kill唤醒。TASK_UNINTERRUPTIBLE:深睡眠(等待磁盘 IO),不可杀(即使kill -9也没用,因为它不响应信号)。如果 Load Average 飙高但 CPU 使用率不高,通常就是有一堆这种进程堵在磁盘 IO 上。
亲属关系:
struct task_struct *parent:父进程。struct list_head children:子进程链表。- 实战:
pstree命令就是遍历这些指针画出来的。
1.2 它是怎么来的?(Fork vs Clone)
Linux 创建进程极其高效,因为它极其“懒惰”。
Fork (传统):
- 复制父进程的
task_struct。 - COW (Copy-On-Write):不复制内存,只复制页表,并标记为只读。
- 复制父进程的
Clone (现代):
fork()和pthread_create()底层调用的都是clone()系统调用。Flags 决定一切:
CLONE_VM: 共享内存(线程)。CLONE_FILES: 共享文件描述符(线程)。CLONE_NEWPID: 进入新的 PID Namespace(容器)。
内核线程 (Kernel Threads):
- 如
kthreadd,ksoftirqd。它们没有 mm指针(mm为 NULL),直接使用上一个进程的内核页表。它们永远不会切换到用户态。
- 如
1.3 它是怎么没的?(Exit & Wait)
- Do_exit:进程自杀(调用
exit)或被杀。它会释放内存 (mm_put)、关闭文件 (files_put),但它不会释放task_struct栈内存。 - 僵尸 (Zombie):此时进程状态变为
EXIT_ZOMBIE。它保留在内核中,只是为了把“退出码 (Exit Code)”交给父进程。 - 收尸:父进程调用
wait()读取退出码后,内核才会真正释放task_struct(Slab 释放)。
第二章:公平的艺术 —— CFS 调度器 (The Scheduler)
Linux 2.6.23 引入的 CFS (Completely Fair Scheduler) 是调度算法的里程碑。它的核心理念不再是“优先级队列”,而是“物理模型”。
2.1 理想多任务模型
- 理念:如果你有 N 个进程,CPU 应该像流水一样均分给它们。在任意时间段 T 内,每个进程都应该得到
T/N的运行时间。 - 现实:CPU 不能无限分割。所以 CFS 试图让每个进程的 虚拟运行时间 (vruntime) 保持一致。
2.2 核心指标:vruntime
- 公式:
$$ vruntime += 实际运行时间 \times \frac{1024}{进程权重} $$ 权重 (Weight):
- 由 Nice 值决定 (-20 到 +19)。
- Nice 0 的权重是 1024。
- Nice -10 (高优先级) 的权重很大,分母变大 ->
vruntime涨得慢 -> 总是比别人小 -> 总是被调度。 - 总结:vruntime 越小,越缺 CPU,越需要被调度。
2.3 核心数据结构:红黑树 (Red-Black Tree)
- 内核不用链表管理进程(O(n) 太慢),而是用红黑树。
- Key =
vruntime。 逻辑:
- 树的最左侧节点 (Leftmost Node),就是全系统
vruntime最小的进程。 - Pick Next:调度器只需要把最左侧节点取出来运行即可。复杂度 O(1)(因为缓存了 leftmost 指针)。
- Enque:进程运行了一会儿,
vruntime变大了,把它放回树里重新排序。复杂度 O(log N)。
- 树的最左侧节点 (Leftmost Node),就是全系统
2.4 休眠与唤醒的作弊 (Place Entity)
- 问题:一个进程睡了很久(比如等待键盘输入),它的
vruntime停滞了,变得非常小。一旦醒来,它会长期霸占 CPU 补课,导致其他进程卡顿。 修正:
- 内核维护一个
min_vruntime(当前运行队列中最小的 vruntime)。 - 当进程醒来时,内核会重置它的 vruntime:
vruntime = max(vruntime, min_vruntime - 补偿)。 - 既保证它能尽快抢到 CPU(响应快),又不让它饿死别人。
- 内核维护一个
第三章:调度阶级 (Scheduling Classes)
CFS 只是平民(普通进程)的规则。Linux 内核有着严格的阶级制度。调度器在选择下一个进程时,会按照以下顺序遍历调度类:
- Stop Class (最高):用于 CPU 热插拔、迁移等紧急任务。
Deadline Class (
SCHED_DEADLINE):- 硬实时。例如:必须在 10ms 内完成渲染。
- 基于 (Period, Runtime, Deadline) 模型。
Real-Time Class (RT):
SCHED_FIFO:先进先出。只要我不让出 CPU,谁也别想运行。小心:写死循环会卡死整个核。SCHED_RR:时间片轮转。同优先级的 RT 进程轮流跑。
Fair Class (CFS):
SCHED_NORMAL:绝大多数进程都在这里。SCHED_BATCH:适合不交互的批处理任务。
Idle Class (最低):
- 只有 CPU 没事干时才跑它(0 号进程
swapper)。进入省电模式 (C-State)。
- 只有 CPU 没事干时才跑它(0 号进程
第四章:乾坤大挪移 —— 上下文切换 (Context Switch)
调度器决定换人后,真正的“切换”动作发生了。这是汇编级的艺术。
4.1 切换流程
函数:context_switch() -> switch_to()
切换内存 (switch_mm):
- 更换 CR3 寄存器(页表基地址)。
- 优化:如果下一个进程是内核线程,由于内核空间页表是一样的,不需要切换 CR3,大大减少 TLB Miss。
切换寄存器 (switch_to):
- 保存当前进程的 RSP (栈指针), RIP (指令指针), RBP, RBX 等到
task_struct。 - 载入下一个进程的寄存器。
- 魔法瞬间:当 RIP 载入的那一刻,CPU 就开始执行新进程的代码了。
- 保存当前进程的 RSP (栈指针), RIP (指令指针), RBP, RBX 等到
4.2 抢占 (Preemption)
用户态抢占:
- 当系统调用返回用户态,或中断处理返回用户态时。
- 检查
TIF_NEED_RESCHED标志。如果置位,调用schedule()。
内核态抢占 (Kernel Preemption):
- 这是 Linux 低延迟的关键。即使 CPU 在内核里跑(比如正在执行系统调用),只要没有持有自旋锁,高优先级的进程(如鼠标中断)也可以抢占它。
第五章:多核与并发原语 (SMP & Locking)
现在的服务器动辄 64 核、128 核。如何防止它们打架?
5.1 负载均衡 (Load Balancing)
- 每个 CPU 都有自己的 Runqueue (
rq)。理想情况下大家互不干扰。 - 问题:CPU 0 忙死,CPU 1 闲死。
机制:
- Work Stealing:CPU 1 醒来发现自己没事干,去 CPU 0 的队列里“偷”几个任务过来。
Scheduling Domains:
- SMT Domain (超线程):迁移成本极低(共享 L1 Cache)。
- MC Domain (多核):迁移成本中等(共享 L3)。
- NUMA Domain (跨路):迁移成本极高。
5.2 锁的物理学 (Locking Primitives)
当多核同时访问共享数据(如 dentry 缓存),必须加锁。
Spinlock (自旋锁):
- 行为:while(lock_is_taken); // 忙等待。
- 场景:持有时间极短,且不可睡眠(中断上下文)。
- 底层:
LOCK前缀指令 + CAS (Compare-And-Swap) 原子操作。
Mutex (互斥量):
- 行为:拿不到锁就去睡觉 (Schedule Out),让出 CPU。
- 场景:持有时间长,可能发生 IO。
Futex (Fast Userspace Mutex):
- 性能神器。在没有竞争时,完全在用户态(原子变量),不需要陷入内核系统调用。只有发生竞争(Contention)时,才 syscall 进内核挂起线程。Java 的
synchronized和 Go 的Mutex底层都是 Futex。
- 性能神器。在没有竞争时,完全在用户态(原子变量),不需要陷入内核系统调用。只有发生竞争(Contention)时,才 syscall 进内核挂起线程。Java 的
RCU (Read-Copy-Update):
- 黑科技。内核中最复杂的同步机制。
- 理念:读锁完全无开销(无锁)。写者去拷贝一份数据修改,改完指过去。等所有读者读完了旧数据,再释放旧内存。
- 场景:读多写少(如路由表、文件描述符表)。
第六章:容器的心脏 —— Cgroups CPU 子系统
Docker 限制 CPU 到底是怎么做到的?
6.1 CPU Bandwidth Control
- 源码:
kernel/sched/fair.c 配额机制:
cpu.cfs_period_us(周期):通常 100ms。cpu.cfs_quota_us(配额):比如 20ms。
原理:
- 内核为每个 Cgroup 维护一个
vruntime账本。 - 容器里的进程每跑 1ms,扣除 1ms 配额。
- Throttling:当配额扣光时,内核强制把该 Cgroup 下的所有进程踢出 Runqueue,设为睡眠状态。
- 下一周期开始时,重新注资,唤醒进程。
- 内核为每个 Cgroup 维护一个
- 现象:如果你发现容器 CPU 使用率才 20% 但响应极慢,检查
nr_throttled计数。这通常是因为周期设置不合理,导致短时间内配额耗尽,被强制“关小黑屋”。
第四卷:经络血脉 —— 网络协议栈 (NET)
本卷目标:
- 透视:从网卡电信号到
recv()返回,追踪一个数据包的奇幻漂流。 - 解构:理解 Linux 网络核心对象
sk_buff的设计哲学。 - 极速:掌握 C10K/C10M 问题背后的 Epoll、零拷贝与 Kernel Bypass 技术。
第一章:从网线到内存 (Layer 1 & 2 - The Driver Layer)
数据包到达服务器时,CPU 还毫不知情。网卡(NIC)是第一个接待员。
1.1 DMA 与 Ring Buffer
DMA (Direct Memory Access):
- 网卡内部有一个 Rx Ring Buffer(接收环形缓冲区)。这其实是主机内存中的一段区域(DMA 区域)。
- 当光/电信号到达,网卡控制器直接将数据搬运到内存中,不需要 CPU 参与。
硬中断 (Hard IRQ):
- 数据搬完后,网卡发起一个硬件中断,告诉 CPU:“喂,有货来了,快来处理”。
- CPU 暂停当前手头的工作,执行网卡驱动注册的中断处理函数。
- 性能瓶颈:如果每来一个包都中断一次,CPU 会被“中断风暴”淹没(Livelock),导致用户态进程完全饿死。
1.2 NAPI (New API) —— 中断与轮询的妥协
Linux 2.6 引入 NAPI 机制解决中断风暴。
- 第一个包到达:触发硬中断。
- 关闭中断:驱动程序告诉网卡,“先别发中断了,我去叫人”。
- 唤醒软中断:触发
NET_RX_SOFTIRQ。 - 轮询 (Polling):内核线程(
ksoftirqd/n)开始批量从 Ring Buffer 取包(一次取budget个,默认 300 或 64)。 - 恢复中断:如果 Ring Buffer 空了,重新开启网卡硬中断,进入休眠。
1.3 核心数据结构:sk_buff (Socket Buffer)
Linux 网络栈的通用货币。理解它就能理解零拷贝。
- 结构:包含
head,data,tail,end四个指针。 操作:
- 封包:当 TCP 层要把数据传给 IP 层时,不需要拷贝数据,只需要移动
data指针预留头部空间,填充 IP 头即可。 - 克隆:当
tcpdump抓包时,只需要拷贝sk_buff结构体(元数据),不需要拷贝实际的数据负载 (Payload)。 - 代价:
sk_buff结构体本身较大(200+ 字节),高并发下频繁分配释放会造成 Slab 压力。
- 封包:当 TCP 层要把数据传给 IP 层时,不需要拷贝数据,只需要移动
第二章:协议栈的迷宫 (Layer 3 & 4 - IP & TCP)
软中断拿到了包,开始向上层层递交。
2.1 网络层的关卡 (IP Layer)
路由子系统 (FIB):
- 数据包进来,内核要问:这是给我的,还是路过的?
- Local Process:目的 IP 是本机 -> 往上送。
- Forward:目的 IP 是别人,且
net.ipv4.ip_forward=1-> 查路由表转发。 - FIB Trie:路由表不是简单的数组,而是 LC-Trie (最长前缀匹配前缀树),查找速度极快。
Netfilter (防火墙基石):
- Iptables, IPVS, Docker, K8s Service 全靠它。
5 Hook Points:
PREROUTING(刚进来,还没查路由)。INPUT(给本机的)。FORWARD(转发的)。OUTPUT(本机发出的)。POSTROUTING(马上要滚出去的)。
Conntrack (连接跟踪):
- Netfilter 会记录每一条流的状态 (NEW, ESTABLISHED, RELATED)。这是 NAT (网络地址转换) 能工作的前提。
- 故障典籍:
nf_conntrack: table full, dropping packet。这是高并发服务器常见故障,意味着连接跟踪表满了,内核开始丢包。
2.2 传输层的精密机器 (TCP Layer)
这是内核里最复杂的代码之一。
三次握手 (The Handshake):
- SYN Queue (半连接队列):收到 SYN,状态变
SYN_RECV,放入此队列。如果满了,开启tcp_syncookies抵御攻击。 - Accept Queue (全连接队列):收到 ACK,三次握手完成,状态变
ESTABLISHED,移入此队列。 - 应用层:应用调用
accept(),就是从全连接队列里取出一个 Socket。
- SYN Queue (半连接队列):收到 SYN,状态变
拥塞控制 (Congestion Control):
- CUBIC (默认):基于丢包作为信号。一旦丢包,窗口减半。在现代高带宽网络中过于保守。
- BBR (Google):基于带宽和延迟 (BDP) 模型。不看丢包,看网络是不是真的堵了。在弱网环境下能提升数倍吞吐量。
重传机制:
- RTO (Retransmission Timeout):超时重传,慢。
- Fast Retransmit:收到 3 个重复 ACK,立即重传,快。
第三章:Socket 接口与高性能网络 (Layer 5 - User Space)
终于,数据要交给用户进程了。
3.1 Socket 的本质
- Everything is a File:Socket 也是文件,有
inode,有file_operations。 接收缓冲区:
recv()不是直接从网卡读。而是把内核缓冲区 (sk_receive_queue) 里的数据拷贝到用户提供的 buffer 中。- 这就是网络 I/O 慢的根本原因:上下文切换 + 内存拷贝。
3.2 I/O 模型的进化
BIO (Blocking I/O):
read()没数据就卡住线程。一个连接需要一个线程。并发上限 = 线程上限。
NIO (Non-blocking I/O):
read()没数据返回EAGAIN。应用层写死循环轮询。CPU 空转,发热。
I/O Multiplexing (多路复用):
- Select/Poll:把 1000 个 fd 给内核,“谁有数据告诉我不?” 内核遍历一遍,返回。缺点:O(N),连接多了遍历太慢。
Epoll (Linux 专属):
- RB-Tree:在内核里维护一个红黑树,存所有待监控的 socket。
- Callback:当网卡收到数据,协议栈走到 TCP 层,触发回调,把该 socket 加入 Ready List (双向链表)。
- Epoll_wait:只需要查看 Ready List 有没有东西。O(1)。
- C10K 问题:Epoll 使得单机维护 100 万长连接成为可能。
第四章:零拷贝与旁路技术 (Zero Copy & Kernel Bypass)
即使是 Epoll,依然要把数据从内核拷贝到用户态。在 40Gbps/100Gbps 网络下,这依然太慢。
4.1 零拷贝 (Zero Copy)
Sendfile:
- 传统发文件:Disk -> Kernel Cache -> User Buffer -> Kernel Socket Buffer -> NIC。 (4次拷贝,4次切换)
- Sendfile:Disk -> Kernel Cache -> NIC。 (2次拷贝,2次切换)。
- Kafka 和 Nginx 高性能的秘诀。
- Splice:两个文件描述符之间传递数据(如 Pipe 到 Socket),完全在内核完成,零拷贝。
4.2 Kernel Bypass (绕过内核)
如果内核协议栈太慢,那就不要用它了。
DPDK (Data Plane Development Kit):
- Intel 推出的框架。
- UIO:用户态驱动,直接接管网卡 PCI 设备,映射网卡内存到用户空间。
- PMD (Poll Mode Driver):一个死循环在 CPU 专核上狂转,轮询网卡。
- 无中断、无拷贝、无系统调用。
- 代价:你需要自己实现 TCP/IP 栈(如 F-Stack)。
XDP (eXpress Data Path):
- Linux 内核原生的 Bypass。
- 在网卡驱动层(
sk_buff分配之前)运行 eBPF 字节码。 - 场景:DDOS 防御(直接 Drop,不进协议栈)、负载均衡(直接修改 MAC/IP 转发)。
第五章:故障排查实战 (Troubleshooting)
场景 1:Ping 得通,Curl 不通
- 分析:Ping 是 ICMP (Layer 3),Curl 是 TCP (Layer 4)。
- 可能原因:Iptables 允许了 ICMP 但 DROP 了 TCP 80;或者 MTU 问题(大包被丢弃,Ping 包小没发现)。
场景 2:TCP 连接建立慢,伴有丢包
- 工具:
dmesg | grep conntrack看是不是连接表满了。 - 工具:
netstat -s | grep "listen queue"看是不是全连接队列溢出 (Syn Flood)。
场景 3:网卡中断不均衡,CPU 0 跑死,其他核围观
- 解决:检查 irqbalance 服务是否开启。
- 优化:配置 RPS (Receive Packet Steering) 或 RFS,软件模拟多队列,把包分发给其他核处理。
📖 第五卷:有容乃大 —— 存储与文件系统 (VFS & Storage)
本卷目标:
- 抽象:彻底理解 VFS 四大对象,明白为什么 socket 和 pipe 也是文件。
- 路径:追踪一个
write()调用从 Page Cache 到 BIO 再到磁盘扇区的全过程。 - 演进:掌握从传统的 Journaling 到现代 CoW (Copy-on-Write) 文件系统的技术变革。
第一章:伟大的抽象 —— VFS (Virtual File System)
Linux 支持 100 多种文件系统。为什么 cp 命令不需要针对 ext4 写一套代码,针对 ntfs 写一套代码?因为 VFS。
1.1 VFS 四大金刚
内核中用 C 结构体模拟了面向对象的继承多态。
Superblock (超级块):
- 代表一个已挂载的文件系统(如
/dev/sda1)。 - 存储元数据:块大小、魔数、Inode 总数。
- 代表一个已挂载的文件系统(如
Inode (索引节点):
- 文件的灵魂。存储权限、大小、时间戳、数据块指针。
- 唯一性:在同一个文件系统中,Inode 号是唯一的。
- 注意:Inode 不包含文件名!文件名只是目录中的记录。
Dentry (目录项):
- 文件名的载体。连接文件名和 Inode。
- Dentry Cache (Dcache):内核为了加速路径查找(如
/etc/nginx/nginx.conf),会缓存解析过的目录项。内存泄漏高发区:如果大量生成临时文件名(如 PHP 的 session 文件),Dcache 会迅速吃光 RAM。
File (文件对象):
- 代表进程打开的文件。
- 包含核心字段:
f_pos(当前读写偏移量)。 - 区别:两个进程打开同一个文件,有 2 个
struct file,但共享 1 个struct inode。
1.2 挂载 (Mount) 与命名空间
- Mount Point:挂载点其实就是覆盖了原目录的 Dentry。
- Bind Mount:
mount --bind /A /B。让两个目录指向同一个 Dentry。容器中挂载 Volume 就是用的这个。 - Mount Namespace:容器隔离的基础。每个容器有自己独立的挂载树,互不干扰。
第二章:数据的高速公路 —— I/O 栈 (The I/O Stack)
write() 返回成功了,数据就存好了吗?差得远呢。
2.1 Page Cache (页缓存)
Write Back (回写):
write()只是把用户态数据拷贝到了内核态的 Page Cache (Radix Tree/XArray 管理)。- 页面被标记为 Dirty (脏页)。
write()随即返回。速度极快。- 内核线程
kworker后台异步将脏页刷入磁盘。 - 风险:如果此时断电,数据丢失。
Write Through / Sync:
- 调用
fsync()或open(O_SYNC)。 - 强制立刻刷盘,写完才返回。慢,但安全(数据库 WAL 日志必备)。
- 调用
2.2 通用块层 (Block Layer)
BIO (Block I/O):
- 当 Page Cache 决定刷盘,或者发生缺页读取时,会组装一个
struct bio。 - 合并与排序:块层会将对相邻扇区的多个 BIO 合并成一个 Request,以减少机械臂移动(HDD)或各种开销。
- 当 Page Cache 决定刷盘,或者发生缺页读取时,会组装一个
I/O 调度器:
- CFQ (已淘汰):完全公平队列,针对机械盘优化。
- Deadline:保证读写延迟不超过最后期限。
- NOOP/None:啥也不干。NVMe SSD 必选(因为 SSD 随机读写极快,不需要排序,CPU 排序反而是累赘)。
2.3 异步 I/O 的进化
- AIO (Legacy):仅支持 Direct I/O,限制多,难用。
io_uring (Modern):
- Linux 5.1 引入的神器。
- 基于 SQ/CQ (提交/完成) 环形队列,用户态和内核态共享内存。
- 零系统调用:批量提交 I/O 请求,无需频繁陷入内核。性能碾压 Epoll + AIO。
第三章:文件系统内幕 (EXT4 & XFS)
3.1 EXT4 的数据一致性
突然断电了,文件系统会坏吗?
Journaling (日志):
- JBD2:Ext4 的日志子系统。
- 流程:写数据前,先在日志区记录“我要写数据了” (Transaction) -> 写实际数据 -> 在日志区记录“我写完了” (Commit)。
模式:
data=ordered(默认):只记录元数据日志,但保证先写数据再写元数据。性能与安全平衡。data=writeback:最快,但断电可能导致文件内容包含旧垃圾数据。data=journal:所有数据都写两次。最慢,最安全。
3.2 现代特性
Extent Tree:
- 老文件系统用“块列表”记录大文件,元数据太大。
- Extent 用
(起始块, 长度)记录,高效管理大文件。
Delayed Allocation (延迟分配):
- 数据先写在 Cache 里,不急着分配磁盘块。等刷盘时一次性分配一大块连续空间,减少碎片。
第四章:下一代存储技术 (CoW & Overlay)
4.1 Copy-On-Write (Btrfs/ZFS)
- 原理:修改数据时,不覆盖原位置,而是找个新地方写,然后修改指针指向新地方。
优势:
- 秒级快照:快照只是复制了一份 B+ 树的根指针,不拷贝数据。
- 数据校验:每个块都有 Checksum,彻底解决静默数据损坏 (Bit Rot)。
4.2 OverlayFS (容器存储)
Docker 镜像分层的秘密。
结构:
- LowerDir (只读):基础镜像层 (Image Layers)。
- UpperDir (读写):容器层 (Container Layer)。
- MergedDir:用户看到的挂载点。
写操作:
- 当你要修改基础镜像里的
/etc/hosts时,OverlayFS 会触发 Copy-up,把文件从 LowerDir 复制到 UpperDir,然后修改 UpperDir 里的副本。 - 这也是为什么在容器里修改大文件(如数据库数据文件)会很慢的原因。
- 当你要修改基础镜像里的
第六卷:天眼洞察 —— 虚拟化与可观测性 (Virtualization & Observability)
本卷目标:
- 矩阵:从硬件虚拟化到轻量级容器,理解“云”的本质。
- 天眼:掌握 eBPF 和 Perf,将 Linux 内核变成透明的水晶。
- 心法:学会系统级性能调优的方法论。
第一章:虚拟化基石 (KVM & QEMU)
云服务器 (EC2/CVM) 到底是什么?
1.1 CPU 虚拟化 (VT-x)
Root Mode vs Non-Root Mode:
- Intel CPU 引入的新模式。Host OS 运行在 Root 模式,Guest OS 运行在 Non-Root 模式。
- VM-Exit:当 Guest OS 试图执行敏感指令(如修改页表、IO 操作)时,CPU 强制暂停 Guest,切回 Root 模式交给 KVM 处理。这是虚拟化开销的主要来源。
- vCPU:在 Linux 看来,一个 vCPU 就是一个普通的 QEMU 线程 (
task_struct)。
1.2 内存与 I/O 虚拟化
EPT (Extended Page Tables):
- 硬件支持的二级地址翻译。
- Guest 虚拟地址 -> Guest 物理地址 -> Host 物理地址。
- 无需 KVM 软件模拟页表 (Shadow Page Table),性能大增。
VirtIO (半虚拟化):
- Guest OS 知道自己是虚拟机,不装傻去读写 IDE 寄存器,而是通过共享内存队列 (Virtqueue) 直接把数据扔给宿主机。
第二章:容器技术 (Container Internals)
Docker/K8s 没有任何“黑科技”,只是组合了 Linux 的现有功能。
Namespace (隔离):决定了你能看见什么。
PID: 也就是ps只能看到容器内的进程。NET: 独立的网卡、IP、路由表、Iptables。
Cgroups (限制):决定了你能用多少。
- 文件系统接口:
/sys/fs/cgroup/。 - K8s 的 QoS (Guaranteed/Burstable) 就是通过调整
cpu.shares和cpu.cfs_quota_us实现的。
- 文件系统接口:
Capabilities (权限):
- Root 不再是全能神。将 Root 权限拆分为细粒度权限(如
CAP_NET_ADMIN,CAP_SYS_TIME)。 - 容器默认虽然是 Root 运行,但被剥夺了绝大多数 Cap,所以无法修改系统时间或加载内核模块。
- Root 不再是全能神。将 Root 权限拆分为细粒度权限(如
第三章:上帝视角 —— eBPF 与可观测性
传统的监控(Top, Zabbix)只能告诉你“CPU 高”,eBPF 能告诉你“CPU 都在执行哪个函数”。
3.1 eBPF:内核的可编程性
- 革命:Linux 内核不仅是可配置的,现在是可编程的。
- Verifier:在加载字节码前,内核会进行极其严格的安全检查(防止死循环、内存越界),确保不会搞崩系统。
- Maps:eBPF 程序(内核态)和用户态程序(Go/Python)通过 Map(Hash/Array)共享数据。
3.2 动态追踪实战
- Kprobe/Kretprobe:在任意内核函数的入口和出口插桩。
- Uprobe:在用户态函数(如 MySQL 的
dispatch_command)插桩。 - Tracepoints:内核源码里预留的稳定静态钩子。
场景:
- 抓取所有执行
open("/etc/passwd")的进程 PID。 - 统计 TCP 重传率最高的 IP。
- 绘制 Off-CPU 火焰图,分析进程睡着时在等什么锁。
- 抓取所有执行
第四章:宗师的心法 —— 性能调优方法论
工具只是术,方法论才是道。
4.1 USE 方法 (Utilization, Saturation, Errors)
Brendan Gregg 提出,针对任何资源(CPU/内存/磁盘/网络):
- Utilization (利用率):忙的时间比例是多少?(如磁盘 IO 利用率 90%)
- Saturation (饱和度):有多少任务在排队?(如 Load Average, Wait Queue)
- Errors (错误):有没有报错?(如 Dropped Packets, IO Errors)
4.2 性能分析检查清单 (Checklist)
当面对一台慢机器:
uptime-> 看 Load Avg (系统堵没堵)。dmesg | tail-> 看内核有没有报错 (OOM, 硬件错误)。vmstat 1-> 看r(运行队列),b(IO等待),si/so(Swap)。mpstat -P ALL 1-> 看有没有单核跑死。pidstat 1-> 找元凶进程。iostat -xz 1-> 看磁盘利用率和延迟。sar -n DEV 1-> 看网络吞吐。perf top/profile-> 抓火焰图,看代码热点。