运维知识
悠悠
2025年12月11日

硬核挑战:如果说精通 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

  1. CPP (C Pre-Processor):处理 #include, #define。所有宏在这一步消失。
  2. CC1 (C Compiler):最核心部分。

    • AST (Abstract Syntax Tree):将 C 代码解析为树状结构。
    • GIMPLE/RTL:GCC 的中间表示 (IR)。优化 (O2/O3) 主要发生在这里(死代码消除、循环展开)。
    • Assembly:输出 .s 汇编文件。
  3. AS (Assembler):将汇编转为机器码 relocatable object (.o)。
  4. Collect2 / LD (Linker):将多个 .o 和库文件链接成最终可执行文件。

2.2 ELF 文件深度解剖 (Executable and Linkable Format)

这是理解操作系统加载程序的基石。必须精通 readelfobjdump

  • ELF Header:Magic Number (7F 45 4C 46), 入口地址 (Entry Point)。
  • Program Headers (Segments):告诉内核如何把文件映射到内存。

    • LOAD Segment: 哪些部分需要加载 (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_structpending 信号位图。
  • 处理:如果已注册 Handler,内核会修改用户栈,压入信号处理函数栈帧,强行让 CPU 跳转到 Handler 执行,执行完再调用 sigreturn 回到原断点。

    第二卷:乾坤挪移 —— 内存管理子系统 (MM)

本卷目标

  1. 祛魅:理解为什么 top 看到的 VIRT 很大但 RES 很小。
  2. 溯源:理解一次 malloc 到底触发了多少内核机制。
  3. 优化:掌握如何通过 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 语言中不足以保证并发安全(它不管缓存一致性,只管编译器不优化)。

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 加载段。
    • Heapbrk 指针控制的区域,向上增长。
    • 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

  1. 合法性检查:查找 VMA 红黑树。如果地址不在任何 VMA 内 -> SIGSEGV (段错误)
  2. 权限检查:如果 VMA 是只读,你试图写 -> SIGSEGV
  3. Handling (处理)

    • Anonymous Page (匿名页):如 malloc 新申请的内存。内核分配一个物理页,全填 0 (Security),修改页表。这叫 Minor Fault
    • File-backed Page (文件页):如 mmap 一个文件。内核启动磁盘 I/O,将文件内容读入 Page Cache,再映射到用户空间。这叫 Major Fault (此时进程会进入 Sleep 状态)。
    • COW (Copy-On-Write):Fork 产生的只读页被写入。内核申请新物理页,拷贝数据,将页表改为可写。

第三章:物理内存管理 (Physical Memory - The Reality)

内核不仅要管理虚拟的幻象,还要管理真实的硅片。

3.1 物理寻址与 Zones

  • PFN (Page Frame Number):物理页号。内核用 struct page 结构体(每个结构体 64字节)管理每一个物理页。

    • 计算:如果你的机器有 128GB 内存,struct page 数组本身就要占用约 2GB 内存 ( 128GB / 4KB * 64B )。这叫 Vmemmap 开销。
  • 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) 链表:

    1. Active Anon (活跃匿名页)
    2. Inactive Anon (不活跃匿名页)
    3. Active File (活跃文件页 - Page Cache)
    4. 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_rmapstruct page 中有指针指向映射了它的 VMA 链表。这是内存回收能工作的基石,但也消耗了大量元数据空间。

4.3 OOM Killer (终极审判)

kswapd 拼命回收也无法满足新内存请求时,触发 Direct Reclaim;如果还不行,触发 OOM。

  • Scores:每个进程初始分 = 内存占用 (RSS)。
  • OOM Score Adj/proc/<pid>/oom_score_adj (-1000 到 +1000)。

    • 设为 -1000:免死金牌(如 sshdsystemd)。
    • 设为 +1000:优先处决

第五章:用户态分配器 (glibc malloc)

内核只提供 brkmmap,是 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)

本卷目标

  1. 解剖:深入 task_struct,看到进程的血肉。
  2. 调度:理解 CFS 算法如何用一颗红黑树实现“绝对公平”。
  3. 并发:看清自旋锁、互斥锁、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)。

2.4 休眠与唤醒的作弊 (Place Entity)

  • 问题:一个进程睡了很久(比如等待键盘输入),它的 vruntime 停滞了,变得非常小。一旦醒来,它会长期霸占 CPU 补课,导致其他进程卡顿。
  • 修正

    • 内核维护一个 min_vruntime(当前运行队列中最小的 vruntime)。
    • 当进程醒来时,内核会重置它的 vruntime:vruntime = max(vruntime, min_vruntime - 补偿)
    • 既保证它能尽快抢到 CPU(响应快),又不让它饿死别人。

第三章:调度阶级 (Scheduling Classes)

CFS 只是平民(普通进程)的规则。Linux 内核有着严格的阶级制度。调度器在选择下一个进程时,会按照以下顺序遍历调度类:

  1. Stop Class (最高):用于 CPU 热插拔、迁移等紧急任务。
  2. Deadline Class (SCHED_DEADLINE):

    • 硬实时。例如:必须在 10ms 内完成渲染。
    • 基于 (Period, Runtime, Deadline) 模型。
  3. Real-Time Class (RT):

    • SCHED_FIFO:先进先出。只要我不让出 CPU,谁也别想运行。小心:写死循环会卡死整个核。
    • SCHED_RR:时间片轮转。同优先级的 RT 进程轮流跑。
  4. Fair Class (CFS):

    • SCHED_NORMAL:绝大多数进程都在这里。
    • SCHED_BATCH:适合不交互的批处理任务。
  5. Idle Class (最低):

    • 只有 CPU 没事干时才跑它(0 号进程 swapper)。进入省电模式 (C-State)。

第四章:乾坤大挪移 —— 上下文切换 (Context Switch)

调度器决定换人后,真正的“切换”动作发生了。这是汇编级的艺术。

4.1 切换流程

函数:context_switch() -> switch_to()

  1. 切换内存 (switch_mm)

    • 更换 CR3 寄存器(页表基地址)。
    • 优化:如果下一个进程是内核线程,由于内核空间页表是一样的,不需要切换 CR3,大大减少 TLB Miss。
  2. 切换寄存器 (switch_to)

    • 保存当前进程的 RSP (栈指针), RIP (指令指针), RBP, RBX 等到 task_struct
    • 载入下一个进程的寄存器。
    • 魔法瞬间:当 RIP 载入的那一刻,CPU 就开始执行新进程的代码了。

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。
  • 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,设为睡眠状态。
    • 下一周期开始时,重新注资,唤醒进程。
  • 现象:如果你发现容器 CPU 使用率才 20% 但响应极慢,检查 nr_throttled 计数。这通常是因为周期设置不合理,导致短时间内配额耗尽,被强制“关小黑屋”。

第四卷:经络血脉 —— 网络协议栈 (NET)

本卷目标

  1. 透视:从网卡电信号到 recv() 返回,追踪一个数据包的奇幻漂流。
  2. 解构:理解 Linux 网络核心对象 sk_buff 的设计哲学。
  3. 极速:掌握 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 机制解决中断风暴。

  1. 第一个包到达:触发硬中断。
  2. 关闭中断:驱动程序告诉网卡,“先别发中断了,我去叫人”。
  3. 唤醒软中断:触发 NET_RX_SOFTIRQ
  4. 轮询 (Polling):内核线程(ksoftirqd/n)开始批量从 Ring Buffer 取包(一次取 budget 个,默认 300 或 64)。
  5. 恢复中断:如果 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 压力。

第二章:协议栈的迷宫 (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

      1. PREROUTING (刚进来,还没查路由)。
      2. INPUT (给本机的)。
      3. FORWARD (转发的)。
      4. OUTPUT (本机发出的)。
      5. 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。
  • 拥塞控制 (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次切换)。
    • KafkaNginx 高性能的秘诀。
  • 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)

本卷目标

  1. 抽象:彻底理解 VFS 四大对象,明白为什么 socket 和 pipe 也是文件。
  2. 路径:追踪一个 write() 调用从 Page Cache 到 BIO 再到磁盘扇区的全过程。
  3. 演进:掌握从传统的 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 Mountmount --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)或各种开销。
  • 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)

本卷目标

  1. 矩阵:从硬件虚拟化到轻量级容器,理解“云”的本质。
  2. 天眼:掌握 eBPF 和 Perf,将 Linux 内核变成透明的水晶。
  3. 心法:学会系统级性能调优的方法论。

第一章:虚拟化基石 (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.sharescpu.cfs_quota_us 实现的。
  • Capabilities (权限)

    • Root 不再是全能神。将 Root 权限拆分为细粒度权限(如 CAP_NET_ADMIN, CAP_SYS_TIME)。
    • 容器默认虽然是 Root 运行,但被剥夺了绝大多数 Cap,所以无法修改系统时间或加载内核模块。

第三章:上帝视角 —— 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/内存/磁盘/网络):

  1. Utilization (利用率):忙的时间比例是多少?(如磁盘 IO 利用率 90%)
  2. Saturation (饱和度):有多少任务在排队?(如 Load Average, Wait Queue)
  3. Errors (错误):有没有报错?(如 Dropped Packets, IO Errors)

4.2 性能分析检查清单 (Checklist)

当面对一台慢机器:

  1. uptime -> 看 Load Avg (系统堵没堵)。
  2. dmesg | tail -> 看内核有没有报错 (OOM, 硬件错误)。
  3. vmstat 1 -> 看 r (运行队列), b (IO等待), si/so (Swap)。
  4. mpstat -P ALL 1 -> 看有没有单核跑死。
  5. pidstat 1 -> 找元凶进程。
  6. iostat -xz 1 -> 看磁盘利用率和延迟。
  7. sar -n DEV 1 -> 看网络吞吐。
  8. perf top / profile -> 抓火焰图,看代码热点。

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码