底层基石:操作系统与计算机基础

进程/线程/协程 · CPU 缓存与锁 · I/O 模型与零拷贝 · TCP 核心  |  Part 01

本主题的底层路线(万变不离其宗):
一切高性能框架的底座 = 并发(线程 / 协程) + 高效的 I/O 多路复用 + 减少拷贝与上下文切换 + 可靠的字节流(TCP)。 上面所有框架(Netty、Tomcat、Redis、Kafka、Nginx、Dubbo、Spring WebFlux……)都是这四件事的排列组合与取舍,没有第五件新东西。
目录 0. 读前必读:为什么"底层优先"是唯一省力的路
1. 进程 / 线程 / 协程:调度与上下文切换
   1.1 用户态与内核态:一条特权分界线
   1.2 进程 vs 线程:地址空间的取舍
   1.3 上下文切换到底贵在哪(拆开算账)
   1.4 C10K 逼出了协程
   1.5 有栈 / 无栈协程、green thread、虚拟线程
2. CPU 与内存层级:性能的物理上限
   2.1 存储金字塔与延迟数量级
   2.2 缓存行与伪共享(false sharing)
   2.3 MESI、store buffer 与内存屏障
   2.4 原子性 / 可见性 / 有序性:并发三件事
3. 并发原语:CAS 与锁的层次
   3.1 CAS:硬件给的唯一"原子武器"
   3.2 ABA 问题与版本号
   3.3 乐观锁 vs 悲观锁
   3.4 自旋锁 vs 互斥锁与 futex 思想
   3.5 JVM 偏向 / 轻量 / 重量级锁
4. I/O 模型演进:从 BIO 到 io_uring
   4.1 一次 read() 到底发生了什么(两个阶段)
   4.2 四象限:阻塞/非阻塞 × 同步/异步
   4.3 BIO:每连接一线程为什么撑不住
   4.4 NIO:Buffer / Channel / Selector
   4.5 select / poll:O(n) 与 fd 限制
   4.6 epoll:红黑树 + 就绪链表 + 回调,LT/ET
   4.7 kqueue / IOCP:同一思想的不同方言
   4.8 io_uring:SQ/CQ 双环与内核旁路
   4.9 Reactor:把多路复用封装成编程模型
5. 零拷贝:为什么"拷贝"是头号大敌
   5.1 拷贝与切换的账本
   5.2 page cache:被低估的主角
   5.3 mmap / sendfile / splice / MSG_ZEROCOPY 对比
6. TCP 核心:可靠字节流是怎么造出来的
   6.1 三次握手与 SYN Flood
   6.2 四次挥手与 TIME_WAIT 的意义
   6.3 滑动窗口:流量控制
   6.4 拥塞控制:慢启动 / 拥塞避免 / 快重传
   6.5 Nagle 与延迟确认:一对糟糕的组合
   6.6 粘包 / 拆包:成因与三种解法
7. 总表:底层路线 → 框架映射
8. "一句话记住"速查表
9. 自测:能答出来才算过关

0. 读前必读:为什么"底层优先"是唯一省力的路

你有数学背景,这是巨大优势:你习惯"先抓公理与结构,再看定理是结构的推论"。计算机系统正好可以这样读。

框架之所以让人晕,是因为大多数教程按API 罗列讲:Netty 有 EventLoop、ChannelPipeline、ByteBuf;Kafka 有 Partition、ISR、Segment……名词几百个,彼此看不出关系。但如果换个视角:

一句话记住:操作系统只给了程序员四样"原材料"——CPU 时间(调度)内存层级(缓存)系统调用(进内核干活)字节流/数据包(网络与磁盘)。所有框架都只是在这四样原材料上做取舍:用什么并发单位、如何等 I/O、如何少拷贝、如何在不可靠介质上造可靠语义。看懂取舍,框架就只剩"参数不同"。

本篇是整套材料的地基。地基里最重要的是四条主线,请在读每一节时不断回头对照:

底层主线要回答的根本问题上层框架的对应体现
并发线程 / 协程一个 CPU 核如何"同时"服务上万个请求?切换的成本从哪来?Tomcat 线程池、Netty EventLoop、Go goroutine、Java 21 虚拟线程、Reactor 调度器
等待I/O 多路复用如何用 1 个线程盯住 10 万个 socket,而不是 10 万个线程?Netty/Redis/Nginx 的 epoll 事件循环、Selector、WebFlux 的非阻塞栈
搬运零拷贝数据从磁盘到网卡,最少要过几次手?Kafka sendfile、RocketMQ mmap、Netty FileRegion / CompositeByteBuf / 堆外内存
可靠TCP 字节流在会丢包、会乱序、会重复的网络上,如何得到有序不丢的字节流?代价是什么?连接池、心跳与保活、粘包解码器、超时重试与幂等、RPC 协议设计

阅读建议:第 1–3 章是"单机 CPU 侧",第 4–5 章是"单机 I/O 侧",第 6 章是"跨机器"。三块合起来正好是后续所有中间件的物理基础。

1. 进程 / 线程 / 协程:调度与上下文切换

1.1 用户态与内核态:一条特权分界线

CPU 有特权级(x86 上是 ring 0 ~ ring 3)。操作系统内核跑在 ring 0,能执行所有指令、访问所有内存和外设;你的 Java 进程跑在 ring 3,不能直接碰硬件。为什么要这条线?因为"隔离与仲裁":磁盘、网卡、物理内存是全机共享资源,若谁都能直接写,任何一个 bug 都能毁掉整台机器。

于是,凡是要碰硬件或全局资源的事(读文件、发网络包、创建线程、分配大块内存),你都必须请内核代办。请求的方式就是系统调用(syscall):把参数放到约定的寄存器,执行一条特殊指令(x86-64 上是 syscall),CPU 切到 ring 0,跳到内核入口,内核干完再返回。

一句话记住:用户态 → 内核态不是"函数调用变慢了一点",而是换了一套执行环境:切栈(用户栈 → 内核栈)、保存/恢复寄存器、可能刷新流水线与部分缓存/TLB。所以性能优化的一条铁律是:能少进内核就少进内核(批量化、缓冲、mmap、io_uring 都是这条铁律的产物)。

数量级感受(同一台机器上的相对成本,具体值与 CPU/内核版本相关,量级更重要):

操作量级解读
普通函数调用~1 ns基本免费
一次简单系统调用(如 getpid~100 ns ~ 数百 ns开启 Spectre/Meltdown 缓解后更贵
一次线程上下文切换~1–10 μs(含缓存污染可到几十 μs)比系统调用再贵一到两个数量级
一次协程切换(用户态)~10–200 ns只换寄存器和栈指针,不进内核

1.2 进程 vs 线程:地址空间的取舍

先破一个常见误解:在 Linux 内核里,没有"线程"这个独立概念。内核只调度一种东西——任务(task_struct)。进程和线程的区别,只在于创建时"共享了多少东西":

clone(CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD ...)   → 我们叫它"线程"(共享地址空间)
clone(不共享地址空间)                                              → 我们叫它"进程"(fork 的本质)

所以"线程比进程轻"的真正原因是:共享地址空间 ⇒ 切换时不用换页表(不刷 TLB)、创建时不用复制页表,而不是因为内核对它们区别对待。

维度进程线程协程 / 虚拟线程
调度者内核内核用户态运行时(JVM、Go runtime、libco)
地址空间独立(页表独立)共享共享(同线程内)
切换成本最贵(换页表 + 刷 TLB)贵(进内核 + 缓存污染)便宜(几十~几百 ns,不进内核)
独立,虚拟内存按需固定预留(Java 默认约 1 MB/线程)极小且可增长(几百 B ~ 几 KB,放堆上)
崩溃影响只死自己整个进程死整个进程死
可创建数量级数千~上万(受内存与调度器限制)百万
典型代表Nginx worker、PostgreSQL 连接Tomcat 线程池、JDBC 阻塞调用goroutine、Kotlin 协程、Java 21 虚拟线程
取舍视角:Nginx 用"多进程 + epoll"而不是多线程,是为了隔离性(一个 worker 崩了不影响别人)和避免多线程共享数据的锁;Redis 用"单线程 + epoll",是为了彻底消灭锁(数据结构操作天然原子)。两者都不是"技术水平不同",而是取舍点不同

1.3 上下文切换到底贵在哪(拆开算账)

很多人只知道"上下文切换贵",说不出贵在哪。拆成四笔账:

  1. 直接成本:保存 / 恢复。通用寄存器、程序计数器、栈指针、浮点/向量寄存器(AVX 状态可达数百字节)、内核栈切换。这部分是 μs 以内的固定开销。
  2. 调度器成本。Linux CFS 要在红黑树里挑下一个任务、更新 vruntime、处理负载均衡(可能跨核迁移)。
  3. 缓存污染(隐形,常常最贵)。新任务的数据不在 L1/L2 里,它跑起来会一路 cache miss,把旧任务的热数据挤走;旧任务被换回来时同样要重新预热。这笔账不体现在"切换"这条指令上,而体现在之后几千条指令都变慢
  4. TLB / 分支预测器失效。跨进程切换还要刷 TLB(有 PCID 可缓解),分支预测历史也基本作废。
一句话记住:上下文切换真正的代价不是"保存寄存器的那几百纳秒",而是把 CPU 好不容易预热的缓存和预测器全部作废。所以高性能框架的共同姿势是:线程数 ≈ 核数,让线程尽量不被换下去(Netty EventLoop、Redis 单线程、Disruptor 绑核,都是这个思路)。

主动切换与被动切换

观察方式:vmstat 1cs 列;pidstat -w -p <pid> 1 区分 cswch/s(自愿)与 nvcswch/s(非自愿)。自愿切换高 ⇒ 大概率在等 I/O 或等锁;非自愿高 ⇒ CPU 争抢/线程太多。这是线上排查的第一把尺。

1.4 C10K 逼出了协程

设想一个即时通讯服务:10 万个长连接,但绝大多数时间在"静默等消息"。用"每连接一线程":

问题的本质是错配:线程是"内核调度单位",而我们想表达的是"一个业务处理流程"。业务流程数量巨大且多数在等待,却被迫一一对应到昂贵的内核对象。

两条出路:

  1. 回调 + 事件循环(Reactor):少量线程 + epoll。性能极好,但代价是代码被切碎成回调,出现"回调地狱",异常与上下文难传递(Netty、Node.js 的世界)。
  2. 协程:保留"顺序写代码"的直觉,但把"阻塞点"变成用户态挂起——遇到 I/O 未就绪时,运行时保存这段执行流的现场,把载体线程让给别的协程,等 epoll 通知就绪后再恢复。等于"手写回调"的自动化。
1 : 1 模型(平台线程) M : N 模型(协程 / 虚拟线程) 10 万连接 = 10 万业务流程 10 万个 Java 平台线程 每个预留 ~1MB 栈 · 每个对应一个 task_struct 内核调度器(运行队列里 10 万个任务) 阻塞 read() ⇒ 进内核 ⇒ 换下线程 切换 1~10 μs + 缓存被冲刷 内存与调度双重爆炸 → 这就是 C10K 问题 10 万连接 = 10 万协程(廉价对象) 栈在堆上,几百 B 起,按需增长 用户态调度器(ForkJoinPool / Go sched) 挂起 = 保存少量寄存器 + 换栈,几十 ns T1T2T3T4 载体线程数 ≈ CPU 核数(几乎不被换下) 底层仍是 epoll:真正等 I/O 的只有 1 个线程 阻塞语义保留在代码里,阻塞成本被搬到用户态
图 1 1:1 线程模型 vs M:N 协程模型:协程没有消灭"等待",只是把"等待的记账方式"从内核搬到了用户态
一句话记住:协程 = 把线程的"阻塞挂起"能力,用用户态数据结构重新实现一遍。它省下的是"内核调度 + 切换 + 栈内存",换来的是"运行时必须接管所有阻塞点"(这也是它最大的坑,见下)。

1.5 有栈 / 无栈协程、green thread、虚拟线程

green thread(绿色线程)

历史概念:完全由用户态运行时调度、不映射到内核线程的线程(早期 JVM 1.1 的绿色线程、Erlang 进程)。它的致命缺陷是:无法利用多核(整个运行时就一个内核线程),且一个协程调用阻塞式系统调用会卡死全部。现代做法都是 M:N(M 个协程映射到 N 个内核线程),Java 21 的虚拟线程本质是"回到 green thread,但做成 M:N 且与 JDK 阻塞点深度集成"。

有栈协程(stackful)

每个协程有自己的一小块可增长栈(Go goroutine 初始 2 KB,Kotlin/libco 类似思路)。挂起时把栈指针和寄存器存起来即可,因此可以在任意深度的函数嵌套里挂起

无栈协程(stackless)

没有独立栈,编译器把带 async/await 的函数改写成状态机,局部变量提升成状态机对象的字段,挂起就是"记住当前状态号后返回"。Rust async、C# async/await、JS Promise、Kotlin 的 suspend(编译成 CPS 状态机)都属这类。

Java 21 虚拟线程:有栈协程的 JVM 版

虚拟线程(Loom)是有栈路线:栈帧存在堆上(StackChunk),挂起时把栈从载体线程"卸下"(unmount),恢复时再"装上"(mount)。JDK 把 socket、Thread.sleepBlockingQueue 等阻塞点全部改造成"挂起虚拟线程 + 注册到 epoll/Poller",所以你写老式阻塞代码也能得到 Reactor 的伸缩性。

协程的共同陷阱(务必记住):运行时只能接管"它知道的阻塞点"。若协程里执行了它不认识的阻塞——如 JNI/本地方法、synchronized 里长时间阻塞(早期 Loom 会 pin 住载体线程)、CPU 密集死循环、文件 I/O(Linux 上普通文件 I/O 无法用 epoll,Loom 只能用额外线程池兜底)——就会把载体线程钉死,几个这种协程就能让"百万并发"退化成"几个线程的吞吐"。这就是"协程不是免费午餐"的技术根因。
维度有栈协程无栈协程
实现机制独立可增长栈 + 换栈指针编译期改写为状态机
能否深层挂起能(任意调用深度)不能(只能在 async 函数内)
内存开销KB 级字节~百字节级
生态侵入低(老代码直接受益)高(async 传染)
代表Go goroutine、Java 虚拟线程Rust async、C# async、JS、Kotlin suspend
→ 映射到框架:Tomcat(传统) = 1:1 线程池;Netty / WebFlux = 少量线程 + 回调(无栈思想的手工版);Java 21 + Spring Boot 3.2 虚拟线程 = 有栈协程,让"阻塞写法 + 高并发"重新兼容。三者不是新旧替代,而是"编程模型友好度"与"运行时控制力"的取舍。

2. CPU 与内存层级:性能的物理上限

2.1 存储金字塔与延迟数量级

现代 CPU 一个时钟周期约 0.3 ns,而一次主存访问要 ~100 ns ≈ 300 个周期。这意味着:CPU 大部分时间在等内存。所有数据结构的性能,最终都要用"缓存命中率"来解释。

层级容量量级延迟换算成"人类尺度"(1 周期 = 1 秒)
寄存器几百字节< 1 周期就在手上
L1 cache32–64 KB / 核~1 ns(4 周期)4 秒(伸手拿抽屉)
L2 cache0.5–2 MB / 核~4 ns(12 周期)12 秒(走到书架)
L3 cache8–64 MB / 共享~15–40 ns约 1 分钟(去隔壁办公室)
主存 DRAMGB~80–120 ns约 5 分钟(下楼取快递)
NVMe SSD 随机读TB~20–100 μs约 2–3 天
机械盘寻道TB~5–10 ms约 半年 ~ 1 年
同机房网络 RTT~0.1–0.5 ms约 数天 ~ 2 周
跨地域 RTT~30–100 ms约 3–10 年
一句话记住:这张表就是整套后端知识的"物理常数表"。为什么要缓存(Redis)?因为内存比磁盘快千倍。为什么 B+ 树而不是二叉树?因为磁盘按块读,要让一次 I/O 搬回尽量多的有用信息。为什么顺序写日志(WAL)?因为顺序 I/O 比随机 I/O 快两三个数量级。为什么怕跨机房强一致?因为一次 RTT 就是几十毫秒。后面所有章节都在还这张表的债。

缓存不是按字节搬,而是按"行"搬

CPU 与内存之间的最小传输单位是缓存行(cache line),x86 与多数 ARM 上是 64 字节(Apple Silicon 部分层级 128 字节)。你读 arr[0] 一个 int,实际会把包含它的 64 字节整行拉进 L1。

这一条事实直接推出两个重要结论:

  1. 顺序访问远快于随机访问(即使都在内存里)。遍历 int[] 时,每 16 个元素才 miss 一次,而且硬件预取器会提前把下一行取来;遍历链表则每个节点都可能 miss。这就是为什么 ArrayList 通常比 LinkedList 快得多,即使理论复杂度相同。
  2. 不相关的两个变量若挤在同一行,会互相拖累 —— 即伪共享。

2.2 缓存行与伪共享(false sharing)

多核各有自己的 L1/L2。硬件必须保证"同一块内存在各核看到的值一致",这靠缓存一致性协议(MESI):每个缓存行在每个核里处于 Modified / Exclusive / Shared / Invalid 之一。核 A 要写某行,必须先让其他核的该行副本失效(Invalid),自己独占。

问题来了:失效的粒度是整行 64 字节,不是单个变量。若变量 a(核 0 频繁写)和 b(核 1 频繁写)恰好在同一行,两个核就会反复把这一行抢来抢去——明明各写各的、逻辑上零冲突,性能却像加了锁。这就是伪共享

① 伪共享:a 与 b 在同一条 64B 缓存行 Core 0 只写 a Core 1 只写 b a b 其他无关字段… 一条缓存行 = 64 字节(图中每格 8 字节)· 一致性协议的失效粒度 = 整行 Core0 写 a ⇒ 发失效消息 ⇒ Core1 中这一整行变 Invalid Core1 写 b ⇒ 又要把行抢回来 ⇒ 行在两核间来回弹(ping-pong) 结果:逻辑上毫无共享,实测吞吐可能只有正常的 1/5 ~ 1/10 ② 填充(padding)后:各占独立缓存行,互不干扰 a padding(补满 64B) b padding(补满 64B) Java:@Contended(需 -XX:-RestrictContended)· 手写 long p1~p7 填充 · Disruptor / LongAdder 都这么干
图 2 伪共享与填充:一致性协议的失效粒度是"行",不是"变量"
一句话记住:伪共享 = 共享的是缓存行,不是数据。解法只有一个思路:把高频写的变量隔到不同缓存行(填充 / @Contended / 拆分成每线程独立槽位)。
→ 映射到框架:

2.3 MESI、store buffer 与内存屏障

既然 MESI 已经保证一致性,为什么还需要 volatile 和内存屏障?因为 CPU 为了不被"等失效确认"拖慢,加了两个缓冲:

再加上编译器指令重排、CPU 乱序执行与投机执行,程序的实际执行顺序 ≠ 你写的顺序。这不是 bug,是为了榨干流水线;代价是并发下的直觉失效。

于是硬件提供内存屏障(memory barrier / fence),让你在需要的地方"手动补上顺序保证":

屏障(概念名)含义x86 实现
LoadLoad屏障前的读,先于屏障后的读完成x86 天然保证(TSO)
StoreStore屏障前的写,先于屏障后的写对外可见x86 天然保证
LoadStore屏障前的读,先于屏障后的写x86 天然保证
StoreLoad(最贵)屏障前的写必须真正落到缓存,才允许后面的读执行mfencelock addl $0,(%rsp)

x86 是较强的内存模型(TSO),只需要防 StoreLoad 重排;ARM/POWER 是弱内存模型,四种都可能重排,所以同一段并发 Java 代码在 x86 上"碰巧对",换到 ARM 服务器(如鲲鹏、Graviton、Apple Silicon)就可能暴雷。这就是为什么必须按 JMM 写代码,而不是按实验现象写代码。

volatile 的底层真相

// Java 源码
volatile boolean flag;
flag = true;      // 写

// HotSpot 在 x86 上生成(简化)
movb  $1, flag(%rip)
lock  addl $0, (%rsp)   ← 一条"空操作"的 lock 前缀指令,副作用是全屏障(StoreLoad)
                          效果:① 把 store buffer 刷出去(可见性)② 禁止跨越它重排(有序性)
一句话记住:volatile = 禁止重排 + 强制刷/失效缓存,让"写完立刻对别人可见、读一定读最新"。它不提供原子性volatile int i; i++ 依然是"读-改-写"三步,仍会丢更新。要原子性得用 CAS 或锁。

2.4 原子性 / 可见性 / 有序性:并发三件事

把上面的机制收敛成三个正交问题,后面看任何并发工具都用这三把尺子量:

性质被什么破坏底层根因Java 里的解法
原子性线程切换发生在多步操作中间i++ 编译成 load / add / store 三条指令synchronizedLockAtomicXxx(CAS)
可见性写留在 store buffer / 读命中旧的本地缓存行store buffer + invalidate queuevolatilefinal、锁的释放/获取语义
有序性编译器与 CPU 重排乱序执行、投机执行、编译优化volatile、屏障、happens-before 规则

happens-before 是 JMM 给你的"公理系统"(你会喜欢这个类比):它不描述时间先后,而是描述"可见性的偏序关系"。核心几条:程序顺序规则、监视器锁规则(unlock 先于后续 lock)、volatile 规则(写先于后续读)、线程启动/终止规则、传递性。只要你能用这些公理推出 A hb B,就保证 A 的结果对 B 可见;推不出来,就必须假设可能出错。

→ 映射到框架:典型双重检查单例必须写 private static volatile Singleton inst;,否则 new 内部"分配内存 → 初始化 → 赋值引用"可被重排为"分配 → 赋值 → 初始化",别的线程会拿到非空但未初始化完的对象。Spring 三级缓存、各类懒加载容器都要处理同一问题。

3. 并发原语:CAS 与锁的层次

3.1 CAS:硬件给的唯一"原子武器"

互斥的本质需求是"读-改-写"不可被打断。软件层面无论怎么写都做不到(任何两条指令之间都可能被切换),所以必须由硬件提供一条原子指令。x86 给的是 cmpxchglock 前缀,ARM 给的是 LL/SC 对(ldaxr/stlxr)。语义:

CAS(内存地址 V, 期望值 A, 新值 B):
    原子地执行 { if (*V == A) { *V = B; return true; } else return false; }

lock 前缀在现代 CPU 上不是"锁总线",而是锁缓存行(cache locking):在这条指令期间独占该行并阻止别人拿走。这就是为什么 CAS 的成本与缓存行争抢强相关——同一行上 N 个核狂 CAS,性能会随核数增加而下降

// Java 里 CAS 的标准用法:失败就重试(自旋)
public final int getAndAdd(int delta) {
    int v;
    do { v = get(); }                          // 读当前值
    while (!compareAndSet(v, v + delta));      // 变了就重来
    return v;
}
一句话记住:CAS = "我猜它还是旧值,是就改,不是就重来"。它把"阻塞等待"换成了"失败重试",所以低冲突时极快(无系统调用、无切换),高冲突时反而更差(自旋烧 CPU + 缓存行弹跳)。这就是乐观锁的适用边界。

3.2 ABA 问题与版本号

CAS 只比较"值相等",无法区分"从没变过"和"变走又变回来了"。若值是指针或有业务含义的状态,这个区别就是致命的。

初始:栈顶 = A,A.next = B(栈:A → B → C)
线程1:读到栈顶 A,准备 CAS(top, A, B) 完成弹出         ← 此刻被切走
线程2:弹出 A、弹出 B,再压入 A                          ← 栈变成:A → C,B 已被回收/复用
线程1:恢复,CAS(top, A, B) —— 值仍是 A,CAS 成功!      ← 栈顶变成已被释放的 B
结果:链表结构被破坏,出现悬垂引用 / 数据丢失

解决思路只有一条:给值加上"变更历史"的信息,让"变回来"也能被识别。

方案做法代价 / 备注
AtomicStampedReference把 (引用, int 版本号) 作为整体 CAS,每次修改版本号 +1Java 中的标准答案;内部用一个不可变 Pair 对象,需额外分配
AtomicMarkableReference(引用, boolean 标记),只关心"是否被动过一次"比版本号轻,用于逻辑删除标记(如无锁链表的 mark 位)
双字 CAS / 指针打标cmpxchg16b;或利用地址对齐的低位空闲比特存版本C/C++ 常见;省内存但可移植性差
数据库乐观锁UPDATE t SET v=?, version=version+1 WHERE id=? AND version=?完全同构:ABA 的工程解法与 MySQL 乐观锁是同一个思想
回避法:不复用版本号永不回绕、节点不立刻回收(GC / hazard pointer / epoch)Java 有 GC,天然缓解了"内存被复用导致的 ABA",这也是为什么 Java 里 ABA 比 C++ 少见
直觉类比:你把钱包放桌上(值 = 钱包)。回来时钱包还在原位——但可能有人拿走用过又放回。只看"钱包在不在"是不够的,要看"封条编号变没变"。版本号就是封条。

3.3 乐观锁 vs 悲观锁

这不是两种 API,而是两种关于"冲突概率"的假设。整个数据库、缓存、分布式协调的设计都在这条轴上取舍。

维度悲观锁(先占再改)乐观锁(先改再验)
假设冲突大概率发生冲突小概率发生
流程加锁 → 操作 → 解锁;冲突者阻塞等待读版本 → 计算 → CAS/带版本更新;冲突者重试或失败
开销来源阻塞与唤醒(可能进内核)、死锁风险重试的白工、活锁风险、缓存行争抢
临界区适合长临界区(含 I/O、复杂计算)极短临界区(几条指令)
底层机制互斥量、信号量、行锁(MySQL for updateCAS、版本号、MVCC 读快照
Java 代表synchronizedReentrantLockAtomic*LongAdderConcurrentHashMap 的桶级 CAS
中间件代表MySQL 行锁/间隙锁、Redis 分布式锁、ZooKeeper 临时顺序节点锁MySQL MVCC + version 字段、Redis WATCH/MULTI、ES 的 _seq_no、etcd CAS 写
一句话记住:冲突少 → 乐观(省掉等待);冲突多 → 悲观(省掉白工)。秒杀同一商品是"高冲突",用乐观锁会疯狂重试,反而应该悲观锁 / 队列串行化;而"更新用户各自的资料"是低冲突,乐观锁最合适。这条判断在数据库、缓存、并发容器里通用。

3.4 自旋锁 vs 互斥锁与 futex 思想

拿不到锁时怎么办?只有两个选择——原地转圈等(自旋),或睡过去让别人跑(阻塞)

维度自旋锁互斥锁(阻塞锁)
拿不到锁时while(!CAS(...)) { pause; } 空转,占着 CPU系统调用把自己挂到等待队列并让出 CPU
成本无切换;但白烧 CPU 时间片两次上下文切换(睡 + 醒)≈ μs 级 + 缓存污染
适用临界区极短多核;持锁者正在另一个核上跑临界区较长 / 含 I/O / 单核 / 竞争激烈
致命场景单核上自旋 = 死等(持锁者拿不到 CPU 就永远不放锁);等待者多时全员烧 CPU短临界区时"切换成本 > 临界区本身",纯亏
典型实现Linux 内核 spin_lock、JVM 自适应自旋、Thread.onSpinWait()(发 pause 指令省电并让出流水线)pthread_mutexReentrantLock 的 park/unpark(底层 futex

futex:两个世界的缝合线

真实的 pthread_mutex 既不是纯自旋也不是每次都进内核,而是 futex(fast userspace mutex)

lock():
  ① 用户态 CAS 抢锁            → 没人竞争(绝大多数情况):完全不进内核,成本 ≈ 一条 CAS 指令 ✅
  ② 抢不到,短暂自适应自旋      → 赌持锁者马上就放
  ③ 还不行:futex(FUTEX_WAIT)   → 才进内核,挂进等待队列睡下去
unlock():
  ① 用户态 CAS 释放
  ② 若标记显示"有人在等"       → futex(FUTEX_WAKE) 进内核唤醒一个
一句话记住:futex 的设计哲学 = "无竞争时零内核开销,有竞争时才付内核代价"。这条哲学在整个系统栈里反复出现:JVM 的偏向/轻量级锁、Netty 的无锁队列、数据库的 latch、Go 的 sync.Mutex(也是自旋后再 park),全都是同一招——把"快路径(fast path)"做成纯用户态

3.5 JVM 偏向 / 轻量 / 重量级锁

synchronized 不是一上来就用操作系统互斥量,而是根据竞争情况单向膨胀。锁状态记在对象头 Mark Word 里:

状态机制成本一句话
无锁Mark Word 存 hashCode / 分代年龄0没人加锁
偏向锁Mark Word 记下线程 ID,同一线程再进只需比对 ID几乎为 0赌"永远只有一个线程用它"(JDK 15 默认关闭,JDK 18 彻底移除——因为现代应用多线程普遍,撤销偏向的开销大于收益)
轻量级锁线程栈上建 Lock Record,CAS 把 Mark Word 指向它;失败则自适应自旋一次 CAS + 可能自旋赌"竞争是交替的、很短的"——本质就是自旋锁
重量级锁膨胀出 ObjectMonitor,竞争者进 _EntryListpark() → 底层 futex 睡眠系统调用 + 上下文切换放弃赌博,交给操作系统排队
一句话记住:JVM 锁膨胀 = 把"乐观→自旋→阻塞"这条通用阶梯自动化了。它和 futex 是同一个思想在不同层的实现:先赌无竞争(最便宜),赌输了逐级加码,最后才付内核的钱。
易错点:锁只能升不能降(不会从重量级退回轻量级,只能在安全点批量撤销/清理)。所以一次剧烈的竞争会让这个锁"永久变贵"——这就是"锁粒度要细、临界区要短、不要在锁里做 I/O"的底层理由。ConcurrentHashMap 从分段锁改为"桶级 CAS + 单桶 synchronized",正是把竞争概率压到最低,让绝大多数操作停在"无锁/轻量"级别。

4. I/O 模型演进:从 BIO 到 io_uring

4.1 一次 read() 到底发生了什么(两个阶段)

这是全章的钥匙。当你对一个 socket 调用 read(fd, buf, len),实际有两个完全不同的阶段

  1. 阶段①:等数据就绪。网卡收到包 → DMA 写入内存 → 触发中断 → 内核软中断处理协议栈 → 数据放进该 socket 的内核接收缓冲区。这个阶段的耗时取决于对端和网络,可能是 1 μs,也可能是 30 秒。
  2. 阶段②:把数据从内核缓冲区拷到用户缓冲区。纯 CPU 内存拷贝,耗时取决于数据量,通常很短且可预期。
一句话记住:"阻塞 / 非阻塞"说的是阶段①要不要等;"同步 / 异步"说的是阶段②由谁做。把这两句话记牢,五种 I/O 模型不用背,可以现场推导出来。

4.2 四象限:阻塞/非阻塞 × 同步/异步

阶段①(等数据就绪):阻塞 阶段①:非阻塞 阶段② (内核→用户 拷贝): 同步(自己拷) 阶段②: 异步 (内核拷完 再通知你) 阻塞 I/O(BIO) 线程调 read() 后一路睡到数据拷完 两个阶段都在等 ⇒ 1 连接必须占 1 线程 ✔ 代码最直观 ✘ 连接数 = 线程数 代表:Socket.getInputStream().read() Tomcat BIO 模式 · JDBC · 传统 RPC 客户端 线程数即并发上限,栈内存与切换是天花板 非阻塞 + I/O 多路复用 阶段①交给 select/poll/epoll 统一等 就绪后你自己 read()做阶段②拷贝 ✔ 1 线程管 10 万连接 ✘ 回调式编程 代表:Netty · Redis · Nginx · Node.js Java NIO Selector · WebFlux ★ 今天 99% 的高性能服务都站在这一格 阻塞 + 异步(罕见 / 退化) 让内核负责拷贝,自己却又阻塞着等通知 语义上互相抵消,工程价值很低 近似例:信号驱动 I/O(SIGIO); 或 io_uring 提交后立刻阻塞等 CQE 列出来只为让四象限完整 —— 记住 "两个维度正交"这件事本身更重要 真异步 I/O(AIO) 提交请求立即返回,内核把数据放好 再回调/投递完成事件 ⇒ 两阶段都不等 ✔ 理论最优 ✘ 错误处理与生命周期最难 代表:Windows IOCP(成熟)· Linux AIO (长期残缺)· io_uring(真正的答案) Java AIO 在 Linux 上是 epoll 模拟的,故 Netty 弃用
图 3 I/O 四象限:横轴看"阶段①要不要等",纵轴看"阶段②谁来拷"
为什么说"多路复用仍是同步 I/O"?因为 epoll_wait 只告诉你"可读了",真正的 read() 拷贝还是你的线程亲自执行的。它省掉的是"为每个连接配一个线程去等",不是"拷贝"。这是面试与理解的分水岭。

4.3 BIO:每连接一线程为什么撑不住

// 经典 BIO 服务器:结构极清晰,扩展性极差
ServerSocket ss = new ServerSocket(8080);
while (true) {
    Socket s = ss.accept();               // 阻塞:没连接就睡
    new Thread(() -> {                    // 每个连接一个线程
        InputStream in = s.getInputStream();
        byte[] buf = new byte[1024];
        int n = in.read(buf);             // 阻塞:没数据就睡 ← 线程被"钉死"在这里
        // ... 处理并回写
    }).start();
}

三笔无法回避的账:

线程池能缓解(Tomcat 的做法),但改变不了本质:并发上限 ≈ 线程数。若业务再有慢调用(下游超时 3 秒),线程被占住,整个池瞬间耗尽 —— 这就是"线程池打满 + 雪崩"的经典成因,也是 Hystrix/Sentinel 隔离与熔断存在的理由。

→ 注意 BIO 并未过时:连接数少、每连接吞吐大(如数据库连接池、批处理、内部同步 RPC)时,BIO 代码简单、延迟低、无回调复杂度,是正确选择。Java 21 虚拟线程更是让"BIO 写法 + 高并发"重新可行。取舍的判据是"连接数量级"和"空闲比例",不是"新旧"。

4.4 NIO:Buffer / Channel / Selector

Java NIO 的三个核心概念,各自对应一个底层事实:

概念对应的底层事实要点
Buffer系统调用需要一段地址稳定的内存position/limit/capacity 三指针;flip() 是"写模式→读模式"。DirectByteBuffer 分配在堆外,因为 JVM 堆会被 GC 移动对象,内核拿着的地址可能失效 —— 所以用堆内 buffer 做 I/O 时,JDK 内部会先复制一份到堆外再发起 syscall(这就是 Netty 默认用直接内存的根因)
Channelfd(文件描述符)的面向对象封装双向(可读可写),支持 transferTo(零拷贝入口)
Selectorepoll / kqueue / IOCP 的跨平台外壳select() = 一次 epoll_waitSelectionKey = 感兴趣的事件集合 + 附件
Selector sel = Selector.open();                          // ← Linux 上其实是 epoll_create
ch.configureBlocking(false);
ch.register(sel, SelectionKey.OP_READ);                  // ← epoll_ctl(ADD)
while (true) {
    sel.select();                                        // ← epoll_wait:只在有事件时返回
    for (SelectionKey k : sel.selectedKeys()) {          // 只遍历"就绪的",不是全部连接
        if (k.isReadable()) { ((SocketChannel)k.channel()).read(buf); }  // 阶段②仍是自己拷
    }
    sel.selectedKeys().clear();                          // 必须手动清,否则下轮重复处理
}
为什么大家不直接用 Java NIO 而用 Netty?NIO 只把 epoll 包了一层,剩下的脏活全留给你:半包/粘包处理、写缓冲区满时的OP_WRITE注册与反注册、空轮询 bug(Linux epoll 的 select() 立即返回 0 导致 CPU 100%,Netty 用"计数达阈值就重建 Selector"绕开)、内存池、线程模型、异常与连接生命周期。Netty ≈ NIO + 十几年踩坑经验的固化。

4.5 select / poll:O(n) 与 fd 限制

多路复用的第一代思路很朴素:"你把要关心的 fd 都给我,我帮你一起看"

int select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *t);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

它们的结构性缺陷(不是实现不好,是接口设计决定的):

  1. 每次调用都要把整个 fd 集合从用户态拷进内核,返回时再拷出来。10 万个 fd = 每次都搬一遍。
  2. 内核内部 O(n) 遍历:轮询每个 fd 问"你就绪了吗"。
  3. 返回值只告诉你"有几个就绪",不告诉你是哪些 ⇒ 用户态还要再 O(n) 扫一遍找出来。
  4. select 有硬上限fd_set 是位图,大小 FD_SETSIZE(通常 1024),且编译期固定;poll 用数组,突破了数量限制,但 1、2、3 三条毛病一个没少。
  5. selectfd_set 是入参出参共用,每轮循环必须重新 FD_SET 一遍。

核心矛盾:开销与"总连接数 n"成正比,而实际收益只与"活跃连接数 k"成正比。长连接服务里 k ≪ n(10 万连接可能只有几百个活跃),于是 99% 的工作都是白干。

4.6 epoll:红黑树 + 就绪链表 + 回调,LT/ET

epoll 的突破在于换了信息流的方向:不再"每次问一遍所有 fd",而是让数据到达时主动把 fd 登记到就绪表里——从轮询(polling)改成事件驱动(callback)。三个 API 各司其职:

int ep = epoll_create1(0);                       // ① 在内核创建 eventpoll 对象(含红黑树 + 就绪链表)
epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev);          // ② 注册"一次",长期有效:插入红黑树 O(log n)
                                                 //    同时在该 fd 的等待队列上挂 ep_poll_callback
int n = epoll_wait(ep, events, max, timeout);    // ③ 只取就绪链表里的 k 个,O(1) 拿结果

关键机制:网卡收包 → 协议栈把数据放入 socket 接收缓冲区 → 唤醒该 socket 的等待队列 → 触发 ep_poll_callback把该 fd 的 epitem 挂到 eventpoll 的就绪链表 rdllistepoll_wait 只需看这条链表空不空。

select / poll:开销 ∝ 总连接数 n epoll:开销 ∝ 活跃连接数 k 用户态:fd 集合 [3, 5, 7, … , 100002] n = 10 万,其中真正活跃的只有 k ≈ 200 每次调用全量拷入内核 内核:for (每个 fd) 调 f_op->poll() 逐个问 O(n) 遍历 10 万次 没有任何"谁就绪"的记忆,每轮从零问一遍 只返回"就绪个数" 用户态:不知道是哪些 fd 就绪 只能再 O(n) 扫一遍 10 万个 fd select 还受 FD_SETSIZE=1024 限制 结构性问题(接口决定,优化不掉) · 每轮重复传参:n 次拷贝 · 内核 O(n) + 用户态 O(n) 双重遍历 · 无状态:内核不"记住"你关心什么 epoll_ctl(ADD):每个 fd 只注册一次 内核"记住"了你关心什么 ⇒ 后续零传参 红黑树 rbr 存全部被监听 fd 增/删/查 O(log n) 就绪链表 rdllist 只存已就绪的 k 个 取用 O(1) 网卡中断 → 协议栈填 socket 缓冲区 → 回调 ep_poll_callback → 把 fd 挂进 rdllist 从"轮询"翻转为"事件推送",这是本质突破 epoll_wait:只拷 rdllist 里的 k 个 直接给出"是哪些 fd 就绪",用户态无需再扫 10 万连接 200 活跃:只做 200 份工作
图 4 select/poll vs epoll:从"每轮问遍所有人"到"就绪者自己登记"
一句话记住:epoll 快,不是因为算法更聪明,而是因为它把"内核记住你关心哪些 fd"变成了持久状态(红黑树),并把"谁就绪"从轮询查询改成回调登记(就绪链表)。只为活跃连接付费 —— 这就是 C10K/C100K 的通行证。

LT(水平触发)vs ET(边缘触发)

模式触发条件编程要求取舍
LT(默认)只要缓冲区还有数据,每次 epoll_wait 都报告可以只读一部分,剩下的下轮再读;不易漏事件安全、容错好;同一 fd 可能被反复唤醒(有额外开销),且需注意"写事件常态可写"会导致空转,所以 OP_WRITE 要按需注册/注销
ET只在状态发生变化时(不可读→可读)报告一次必须循环读到 EAGAIN,否则剩余数据再也不会通知你 → 连接假死;必须配非阻塞 fd唤醒次数最少、性能上限更高;但代码稍有疏漏就是"消息丢了/卡住"这类难查 bug
// ET 模式的强制写法:必须把数据抽干
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0)  { handle(buf, n); continue; }                 // 继续抽
    if (n == 0) { close(fd); break; }                          // 对端关闭
    if (errno == EAGAIN || errno == EWOULDBLOCK) break;        // ← 抽干了,这才允许退出
    if (errno == EINTR) continue;                              // 被信号打断,重试
    close(fd); break;
}
→ 映射到框架:Netty 默认 LT(EpollMode.LEVEL_TRIGGERED),因为它要保证通用场景下的正确性与背压可控;EpollSocketChannel 也支持切 ET。Nginx 用 ET,因为它自己完全掌控读写循环,愿意用更严格的代码换更少的唤醒。LT vs ET = "容错性" vs "极限性能"的经典取舍。

4.7 kqueue / IOCP:同一思想的不同方言

机制平台特点
epollLinux红黑树 + 就绪链表 + 回调;只管"就绪通知"(Readiness)
kqueueBSD / macOS同样是"注册一次 + 就绪队列",但更统一:一个 kevent 接口同时处理 socket、文件变更(vnode)、信号、定时器、进程退出,EV_SET 可批量提交。设计上比 epoll 更正交
IOCPWindows完成通知(Completion)而非就绪通知:真异步,内核把数据拷好后往完成端口投递结果,还自带线程池调度。这就是"Windows 的 AIO 早已成熟、Linux 直到 io_uring 才补上"的原因
Java Selector跨平台屏蔽差异:Linux 走 epoll、macOS 走 kqueue、Windows 走 select(旧实现,性能一般)。Netty 额外提供 EpollEventLoopGroup / KQueueEventLoopGroup 用 JNI 直连原生 API,绕开 JDK 抽象损耗并暴露 SO_REUSEPORTTCP_FASTOPEN 等高级选项

4.8 io_uring:SQ/CQ 双环与内核旁路

epoll 已经解决了"等"的问题,但仍留两个成本:每次真正读写都要一次系统调用阶段②的拷贝仍由用户线程做。而 Linux 原生 AIO(libaio)长期只支持 O_DIRECT 文件、不支持 socket,形同残废。io_uring(Linux 5.1+)给出了真正的答案。

核心设计:用两个共享内存的环形队列取代"每次调用"

用户态                              共享内存(mmap,双方直接读写,无需拷贝)                        内核
                     ┌──────────────────────────────────────────────┐
写入请求  ─────────►  │  SQ (Submission Queue) 提交队列:SQE 数组     │  ────────►  内核消费并执行
                     └──────────────────────────────────────────────┘
                     ┌──────────────────────────────────────────────┐
读取结果  ◄─────────  │  CQ (Completion Queue) 完成队列:CQE 数组     │  ◄────────  内核写回结果
                     └──────────────────────────────────────────────┘
io_uring_enter():一次系统调用可提交 N 个请求 + 收割 M 个结果(批量化)
SQPOLL 模式:内核起一个轮询线程盯着 SQ ⇒ 稳态下用户态"完全不需要系统调用"

它同时解决了三件事:

一句话记住:epoll 优化的是"怎么少等",io_uring 优化的是"怎么少进内核"。它的思想(共享内存环形队列 + 批量 + 内核旁路)与 DPDK、SPDK、RDMA、Disruptor、Kafka 的批量发送完全同源:把"逐次交互"变成"批量流水线"。
现状与取舍:io_uring 收益在高 IOPS(NVMe、百万级 QPS 代理)场景最明显;但它接口复杂、内核版本要求高,并且历史上出现过较多安全漏洞(部分云厂商与容器运行时默认禁用)。Java 生态:Netty 提供 io_uring 传输(孵化中);Rust 的 tokio-uring、C 的 liburing 更成熟。今天绝大多数业务的瓶颈在业务逻辑与数据库,不在 epoll,别为 io_uring 提前优化。

4.9 Reactor:把多路复用封装成编程模型

epoll 是"机制",Reactor 是"把机制用起来的模式":一个(或几个)线程跑事件循环,epoll_wait 拿到就绪事件后分派(dispatch)给对应 handler。

形态结构代表
单 Reactor 单线程一个线程负责 accept + read + 业务 + writeRedis(业务极短、纯内存,所以够用;顺带消灭了全部锁)
单 Reactor 多线程一个线程管 I/O,业务丢给线程池早期实现;I/O 线程易成瓶颈
主从 Reactor 多线程mainReactor 只管 accept,subReactor 组(每个绑一个线程)管已连接 socket 的读写,业务可再交业务线程池Netty 标准姿势bossGroup + workerGroup);Nginx 是"多进程 + 各自 epoll + SO_REUSEPORT"的变体

Netty 的两条关键设计,都是前面底层结论的直接产物:

一句话记住:Reactor = epoll + 事件分派 + "一连接绑一线程串行化"。后面所有异步框架(Netty、WebFlux、Vert.x、Node.js、Nginx)都是这句话的变体。Part 02 会把 Netty 拆开验证这一点。

5. 零拷贝:为什么"拷贝"是头号大敌

5.1 拷贝与切换的账本

考虑最常见的操作:把磁盘上的文件发给网络对端(静态资源服务、Kafka 消费者拉消息、文件下载)。传统写法 read(file) + write(socket) 的真实代价:

用户态内存 内核态内存 硬件 红色箭头 = CPU 亲自搬(贵) 绿色箭头 = DMA 搬(CPU 不参与) ① 传统 read() + write():4 次拷贝,4 次用户/内核态切换 磁盘 文件数据 Page Cache 内核读缓冲 用户缓冲区 byte[] / ByteBuffer Socket 缓冲区 内核写缓冲 网卡 ①DMA ②CPU ③CPU ④DMA 切换 4 次:read 进内核→回用户(2 次)+ write 进内核→回用户(2 次) 拷贝 4 次:2 次 DMA(省不掉,必须过内存)+ 2 次 CPU 拷贝(纯属浪费) ②③ 的数据在用户态根本没被使用——只是"过个手"就送回内核,这就是可优化空间 ② sendfile + SG-DMA:2 次拷贝(全 DMA),2 次切换,CPU 拷贝 0 次 磁盘 文件数据 Page Cache 数据全程留在内核 网卡 SG-DMA 直接读取 ①DMA ②DMA(只把地址+长度描述符给网卡) 用户态:数据从不经过 只剩 1 次系统调用(sendfile / FileChannel.transferTo)⇒ 切换 2 次;CPU 完全不参与数据搬运 代价:数据不经用户态 ⇒ 应用无法查看或修改内容(不能压缩、不能加密、不能改协议头)
图 5 传统 read+write 与 sendfile 对比:零拷贝消灭的是"CPU 拷贝"和"多余的系统调用",不是全部拷贝
一句话记住:"零拷贝"不是一次都不拷(DMA 那两次是物理必需),而是零次 CPU 参与的拷贝。省下的是三样东西:CPU 周期、内存带宽、以及被拷贝数据挤走的缓存(这一项常被忽略但很关键)。

5.2 page cache:被低估的主角

Linux 会用所有空闲内存做文件缓存(page cache)。它的三个行为决定了很多中间件的设计:

推论(重要):数据库和消息队列常自己做缓存池(MySQL Buffer Pool 用 O_DIRECT 绕过 page cache),是因为它们比内核更懂访问模式(知道哪些页是索引热点、需要精确控制刷盘时机与 checkpoint);而 Kafka 反其道行之,完全依赖 page cache,因为它的模式就是"顺序追加写 + 顺序读最近数据",内核的预读与写回策略正好最优,自己再做一层缓存纯属重复。同一个底层机制,两种相反的取舍,判据是"访问模式是否与内核假设一致"。

5.3 mmap / sendfile / splice / MSG_ZEROCOPY 对比

技术机制CPU 拷贝 / 切换能否改数据适用与代表
mmap + write把 page cache 映射进用户地址空间,用户态指针直接读写文件内容(缺页时才真正加载)CPU 拷贝 1 次(page cache→socket buf);切换 4 次(像访问内存一样)随机读写、需要修改内容、小文件多;RocketMQ CommitLog(1G 分片 MappedByteBuffer)、MySQL/LevelDB 的部分索引、Lucene 段文件。坑:缺页中断成本、大文件占虚拟地址、msync 时机、Java 里 MappedByteBuffer 释放依赖 GC(需 Cleaner 手动清)
sendfilefd → fd 内核内直传;网卡支持 SG-DMA 时只传描述符0 次 CPU 拷贝;切换 2 次不能(数据不进用户态)纯转发大文件;Kafka 消费者拉取(FileChannel.transferTo)、Nginx sendfile on、Netty DefaultFileRegion。坑:TLS 会失效(要加密必须过用户态或用 kTLS)
splice通过管道作为中转,在两个 fd 间移动页引用(不限于"文件→socket",socket→socket 也行)0 次 CPU 拷贝;切换 2 次/段不能代理转发(socket↔socket),HAProxy 转发、TCP 代理;接口比 sendfile 通用但要管 pipe
MSG_ZEROCOPYsend() 时让网卡 DMA 直接读用户内存,避免拷进 socket 缓冲区;完成后通过 errqueue 异步通知"这块内存可以复用了"0 次 CPU 拷贝;切换 2 次+通知(数据本就在用户态)用户态生成的大块数据发送(>10 KB 才划算);需页锁定与完成通知,编程复杂。小包反而更慢
Java DirectByteBuffer堆外分配,绕开"堆内→堆外"的那次 JDK 内部复制省 1 次 CPU 拷贝Netty 默认使用;代价是分配/释放贵(故有内存池 PooledByteBufAllocator)、不受堆大小限制易 OOM(需 -XX:MaxDirectMemorySize 与泄漏检测)
Netty CompositeByteBuf逻辑零拷贝:把 header 与 body 两块内存"视图拼接",不做物理合并省合并拷贝协议编解码;slice()/duplicate() 同理(共享底层内存,注意引用计数与 retain()
一句话记住(取舍公式):要不要改数据 → 决定能不能用 sendfile/splice;数据在哪一侧生成 → 决定用 sendfile(内核侧/文件)还是 MSG_ZEROCOPY(用户侧);随机访问还是顺序转发 → 决定 mmap 还是 sendfile。零拷贝换来的代价永远是"应用失去对数据的直接控制权"。
→ 映射到框架(Kafka 高性能三支柱,全在本章):顺序追加写(对应 §2.1 磁盘顺序 vs 随机差两三个数量级); ② page cache + 预读/写回(对应 §5.2,几乎不自己管缓存); ③ sendfile 零拷贝(对应 §5.3,消费者拉取时数据不进 JVM 堆,也因此避免了 GC 压力)。 再加上"批量 + 压缩"(减少系统调用与网络往返,对应 §4.8 的批量化思想)。Kafka 没有任何魔法,全是本章底层结论的排列组合。

6. TCP 核心:可靠字节流是怎么造出来的

IP 层给你的是:可能丢、可能乱序、可能重复、可能损坏的数据包投递。TCP 要在这之上给出"有序、不丢、不重、有流控与拥塞控制的字节流"。它靠四件东西:序列号(排序与去重)、确认与重传(不丢)、滑动窗口(不把接收方压垮)、拥塞控制(不把网络压垮)。理解这四件事,就理解了 TCP 的全部。

6.1 三次握手与 SYN Flood

三次握手:同步双向序列号 四次挥手:各自关闭发送方向 Client Server CLOSED LISTEN ① SYN, seq = x SYN_SENT SYN_RCVD (进半连接队列) ② SYN+ACK, seq=y, ack=x+1 ③ ACK, ack = y+1 ESTAB. ESTABLISHED (移入全连接队列 等 accept 取走) 为什么必须 3 次?每一方的初始序列号 ISN 都必须被对方确认过 一次。①让服务端知道 x;②让客户端知道 y 且确认了 x; ③让服务端知道"y 已被确认"。2 次做不到双向确认,4 次多余。 SYN Flood:伪造源 IP 只发 ①,服务端为每个 SYN 分配半连接 条目并重发 SYN+ACK ⇒ 半连接队列打满 ⇒ 正常用户连不上。 解法:SYN Cookie(不存状态,把信息编码进 ISN)、缩短重试。 主动关闭方 被动关闭方 ESTAB. ESTAB. ① FIN(我没数据要发了) FIN_WAIT_1 CLOSE_WAIT (应用还能继续发) ② ACK FIN_WAIT_2 ↕ 半关闭:此期间被动方仍可发数据 ③ FIN(我也发完了) LAST_ACK ④ ACK TIME_WAIT CLOSED 等 2MSL (Linux 60s) CLOSED 为什么 4 次?TCP 全双工,两个方向各自独立关闭;被动方 收到 FIN 后可能还有数据没发完,所以 ACK 与自己的 FIN 通常不能合并(无数据可发时可合并为 3 次)。
图 6 TCP 三次握手与四次挥手的状态流转(左:建连;右:断连)

握手的本质:交换并确认初始序列号(ISN)

TCP 的一切可靠性都建立在序列号上。若两端不先就"从哪个号开始数"达成一致,就无法判断丢包、乱序、重复。ISN 还必须是随机的(基于时钟 + 哈希),否则攻击者能猜出序列号伪造报文注入连接。

"为什么不是两次?"教科书答案是"防止历史连接被错误建立"。具体场景:客户端发的一个旧 SYN 在网络里绕路迟到,服务端若两次握手就直接进 ESTABLISHED 并分配资源,而客户端早已放弃 —— 服务端资源就被白占。三次握手让客户端有机会用 RST 否决这个旧连接。更本质地说:可靠通信要求双方序列号都被确认过一次,这在两次交互里做不到。

两个队列与常见故障

队列装什么满了会怎样
半连接队列(SYN queue)收到 SYN、还没收到最后 ACK 的连接新 SYN 被丢弃 ⇒ 客户端超时重试、连接变慢。net.ipv4.tcp_max_syn_backlogtcp_syncookies=1 可在溢出时启用 SYN Cookie
全连接队列(accept queue)已完成握手、等应用 accept() 取走的连接tcp_abort_on_overflow:丢弃 SYN+ACK 的 ACK(默认,客户端重传)或直接 RST。应用 accept 太慢(业务卡住、线程池打满)就会打满这里 —— 用 ss -lntSend-Q/Recv-Q 可确诊
→ 映射到框架:Netty 的 ChannelOption.SO_BACKLOG 就是全连接队列长度(实际取 min(backlog, somaxconn));bossGroup 单线程只做 accept,就是为了尽快把连接从 accept 队列取走。TCP Fast Open(TFO)则允许在 SYN 里携带数据,省掉一个 RTT —— 与 TLS 1.3 的 0-RTT 同源:省 RTT 是所有网络优化的第一目标(回看 §2.1 的延迟表就懂了)。

6.2 四次挥手与 TIME_WAIT 的意义

TCP 是全双工的:两个方向是两条独立的流,各自需要单独关闭。所以 FIN 的语义不是"断开连接",而是"我这个方向不再发数据了"。这就是"半关闭"的由来(Java 里 shutdownOutput())。

TIME_WAIT 为什么必须存在(两条理由,都不能省)

  1. 保证最后那个 ACK 能重传。若第 ④ 个 ACK 丢了,被动方会重传 FIN。如果主动方已经彻底 CLOSED,它只会回一个 RST,被动方就会认为"连接异常终止"(数据可能被判为未送达)。停留 2MSL 就能在这段时间内响应重传的 FIN。
  2. 让本连接的"迷路报文"在网络中消亡。MSL(Maximum Segment Lifetime)是报文最大存活时间。等 2MSL 后,旧连接的所有残留分组都已过期。否则,若立刻用同样的四元组建新连接,一个迟到的旧数据包可能被新连接当成合法数据接收 —— 数据错乱,且这种 bug 极难复现定位。
一句话记住:TIME_WAIT 不是设计缺陷,而是为"四元组复用安全"和"可靠关闭"付的保险费。Linux 上固定 60 秒(TCP_TIMEWAIT_LEN,改需重编译内核)。
工程上的 TIME_WAIT 问题与正确解法:

6.3 滑动窗口:流量控制

最朴素的可靠传输是"发一个、等一个 ACK"(stop-and-wait),吞吐 = 报文大小 / RTT。1 KB / 100 ms = 10 KB/s —— 显然不可接受。解法是允许有多个未确认的报文在途(in-flight),这就是滑动窗口。

发送方视角:
  已发送且已确认 │ 已发送未确认(in-flight) │ 可立即发送 │ 不允许发送
 ───────────────┼──────────────────────────┼───────────┼─────────────
                └──────── 发送窗口 = min(接收方通告窗口 rwnd, 拥塞窗口 cwnd) ────┘
收到 ACK ⇒ 左边界右移(窗口"滑动")⇒ 右边可发送区域随之扩大

理论吞吐上限 = 窗口大小 / RTT      ← 这就是"带宽时延积(BDP)"
一句话记住:吞吐 = 窗口 / RTT。要提高吞吐只有两条路——加大窗口(缓冲区、窗口缩放)或降低 RTT(就近部署、连接复用省握手)。这条公式也直接解释了:为什么跨地域同步复制的吞吐一定上不去;为什么 Kafka 要靠"批量 + 多分区并行"而不是单连接猛发。

6.4 拥塞控制:慢启动 / 拥塞避免 / 快重传

流控保护接收方,拥塞控制保护网络。区别务必分清:rwnd 是"对端告诉你的",cwnd 是"你自己根据丢包/延迟猜出来的"—— 因为网络中间的路由器不会告诉你它快堵了(除非有 ECN)。TCP 只能把丢包当作拥塞信号,用"试探 + 退让"逼近可用带宽。

cwnd
  │            ╱╲  ← 丢包(超时):ssthresh = cwnd/2,cwnd 打回 1,重新慢启动
  │          ╱   ╲
  │        ╱      ╲___╱‾‾╲___  ← 快恢复后的"锯齿":加性增、乘性减(AIMD)
  │      ╱  线性增长(拥塞避免:每 RTT +1 MSS)
  │  ╱‾‾  ← ssthresh
  │ ╱  指数增长(慢启动:每 RTT 翻倍)
  └────────────────────────────────► 时间
阶段规则为什么这样设计
慢启动cwnd 从 1~10 MSS 开始,每收到一个 ACK 就 +1 MSS ⇒ 每 RTT 翻倍,直到达到 ssthresh"慢"指起点低,增长其实是指数级。刚上路不知道带宽多少,指数试探最快找到量级
拥塞避免超过 ssthresh 后,每 RTT 只 +1 MSS(线性)接近极限了,改用小步试探,避免一脚踩爆
快重传收到 3 个重复 ACK 就立即重传丢失的那个段,不等超时(RTO)重复 ACK 说明后续包能到(网络通畅),只是丢了一个 ⇒ 说明拥塞轻微,没必要等几百毫秒的超时
快恢复ssthresh = cwnd/2,cwnd = ssthresh(不回到 1),进入拥塞避免与"超时重传"区别对待:超时 = 严重拥塞(打回 1);3 个重复 ACK = 轻微丢包(只减半)。这是 Reno 相对 Tahoe 的关键改进

为什么还有 BBR

基于丢包的算法有两个时代病:① bufferbloat:中间设备缓冲区很大,队列排满了才丢包,此时延迟已经暴涨,TCP 才开始减速 ⇒ 高吞吐但高延迟;② 把随机丢包误判为拥塞:无线/跨国链路本身就有丢包,cwnd 被反复砍半,吞吐上不去。

BBR 换了信号源:主动测量"瓶颈带宽(BtlBw)"与"最小 RTT(RTprop)",把发送速率控制在 BDP 附近,不以丢包为准。结果是高吞吐 + 低排队延迟,在跨国、弱网、视频场景优势明显(YouTube、QUIC 广泛使用)。取舍:BBR 在与 Cubic 共存时可能抢占更多带宽(公平性争议),BBRv2/v3 在改进。

一句话记住:rwnd 防"压垮对端",cwnd 防"压垮网络";实际发送窗口 = min(rwnd, cwnd)。基于丢包的拥塞控制(Reno/Cubic)用"丢包"当信号,代价是 bufferbloat;BBR 用"带宽与 RTT"当信号,换来低延迟。这与限流算法(令牌桶 vs 滑动窗口 vs 自适应限流)是同一类问题:如何在看不见全局的情况下逼近系统容量。

6.5 Nagle 与延迟确认:一对糟糕的组合

两个各自合理的优化,凑在一起会产生 40 ms 的莫名延迟。这是"局部最优 ≠ 全局最优"的教科书案例。

机制目的规则
Nagle 算法(发送侧)减少小包(避免 41 字节的包只带 1 字节数据,网络被 header 淹没)若有未被确认的已发数据,则小包先攒着,等到"收到 ACK"或"攒满一个 MSS"再发
延迟确认(接收侧)减少纯 ACK 包(希望能搭上响应数据一起发,即"捎带确认")收到数据先不立刻 ACK,等最多 40 ms(Linux)看有没有数据要回;或攒到第二个包再一起确认
死锁式互等(典型的请求-响应型 RPC):
发送方(Nagle):已经发了第一小段,在等 ACK 才肯发第二小段
接收方(延迟ACK):拿到第一小段,但请求不完整无法回响应,于是等 40ms 才发 ACK
                          ↓
        每个请求凭空多出约 40 ms 延迟 —— 应用层代码完全看不出问题在哪
解法与取舍:

6.6 粘包 / 拆包:成因与三种解法

一句话记住:"粘包"不是 TCP 的 bug,而是 TCP 的定义。TCP 是字节流协议(stream),不是报文协议(datagram):它只保证"你发的字节按序到达",从不保证边界。UDP 才保留边界(一个 sendto = 一个 recvfrom)。所以边界必须由应用层协议自己定义。

成因(每一条都对应前面章节的一个机制):

发送方:write("ABC")  write("DE")  write("FGHIJ")
接收方可能读到(以下都合法):
   read() -> "ABCDEFGHIJ"     ← 全粘在一起
   read() -> "ABCD"  "EFGHIJ"  ← 边界完全错位
   read() -> "A" "BCDEFG" "HIJ" ← 任意切分
唯一保证:字节顺序 A,B,C,D,E,F,G,H,I,J 不变、不丢、不重复。
解法做法优缺点Netty 现成实现
① 定长每条消息固定 N 字节,不足补齐最简单;浪费带宽、长度受限,几乎只用于极简协议(如心跳)FixedLengthFrameDecoder
② 分隔符用特殊字节结束,如 \r\n可读性好(HTTP header、Redis RESP 都用);必须转义正文中的分隔符,且解析要扫每个字节(O(n)),二进制数据不友好LineBasedFrameDecoder / DelimiterBasedFrameDecoder
长度字段(首选)header 里写明 body 长度,先读定长 header,再按长度读 body最通用高效:无需扫描与转义、支持二进制、可预分配缓冲。务必校验最大长度,否则恶意长度字段可直接 OOMLengthFieldBasedFrameDecoder(Dubbo、gRPC、Kafka、MQTT 协议都是这一类)
工程细节:解码器必须能处理"半包"——一次 read 只到了半个 header。所以它必须是有状态的累积器:数据不够就存进累积缓冲区(Netty 的 ByteToMessageDecoder 内部 cumulation),等下次事件再拼。这就是为什么 Netty 的解码器不能被多个 Channel 共享(不能加 @Sharable)—— 每个连接的半包状态是独立的。自己写 NIO 时,90% 的诡异 bug 都出在这里。

7. 总表:底层路线 → 框架映射

把全篇收敛成一张查询表。读后续每一个 Part 时,都请回来对照这张表:新框架只是在某一行里换了个取舍。

底层机制解决的根本问题(为什么存在)上层框架里的实例化与取舍
用户态/内核态 + 系统调用隔离与仲裁硬件;代价是切换开销一切"批量化"的动因:Kafka 批量发送、io_uring 批量提交、Netty flush 合并、日志缓冲写;"能少进内核就少进内核"
线程(1:1)让"顺序代码"能并发;但内核对象昂贵Tomcat 线程池、JDBC、@Transactional 同步栈。取舍:模型简单 ↔ 并发上限 ≈ 线程数;线程池打满 → 雪崩 → Sentinel/Hystrix 隔离
协程(M:N)C10K:海量等待 vs 昂贵内核栈goroutine、Java 21 虚拟线程、Kotlin suspend。取舍:写法友好 ↔ 运行时必须接管所有阻塞点(否则钉死载体线程)
缓存行 / 伪共享一致性协议以"行"为粒度失效LongAdder 分格 + @Contended、Disruptor 填充、Netty 每线程内存池。取舍:空间换争抢
内存屏障 / volatilestore buffer 与重排破坏可见性、有序性DCL 单例的 volatile、AQS 的 state、并发容器的 volatile 读写、ConcurrentHashMaptabAt;JMM happens-before 是推理依据
CAS(+ 版本号)硬件唯一的原子读改写;ABA 需要历史信息Atomic*、AQS、ConcurrentHashMap 桶 CAS、MySQL 乐观锁 version 字段、ES _seq_no、etcd CAS、Redis WATCH。取舍:低冲突极快 ↔ 高冲突白工
自旋 / 阻塞 / futex等锁时"烧 CPU"还是"付切换钱"synchronized 偏向→轻量→重量的膨胀阶梯、ReentrantLock park/unpark、数据库 latch。统一哲学:快路径纯用户态
epoll / kqueue1 线程盯 10 万连接;开销只随活跃数增长Netty EventLoop、Redis 事件循环、Nginx worker、WebFlux、Vert.x;Reactor 模式 = epoll + 分派 + 串行化。取舍:LT 容错 ↔ ET 极限性能
io_uring连"系统调用"本身都想省掉Netty io_uring 传输、tokio-uring;思想同 DPDK/SPDK/RDMA/Disruptor:共享内存环 + 批量流水线
page cache内存比磁盘快千倍,且顺序访问可预取Kafka 完全依赖它(顺序写 + 预读);MySQL 用 O_DIRECT 绕过它(自管 Buffer Pool)。取舍判据:访问模式是否与内核假设一致
零拷贝CPU 拷贝浪费周期、内存带宽与缓存Kafka sendfile、RocketMQ mmap、Netty FileRegion/CompositeByteBuf/堆外内存、HAProxy splice。取舍:快 ↔ 应用失去对数据的控制(不能压缩/加密)
TCP 握手 / 挥手同步序列号;全双工需各自关闭连接池(避开握手与 TIME_WAIT)、SO_BACKLOG、Keep-Alive、优雅停机(先摘流量再关连接)、TFO/TLS 0-RTT 省 RTT
滑动窗口吞吐 = 窗口 / RTT;保护接收方背压的物理原型:Reactive Streams 的 request(n)、Netty 高低水位(WRITE_BUFFER_WATER_MARK)、Kafka 消费者 max.poll.records、MQ 拉模式
拥塞控制看不见全局,只能试探 + 退让限流(令牌桶/漏桶)、自适应限流、熔断降级、重试退避(指数 backoff + 抖动,即"乘性减"的应用层版)
Nagle / 延迟 ACK小包合并 vs 低延迟TCP_NODELAY 成为 RPC 默认;应用层自己批量(Kafka linger.ms 就是"可控的 Nagle")
字节流无边界TCP 只保证顺序,不保证边界所有 RPC 协议的第一件事都是定义帧:Dubbo/gRPC/Kafka/MQTT 的"长度字段"、HTTP 的 Content-Length/chunked、Redis RESP 的 $len\r\n;Netty LengthFieldBasedFrameDecoder

8. "一句话记住"速查表

主题一句话
用户态 / 内核态进内核是"换执行环境",不是"慢一点的函数调用";所以能少进就少进。
上下文切换真正的代价不是保存寄存器,而是把预热好的缓存和分支预测全部作废
协程把线程的"阻塞挂起"用用户态数据结构重做一遍;运行时必须接管所有阻塞点,否则钉死载体线程。
有栈 vs 无栈有栈 = 生态友好、内存稍大(Go、虚拟线程);无栈 = 极省内存、async 传染(Rust、C#)。
存储层级L1≈1ns,内存≈100ns,SSD≈50μs,磁盘寻道≈10ms,跨地域≈50ms —— 这是所有架构决策的物理常数。
缓存行CPU 按 64 字节整行搬运,所以顺序访问远快于随机;伪共享是"共享了行,不是共享了数据"。
volatile禁止重排 + 强制可见;不提供原子性
CAS"我猜它还是旧值";低冲突极快,高冲突不如加锁。
ABA只看"值等不等"不够,要看"封条编号变没变"——版本号 / AtomicStampedReference
乐观 vs 悲观冲突少用乐观(省等待),冲突多用悲观(省白工)。
自旋 vs 阻塞临界区极短且多核用自旋;否则阻塞。单核自旋 = 死等。
futex / JVM 锁膨胀统一哲学:无竞争时零内核开销,有竞争才逐级加码。
I/O 两个阶段阻塞/非阻塞 = 阶段①要不要等;同步/异步 = 阶段②谁来拷。
多路复用仍是同步epoll 只告诉你"可读了",拷贝还得你自己做。
epoll把"关心哪些 fd"变成内核里的持久状态(红黑树),把"谁就绪"从轮询改成回调登记(就绪链表)——只为活跃连接付费
LT vs ETLT 容错(可以少读一点),ET 极致(必须读到 EAGAIN)。
io_uringepoll 省的是"等",io_uring 省的是"进内核"。
Reactorepoll + 事件分派 + 一连接绑一线程串行化 ⇒ Handler 内免锁。
零拷贝不是一次都不拷,而是零次 CPU 拷贝;代价是应用看不到数据。
page cacheKafka 拥抱它,MySQL 绕开它 —— 判据是"访问模式是否与内核假设一致"。
三次握手双向序列号都必须被确认过一次,两次做不到。
TIME_WAIT为"最后 ACK 可重传"和"迷路报文消亡"付的保险费;治本靠长连接。
CLOSE_WAITTIME_WAIT 会自己消失,CLOSE_WAIT 不会——见到就去查漏掉的 close()。
滑动窗口吞吐 = 窗口 / RTT;它就是背压的物理原型。
拥塞控制rwnd 防压垮对端,cwnd 防压垮网络;发送窗口 = min(两者)。
Nagle + 延迟 ACK两个局部优化叠加出 40ms 延迟;RPC 一律 TCP_NODELAY
粘包不是 bug,是 TCP 的定义(字节流无边界);边界由应用层协议定义,首选长度字段。

9. 自测:能答出来才算过关

不用写代码,口述能讲清"机制 + 为什么"即可。答不上来的,回对应章节。

  1. 为什么"线程比进程轻"?在 Linux 内核数据结构层面,二者真正的差别是什么?(§1.2)
  2. 一次上下文切换的四笔开销分别是什么?哪一笔最贵却最隐形?(§1.3)
  3. vmstatcs 很高,如何判断是"在等 I/O"还是"线程太多在抢 CPU"?(§1.3)
  4. 为什么协程"看起来能撑百万并发",但一个 JNI 调用就可能让它退化?(§1.5)
  5. 为什么遍历 ArrayList 通常远快于 LinkedList,即使复杂度相同?(§2.1)
  6. MESI 已保证缓存一致,为什么还需要 volatile?(§2.3)
  7. 为什么同一段并发代码在 x86 上"跑得对",换到 ARM 服务器可能出错?(§2.3)
  8. 为什么 LongAdder 在高并发下比 AtomicLong 快?说出两个原因。(§2.2 / §3.1)
  9. ABA 问题为什么在 C++ 无锁栈里更危险,而 Java 里较少见?(§3.2)
  10. 秒杀同一个商品,用乐观锁还是悲观锁?为什么?(§3.3)
  11. pthread_mutex 在无竞争时会进内核吗?futex 的快路径是什么?(§3.4)
  12. 为什么"在 synchronized 块里做网络调用"是严重反模式?(§3.5 + §1.3)
  13. 为什么说"I/O 多路复用仍然是同步 I/O"?(§4.1 / §4.2)
  14. select 的三个结构性缺陷是什么?poll 解决了哪一个?(§4.5)
  15. epoll 的红黑树和就绪链表各解决了 select 的哪个问题?(§4.6)
  16. ET 模式下漏了一次循环读会发生什么?为什么必须配非阻塞 fd?(§4.6)
  17. 为什么 Netty 默认 LT 而 Nginx 用 ET?(§4.6)
  18. io_uring 与 epoll 优化的目标有何本质不同?SQPOLL 为什么能"零系统调用"?(§4.8)
  19. 为什么 Netty 的 Handler 里通常不需要加锁?(§4.9)
  20. "零拷贝"到底零了什么?为什么 DMA 那两次省不掉?(§5.1)
  21. 启用 HTTPS 后 sendfile 为什么失效?(§5.3)
  22. Kafka 用 page cache、MySQL 用 O_DIRECT,判据是什么?(§5.2)
  23. RocketMQ 用 mmap 而 Kafka 用 sendfile,各自的场景理由是什么?(§5.3)
  24. 为什么必须三次握手而不是两次?ISN 为什么要随机?(§6.1)
  25. 半连接队列与全连接队列分别满了,现象有何不同?如何排查?(§6.1)
  26. TIME_WAIT 的两个作用是什么?为什么不该用 tcp_tw_recycle?(§6.2)
  27. 压测机上大量 TIME_WAIT,正确的解决顺序是什么?(§6.2)
  28. 某跨国链路带宽 1 Gbps、RTT 100 ms,为什么单连接吞吐上不去?(§6.3)
  29. 快重传与超时重传后,cwnd 的处理为什么不同?(§6.4)
  30. BBR 相比 Cubic 换掉了什么"信号源"?解决了什么病?(§6.4)
  31. 那个"莫名 40ms 延迟"是怎么产生的?(§6.5)
  32. 为什么说"粘包不是 TCP 的 bug"?为什么 UDP 没有这个问题?(§6.6)
  33. Netty 的 ByteToMessageDecoder 为什么不能标 @Sharable?(§6.6)
本主题的底层路线(回到起点,再读一遍):
一切高性能框架的底座 = 并发(线程 / 协程) + 高效的 I/O 多路复用 + 减少拷贝与上下文切换 + 可靠的字节流(TCP)。 上面所有框架都是这四件事的排列组合与取舍:
· Netty = epoll + Reactor + 零拷贝 + 帧解码 + 内存池;
· Redis = epoll + 单线程消灭锁 + 内存数据结构;
· Kafka = 顺序写 + page cache + sendfile + 批量;
· Nginx = 多进程 + epoll(ET) + sendfile;
· Tomcat / Spring MVC = 线程池 + BIO 语义(虚拟线程让它重获伸缩性);
· WebFlux = Reactor 模式 + 背压(滑动窗口思想的应用层复刻)。
没有第五件新东西。后面的 Part 只是把这些机制往"持久化、一致性、分布式"方向再推一层。
Part 01 · 底层基石:操作系统与计算机基础 —— 面向"跨专业转计算机"的底层优先学习材料。
下一部分(Part 02)将用 Netty 逐行验证本篇结论:EventLoop 如何封装 epoll、ByteBuf 如何实现零拷贝与内存池、Pipeline 如何解决粘包与背压。
配套 Markdown 版本见同名 .md 文件。图示建议结合 HTML 版查看。