进程/线程/协程 · CPU 缓存与锁 · I/O 模型与零拷贝 · TCP 核心 | Part 01
你有数学背景,这是巨大优势:你习惯"先抓公理与结构,再看定理是结构的推论"。计算机系统正好可以这样读。
框架之所以让人晕,是因为大多数教程按API 罗列讲:Netty 有 EventLoop、ChannelPipeline、ByteBuf;Kafka 有 Partition、ISR、Segment……名词几百个,彼此看不出关系。但如果换个视角:
本篇是整套材料的地基。地基里最重要的是四条主线,请在读每一节时不断回头对照:
| 底层主线 | 要回答的根本问题 | 上层框架的对应体现 |
|---|---|---|
| 并发线程 / 协程 | 一个 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 章是"跨机器"。三块合起来正好是后续所有中间件的物理基础。
CPU 有特权级(x86 上是 ring 0 ~ ring 3)。操作系统内核跑在 ring 0,能执行所有指令、访问所有内存和外设;你的 Java 进程跑在 ring 3,不能直接碰硬件。为什么要这条线?因为"隔离与仲裁":磁盘、网卡、物理内存是全机共享资源,若谁都能直接写,任何一个 bug 都能毁掉整台机器。
于是,凡是要碰硬件或全局资源的事(读文件、发网络包、创建线程、分配大块内存),你都必须请内核代办。请求的方式就是系统调用(syscall):把参数放到约定的寄存器,执行一条特殊指令(x86-64 上是 syscall),CPU 切到 ring 0,跳到内核入口,内核干完再返回。
数量级感受(同一台机器上的相对成本,具体值与 CPU/内核版本相关,量级更重要):
| 操作 | 量级 | 解读 |
|---|---|---|
| 普通函数调用 | ~1 ns | 基本免费 |
一次简单系统调用(如 getpid) | ~100 ns ~ 数百 ns | 开启 Spectre/Meltdown 缓解后更贵 |
| 一次线程上下文切换 | ~1–10 μs(含缓存污染可到几十 μs) | 比系统调用再贵一到两个数量级 |
| 一次协程切换(用户态) | ~10–200 ns | 只换寄存器和栈指针,不进内核 |
先破一个常见误解:在 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 虚拟线程 |
很多人只知道"上下文切换贵",说不出贵在哪。拆成四笔账:
read(),数据没来 → 内核把它设为不可运行、放入等待队列 → 切走。这是 BIO 模型的日常。观察方式:vmstat 1 看 cs 列;pidstat -w -p <pid> 1 区分 cswch/s(自愿)与 nvcswch/s(非自愿)。自愿切换高 ⇒ 大概率在等 I/O 或等锁;非自愿高 ⇒ CPU 争抢/线程太多。这是线上排查的第一把尺。
设想一个即时通讯服务:10 万个长连接,但绝大多数时间在"静默等消息"。用"每连接一线程":
问题的本质是错配:线程是"内核调度单位",而我们想表达的是"一个业务处理流程"。业务流程数量巨大且多数在等待,却被迫一一对应到昂贵的内核对象。
两条出路:
历史概念:完全由用户态运行时调度、不映射到内核线程的线程(早期 JVM 1.1 的绿色线程、Erlang 进程)。它的致命缺陷是:无法利用多核(整个运行时就一个内核线程),且一个协程调用阻塞式系统调用会卡死全部。现代做法都是 M:N(M 个协程映射到 N 个内核线程),Java 21 的虚拟线程本质是"回到 green thread,但做成 M:N 且与 JDK 阻塞点深度集成"。
每个协程有自己的一小块可增长栈(Go goroutine 初始 2 KB,Kotlin/libco 类似思路)。挂起时把栈指针和寄存器存起来即可,因此可以在任意深度的函数嵌套里挂起。
没有独立栈,编译器把带 async/await 的函数改写成状态机,局部变量提升成状态机对象的字段,挂起就是"记住当前状态号后返回"。Rust async、C# async/await、JS Promise、Kotlin 的 suspend(编译成 CPS 状态机)都属这类。
async 函数才能 await,普通函数里无法挂起 ⇒ 生态被"染色"(function coloring 问题)。虚拟线程(Loom)是有栈路线:栈帧存在堆上(StackChunk),挂起时把栈从载体线程"卸下"(unmount),恢复时再"装上"(mount)。JDK 把 socket、Thread.sleep、BlockingQueue 等阻塞点全部改造成"挂起虚拟线程 + 注册到 epoll/Poller",所以你写老式阻塞代码也能得到 Reactor 的伸缩性。
synchronized 里长时间阻塞(早期 Loom 会 pin 住载体线程)、CPU 密集死循环、文件 I/O(Linux 上普通文件 I/O 无法用 epoll,Loom 只能用额外线程池兜底)——就会把载体线程钉死,几个这种协程就能让"百万并发"退化成"几个线程的吞吐"。这就是"协程不是免费午餐"的技术根因。
| 维度 | 有栈协程 | 无栈协程 |
|---|---|---|
| 实现机制 | 独立可增长栈 + 换栈指针 | 编译期改写为状态机 |
| 能否深层挂起 | 能(任意调用深度) | 不能(只能在 async 函数内) |
| 内存开销 | KB 级 | 字节~百字节级 |
| 生态侵入 | 低(老代码直接受益) | 高(async 传染) |
| 代表 | Go goroutine、Java 虚拟线程 | Rust async、C# async、JS、Kotlin suspend |
现代 CPU 一个时钟周期约 0.3 ns,而一次主存访问要 ~100 ns ≈ 300 个周期。这意味着:CPU 大部分时间在等内存。所有数据结构的性能,最终都要用"缓存命中率"来解释。
| 层级 | 容量量级 | 延迟 | 换算成"人类尺度"(1 周期 = 1 秒) |
|---|---|---|---|
| 寄存器 | 几百字节 | < 1 周期 | 就在手上 |
| L1 cache | 32–64 KB / 核 | ~1 ns(4 周期) | 4 秒(伸手拿抽屉) |
| L2 cache | 0.5–2 MB / 核 | ~4 ns(12 周期) | 12 秒(走到书架) |
| L3 cache | 8–64 MB / 共享 | ~15–40 ns | 约 1 分钟(去隔壁办公室) |
| 主存 DRAM | GB | ~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 年 |
CPU 与内存之间的最小传输单位是缓存行(cache line),x86 与多数 ARM 上是 64 字节(Apple Silicon 部分层级 128 字节)。你读 arr[0] 一个 int,实际会把包含它的 64 字节整行拉进 L1。
这一条事实直接推出两个重要结论:
int[] 时,每 16 个元素才 miss 一次,而且硬件预取器会提前把下一行取来;遍历链表则每个节点都可能 miss。这就是为什么 ArrayList 通常比 LinkedList 快得多,即使理论复杂度相同。多核各有自己的 L1/L2。硬件必须保证"同一块内存在各核看到的值一致",这靠缓存一致性协议(MESI):每个缓存行在每个核里处于 Modified / Exclusive / Shared / Invalid 之一。核 A 要写某行,必须先让其他核的该行副本失效(Invalid),自己独占。
问题来了:失效的粒度是整行 64 字节,不是单个变量。若变量 a(核 0 频繁写)和 b(核 1 频繁写)恰好在同一行,两个核就会反复把这一行抢来抢去——明明各写各的、逻辑上零冲突,性能却像加了锁。这就是伪共享。
@Contended / 拆分成每线程独立槽位)。
AtomicLong):把一个热点计数器拆成 Cell[] 数组,每个 Cell 用 @Contended 填充,各线程只 CAS 自己那格,读时求和。本质是"用空间换掉缓存行争抢 + CAS 失败重试"。p1..p7),加上数组的顺序访问友好性,做到几乎无锁无 miss,这是它比 ArrayBlockingQueue 快一个数量级的主因之一(另一半是消灭了锁与伪共享的 head/tail 指针)。PoolThreadCache、Recycler 都是"每线程私有区"思想,从根上避免跨核共享。既然 MESI 已经保证一致性,为什么还需要 volatile 和内存屏障?因为 CPU 为了不被"等失效确认"拖慢,加了两个缓冲:
再加上编译器指令重排、CPU 乱序执行与投机执行,程序的实际执行顺序 ≠ 你写的顺序。这不是 bug,是为了榨干流水线;代价是并发下的直觉失效。
于是硬件提供内存屏障(memory barrier / fence),让你在需要的地方"手动补上顺序保证":
| 屏障(概念名) | 含义 | x86 实现 |
|---|---|---|
| LoadLoad | 屏障前的读,先于屏障后的读完成 | x86 天然保证(TSO) |
| StoreStore | 屏障前的写,先于屏障后的写对外可见 | x86 天然保证 |
| LoadStore | 屏障前的读,先于屏障后的写 | x86 天然保证 |
| StoreLoad(最贵) | 屏障前的写必须真正落到缓存,才允许后面的读执行 | mfence 或 lock addl $0,(%rsp) |
x86 是较强的内存模型(TSO),只需要防 StoreLoad 重排;ARM/POWER 是弱内存模型,四种都可能重排,所以同一段并发 Java 代码在 x86 上"碰巧对",换到 ARM 服务器(如鲲鹏、Graviton、Apple Silicon)就可能暴雷。这就是为什么必须按 JMM 写代码,而不是按实验现象写代码。
// 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 或锁。
把上面的机制收敛成三个正交问题,后面看任何并发工具都用这三把尺子量:
| 性质 | 被什么破坏 | 底层根因 | Java 里的解法 |
|---|---|---|---|
| 原子性 | 线程切换发生在多步操作中间 | i++ 编译成 load / add / store 三条指令 | synchronized、Lock、AtomicXxx(CAS) |
| 可见性 | 写留在 store buffer / 读命中旧的本地缓存行 | store buffer + invalidate queue | volatile、final、锁的释放/获取语义 |
| 有序性 | 编译器与 CPU 重排 | 乱序执行、投机执行、编译优化 | volatile、屏障、happens-before 规则 |
happens-before 是 JMM 给你的"公理系统"(你会喜欢这个类比):它不描述时间先后,而是描述"可见性的偏序关系"。核心几条:程序顺序规则、监视器锁规则(unlock 先于后续 lock)、volatile 规则(写先于后续读)、线程启动/终止规则、传递性。只要你能用这些公理推出 A hb B,就保证 A 的结果对 B 可见;推不出来,就必须假设可能出错。
private static volatile Singleton inst;,否则 new 内部"分配内存 → 初始化 → 赋值引用"可被重排为"分配 → 赋值 → 初始化",别的线程会拿到非空但未初始化完的对象。Spring 三级缓存、各类懒加载容器都要处理同一问题。
互斥的本质需求是"读-改-写"不可被打断。软件层面无论怎么写都做不到(任何两条指令之间都可能被切换),所以必须由硬件提供一条原子指令。x86 给的是 cmpxchg 加 lock 前缀,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 只比较"值相等",无法区分"从没变过"和"变走又变回来了"。若值是指针或有业务含义的状态,这个区别就是致命的。
初始:栈顶 = 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,每次修改版本号 +1 | Java 中的标准答案;内部用一个不可变 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++ 少见 |
这不是两种 API,而是两种关于"冲突概率"的假设。整个数据库、缓存、分布式协调的设计都在这条轴上取舍。
| 维度 | 悲观锁(先占再改) | 乐观锁(先改再验) |
|---|---|---|
| 假设 | 冲突大概率发生 | 冲突小概率发生 |
| 流程 | 加锁 → 操作 → 解锁;冲突者阻塞等待 | 读版本 → 计算 → CAS/带版本更新;冲突者重试或失败 |
| 开销来源 | 阻塞与唤醒(可能进内核)、死锁风险 | 重试的白工、活锁风险、缓存行争抢 |
| 临界区适合 | 长临界区(含 I/O、复杂计算) | 极短临界区(几条指令) |
| 底层机制 | 互斥量、信号量、行锁(MySQL for update) | CAS、版本号、MVCC 读快照 |
| Java 代表 | synchronized、ReentrantLock | Atomic*、LongAdder、ConcurrentHashMap 的桶级 CAS |
| 中间件代表 | MySQL 行锁/间隙锁、Redis 分布式锁、ZooKeeper 临时顺序节点锁 | MySQL MVCC + version 字段、Redis WATCH/MULTI、ES 的 _seq_no、etcd CAS 写 |
拿不到锁时怎么办?只有两个选择——原地转圈等(自旋),或睡过去让别人跑(阻塞)。
| 维度 | 自旋锁 | 互斥锁(阻塞锁) |
|---|---|---|
| 拿不到锁时 | while(!CAS(...)) { pause; } 空转,占着 CPU | 系统调用把自己挂到等待队列并让出 CPU |
| 成本 | 无切换;但白烧 CPU 时间片 | 两次上下文切换(睡 + 醒)≈ μs 级 + 缓存污染 |
| 适用 | 临界区极短且多核;持锁者正在另一个核上跑 | 临界区较长 / 含 I/O / 单核 / 竞争激烈 |
| 致命场景 | 单核上自旋 = 死等(持锁者拿不到 CPU 就永远不放锁);等待者多时全员烧 CPU | 短临界区时"切换成本 > 临界区本身",纯亏 |
| 典型实现 | Linux 内核 spin_lock、JVM 自适应自旋、Thread.onSpinWait()(发 pause 指令省电并让出流水线) | pthread_mutex、ReentrantLock 的 park/unpark(底层 futex) |
真实的 pthread_mutex 既不是纯自旋也不是每次都进内核,而是 futex(fast userspace mutex):
lock():
① 用户态 CAS 抢锁 → 没人竞争(绝大多数情况):完全不进内核,成本 ≈ 一条 CAS 指令 ✅
② 抢不到,短暂自适应自旋 → 赌持锁者马上就放
③ 还不行:futex(FUTEX_WAIT) → 才进内核,挂进等待队列睡下去
unlock():
① 用户态 CAS 释放
② 若标记显示"有人在等" → futex(FUTEX_WAKE) 进内核唤醒一个
sync.Mutex(也是自旋后再 park),全都是同一招——把"快路径(fast path)"做成纯用户态。
synchronized 不是一上来就用操作系统互斥量,而是根据竞争情况单向膨胀。锁状态记在对象头 Mark Word 里:
| 状态 | 机制 | 成本 | 一句话 |
|---|---|---|---|
| 无锁 | Mark Word 存 hashCode / 分代年龄 | 0 | 没人加锁 |
| 偏向锁 | Mark Word 记下线程 ID,同一线程再进只需比对 ID | 几乎为 0 | 赌"永远只有一个线程用它"(JDK 15 默认关闭,JDK 18 彻底移除——因为现代应用多线程普遍,撤销偏向的开销大于收益) |
| 轻量级锁 | 线程栈上建 Lock Record,CAS 把 Mark Word 指向它;失败则自适应自旋 | 一次 CAS + 可能自旋 | 赌"竞争是交替的、很短的"——本质就是自旋锁 |
| 重量级锁 | 膨胀出 ObjectMonitor,竞争者进 _EntryList,park() → 底层 futex 睡眠 | 系统调用 + 上下文切换 | 放弃赌博,交给操作系统排队 |
ConcurrentHashMap 从分段锁改为"桶级 CAS + 单桶 synchronized",正是把竞争概率压到最低,让绝大多数操作停在"无锁/轻量"级别。
这是全章的钥匙。当你对一个 socket 调用 read(fd, buf, len),实际有两个完全不同的阶段:
epoll_wait 只告诉你"可读了",真正的 read() 拷贝还是你的线程亲自执行的。它省掉的是"为每个连接配一个线程去等",不是"拷贝"。这是面试与理解的分水岭。
// 经典 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();
}
三笔无法回避的账:
-Xss),1 万连接 ≈ 10 GB 虚拟内存预留。线程池能缓解(Tomcat 的做法),但改变不了本质:并发上限 ≈ 线程数。若业务再有慢调用(下游超时 3 秒),线程被占住,整个池瞬间耗尽 —— 这就是"线程池打满 + 雪崩"的经典成因,也是 Hystrix/Sentinel 隔离与熔断存在的理由。
Java NIO 的三个核心概念,各自对应一个底层事实:
| 概念 | 对应的底层事实 | 要点 |
|---|---|---|
Buffer | 系统调用需要一段地址稳定的内存 | position/limit/capacity 三指针;flip() 是"写模式→读模式"。DirectByteBuffer 分配在堆外,因为 JVM 堆会被 GC 移动对象,内核拿着的地址可能失效 —— 所以用堆内 buffer 做 I/O 时,JDK 内部会先复制一份到堆外再发起 syscall(这就是 Netty 默认用直接内存的根因) |
Channel | fd(文件描述符)的面向对象封装 | 双向(可读可写),支持 transferTo(零拷贝入口) |
Selector | epoll / kqueue / IOCP 的跨平台外壳 | select() = 一次 epoll_wait;SelectionKey = 感兴趣的事件集合 + 附件 |
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(); // 必须手动清,否则下轮重复处理
}
OP_WRITE注册与反注册、空轮询 bug(Linux epoll 的 select() 立即返回 0 导致 CPU 100%,Netty 用"计数达阈值就重建 Selector"绕开)、内存池、线程模型、异常与连接生命周期。Netty ≈ NIO + 十几年踩坑经验的固化。多路复用的第一代思路很朴素:"你把要关心的 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);
它们的结构性缺陷(不是实现不好,是接口设计决定的):
select 有硬上限:fd_set 是位图,大小 FD_SETSIZE(通常 1024),且编译期固定;poll 用数组,突破了数量限制,但 1、2、3 三条毛病一个没少。select 的 fd_set 是入参出参共用,每轮循环必须重新 FD_SET 一遍。核心矛盾:开销与"总连接数 n"成正比,而实际收益只与"活跃连接数 k"成正比。长连接服务里 k ≪ n(10 万连接可能只有几百个活跃),于是 99% 的工作都是白干。
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 的就绪链表 rdllist。epoll_wait 只需看这条链表空不空。
| 模式 | 触发条件 | 编程要求 | 取舍 |
|---|---|---|---|
| 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;
}
EpollMode.LEVEL_TRIGGERED),因为它要保证通用场景下的正确性与背压可控;EpollSocketChannel 也支持切 ET。Nginx 用 ET,因为它自己完全掌控读写循环,愿意用更严格的代码换更少的唤醒。LT vs ET = "容错性" vs "极限性能"的经典取舍。| 机制 | 平台 | 特点 |
|---|---|---|
epoll | Linux | 红黑树 + 就绪链表 + 回调;只管"就绪通知"(Readiness) |
kqueue | BSD / macOS | 同样是"注册一次 + 就绪队列",但更统一:一个 kevent 接口同时处理 socket、文件变更(vnode)、信号、定时器、进程退出,EV_SET 可批量提交。设计上比 epoll 更正交 |
IOCP | Windows | 完成通知(Completion)而非就绪通知:真异步,内核把数据拷好后往完成端口投递结果,还自带线程池调度。这就是"Windows 的 AIO 早已成熟、Linux 直到 io_uring 才补上"的原因 |
Java Selector | 跨平台 | 屏蔽差异:Linux 走 epoll、macOS 走 kqueue、Windows 走 select(旧实现,性能一般)。Netty 额外提供 EpollEventLoopGroup / KQueueEventLoopGroup 用 JNI 直连原生 API,绕开 JDK 抽象损耗并暴露 SO_REUSEPORT、TCP_FASTOPEN 等高级选项 |
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 ⇒ 稳态下用户态"完全不需要系统调用"
它同时解决了三件事:
IORING_REGISTER_BUFFERS)和固定 fd,还能省掉每次的地址校验与引用计数。fsync、openat、statx 统一支持异步;还能用 IOSQE_IO_LINK 把"读完再写"串成链,一次提交完成多步,进一步减少往返。io_uring 传输(孵化中);Rust 的 tokio-uring、C 的 liburing 更成熟。今天绝大多数业务的瓶颈在业务逻辑与数据库,不在 epoll,别为 io_uring 提前优化。epoll 是"机制",Reactor 是"把机制用起来的模式":一个(或几个)线程跑事件循环,epoll_wait 拿到就绪事件后分派(dispatch)给对应 handler。
| 形态 | 结构 | 代表 |
|---|---|---|
| 单 Reactor 单线程 | 一个线程负责 accept + read + 业务 + write | Redis(业务极短、纯内存,所以够用;顺带消灭了全部锁) |
| 单 Reactor 多线程 | 一个线程管 I/O,业务丢给线程池 | 早期实现;I/O 线程易成瓶颈 |
| 主从 Reactor 多线程 | mainReactor 只管 accept,subReactor 组(每个绑一个线程)管已连接 socket 的读写,业务可再交业务线程池 | Netty 标准姿势(bossGroup + workerGroup);Nginx 是"多进程 + 各自 epoll + SO_REUSEPORT"的变体 |
Netty 的两条关键设计,都是前面底层结论的直接产物:
考虑最常见的操作:把磁盘上的文件发给网络对端(静态资源服务、Kafka 消费者拉消息、文件下载)。传统写法 read(file) + write(socket) 的真实代价:
Linux 会用所有空闲内存做文件缓存(page cache)。它的三个行为决定了很多中间件的设计:
write() 只是把数据写进 page cache 并标脏就返回,由内核线程稍后刷盘。快,但断电会丢 —— 需要持久性就得 fsync(这引出 WAL 与刷盘策略,Part 03/05 详述)。O_DIRECT 绕过 page cache),是因为它们比内核更懂访问模式(知道哪些页是索引热点、需要精确控制刷盘时机与 checkpoint);而 Kafka 反其道行之,完全依赖 page cache,因为它的模式就是"顺序追加写 + 顺序读最近数据",内核的预读与写回策略正好最优,自己再做一层缓存纯属重复。同一个底层机制,两种相反的取舍,判据是"访问模式是否与内核假设一致"。| 技术 | 机制 | CPU 拷贝 / 切换 | 能否改数据 | 适用与代表 |
|---|---|---|---|---|
mmap + write | 把 page cache 映射进用户地址空间,用户态指针直接读写文件内容(缺页时才真正加载) | CPU 拷贝 1 次(page cache→socket buf);切换 4 次 | 能(像访问内存一样) | 随机读写、需要修改内容、小文件多;RocketMQ CommitLog(1G 分片 MappedByteBuffer)、MySQL/LevelDB 的部分索引、Lucene 段文件。坑:缺页中断成本、大文件占虚拟地址、msync 时机、Java 里 MappedByteBuffer 释放依赖 GC(需 Cleaner 手动清) |
sendfile | fd → 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_ZEROCOPY | send() 时让网卡 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()) |
IP 层给你的是:可能丢、可能乱序、可能重复、可能损坏的数据包投递。TCP 要在这之上给出"有序、不丢、不重、有流控与拥塞控制的字节流"。它靠四件东西:序列号(排序与去重)、确认与重传(不丢)、滑动窗口(不把接收方压垮)、拥塞控制(不把网络压垮)。理解这四件事,就理解了 TCP 的全部。
TCP 的一切可靠性都建立在序列号上。若两端不先就"从哪个号开始数"达成一致,就无法判断丢包、乱序、重复。ISN 还必须是随机的(基于时钟 + 哈希),否则攻击者能猜出序列号伪造报文注入连接。
"为什么不是两次?"教科书答案是"防止历史连接被错误建立"。具体场景:客户端发的一个旧 SYN 在网络里绕路迟到,服务端若两次握手就直接进 ESTABLISHED 并分配资源,而客户端早已放弃 —— 服务端资源就被白占。三次握手让客户端有机会用 RST 否决这个旧连接。更本质地说:可靠通信要求双方序列号都被确认过一次,这在两次交互里做不到。
| 队列 | 装什么 | 满了会怎样 |
|---|---|---|
| 半连接队列(SYN queue) | 收到 SYN、还没收到最后 ACK 的连接 | 新 SYN 被丢弃 ⇒ 客户端超时重试、连接变慢。net.ipv4.tcp_max_syn_backlog;tcp_syncookies=1 可在溢出时启用 SYN Cookie |
| 全连接队列(accept queue) | 已完成握手、等应用 accept() 取走的连接 | 视 tcp_abort_on_overflow:丢弃 SYN+ACK 的 ACK(默认,客户端重传)或直接 RST。应用 accept 太慢(业务卡住、线程池打满)就会打满这里 —— 用 ss -lnt 看 Send-Q/Recv-Q 可确诊 |
ChannelOption.SO_BACKLOG 就是全连接队列长度(实际取 min(backlog, somaxconn));bossGroup 单线程只做 accept,就是为了尽快把连接从 accept 队列取走。TCP Fast Open(TFO)则允许在 SYN 里携带数据,省掉一个 RTT —— 与 TLS 1.3 的 0-RTT 同源:省 RTT 是所有网络优化的第一目标(回看 §2.1 的延迟表就懂了)。TCP 是全双工的:两个方向是两条独立的流,各自需要单独关闭。所以 FIN 的语义不是"断开连接",而是"我这个方向不再发数据了"。这就是"半关闭"的由来(Java 里 shutdownOutput())。
TCP_TIMEWAIT_LEN,改需重编译内核)。
net.ipv4.ip_local_port_range)导致"connect: Cannot assign requested address"。net.ipv4.tcp_tw_reuse=1(需 tcp_timestamps=1):允许作为客户端发起新连接时复用 TIME_WAIT 端口,用时间戳区分新旧报文,相对安全。SO_REUSEADDR:让服务端重启时能立刻 bind 处于 TIME_WAIT 的端口(解决"Address already in use"),与 tcp_tw_reuse 不是一回事。tcp_tw_recycle:在 NAT 环境下会因时间戳判断错误丢弃合法连接,已在 Linux 4.12 移除。CLOSE_WAIT 堆积几乎总是应用 bug:收到 FIN 后没调 close()(忘了关流、异常路径漏关、线程卡死)。TIME_WAIT 会自己消失,CLOSE_WAIT 不会 —— 见到它就去查代码。最朴素的可靠传输是"发一个、等一个 ACK"(stop-and-wait),吞吐 = 报文大小 / RTT。1 KB / 100 ms = 10 KB/s —— 显然不可接受。解法是允许有多个未确认的报文在途(in-flight),这就是滑动窗口。
发送方视角:
已发送且已确认 │ 已发送未确认(in-flight) │ 可立即发送 │ 不允许发送
───────────────┼──────────────────────────┼───────────┼─────────────
└──────── 发送窗口 = min(接收方通告窗口 rwnd, 拥塞窗口 cwnd) ────┘
收到 ACK ⇒ 左边界右移(窗口"滑动")⇒ 右边可发送区域随之扩大
理论吞吐上限 = 窗口大小 / RTT ← 这就是"带宽时延积(BDP)"
流控保护接收方,拥塞控制保护网络。区别务必分清: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 的关键改进 |
基于丢包的算法有两个时代病:① bufferbloat:中间设备缓冲区很大,队列排满了才丢包,此时延迟已经暴涨,TCP 才开始减速 ⇒ 高吞吐但高延迟;② 把随机丢包误判为拥塞:无线/跨国链路本身就有丢包,cwnd 被反复砍半,吞吐上不去。
BBR 换了信号源:主动测量"瓶颈带宽(BtlBw)"与"最小 RTT(RTprop)",把发送速率控制在 BDP 附近,不以丢包为准。结果是高吞吐 + 低排队延迟,在跨国、弱网、视频场景优势明显(YouTube、QUIC 广泛使用)。取舍:BBR 在与 Cubic 共存时可能抢占更多带宽(公平性争议),BBRv2/v3 在改进。
两个各自合理的优化,凑在一起会产生 40 ms 的莫名延迟。这是"局部最优 ≠ 全局最优"的教科书案例。
| 机制 | 目的 | 规则 |
|---|---|---|
| Nagle 算法(发送侧) | 减少小包(避免 41 字节的包只带 1 字节数据,网络被 header 淹没) | 若有未被确认的已发数据,则小包先攒着,等到"收到 ACK"或"攒满一个 MSS"再发 |
| 延迟确认(接收侧) | 减少纯 ACK 包(希望能搭上响应数据一起发,即"捎带确认") | 收到数据先不立刻 ACK,等最多 40 ms(Linux)看有没有数据要回;或攒到第二个包再一起确认 |
死锁式互等(典型的请求-响应型 RPC):
发送方(Nagle):已经发了第一小段,在等 ACK 才肯发第二小段
接收方(延迟ACK):拿到第一小段,但请求不完整无法回响应,于是等 40ms 才发 ACK
↓
每个请求凭空多出约 40 ms 延迟 —— 应用层代码完全看不出问题在哪
TCP_NODELAY(关闭 Nagle):几乎所有 RPC 框架、Netty(ChannelOption.TCP_NODELAY, true)、Redis、gRPC 的默认选择。因为对交互式小报文而言,延迟远比"省几个包"重要。write 写完整请求,而不是"写 header 再写 body"(这也是 Netty CompositeByteBuf、flush 合并、writeAndFlush 时机管理的意义)。与其让内核猜,不如应用自己控制批量边界。TCP_QUICKACK(Linux,一次性生效需反复设置)可关闭延迟确认,但通常不如直接关 Nagle 干净。sendto = 一个 recvfrom)。所以边界必须由应用层协议自己定义。
成因(每一条都对应前面章节的一个机制):
write 的数据本就连续。read() 返回多少取决于"此刻缓冲区里有多少 + 你给的 buffer 多大",与对端 write 的次数毫无关系。发送方: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 | 最通用高效:无需扫描与转义、支持二进制、可预分配缓冲。务必校验最大长度,否则恶意长度字段可直接 OOM | LengthFieldBasedFrameDecoder(Dubbo、gRPC、Kafka、MQTT 协议都是这一类) |
read 只到了半个 header。所以它必须是有状态的累积器:数据不够就存进累积缓冲区(Netty 的 ByteToMessageDecoder 内部 cumulation),等下次事件再拼。这就是为什么 Netty 的解码器不能被多个 Channel 共享(不能加 @Sharable)—— 每个连接的半包状态是独立的。自己写 NIO 时,90% 的诡异 bug 都出在这里。把全篇收敛成一张查询表。读后续每一个 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 每线程内存池。取舍:空间换争抢 |
| 内存屏障 / volatile | store buffer 与重排破坏可见性、有序性 | DCL 单例的 volatile、AQS 的 state、并发容器的 volatile 读写、ConcurrentHashMap 的 tabAt;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 / kqueue | 1 线程盯 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 |
| 主题 | 一句话 |
|---|---|
| 用户态 / 内核态 | 进内核是"换执行环境",不是"慢一点的函数调用";所以能少进就少进。 |
| 上下文切换 | 真正的代价不是保存寄存器,而是把预热好的缓存和分支预测全部作废。 |
| 协程 | 把线程的"阻塞挂起"用用户态数据结构重做一遍;运行时必须接管所有阻塞点,否则钉死载体线程。 |
| 有栈 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 ET | LT 容错(可以少读一点),ET 极致(必须读到 EAGAIN)。 |
| io_uring | epoll 省的是"等",io_uring 省的是"进内核"。 |
| Reactor | epoll + 事件分派 + 一连接绑一线程串行化 ⇒ Handler 内免锁。 |
| 零拷贝 | 不是一次都不拷,而是零次 CPU 拷贝;代价是应用看不到数据。 |
| page cache | Kafka 拥抱它,MySQL 绕开它 —— 判据是"访问模式是否与内核假设一致"。 |
| 三次握手 | 双向序列号都必须被确认过一次,两次做不到。 |
| TIME_WAIT | 为"最后 ACK 可重传"和"迷路报文消亡"付的保险费;治本靠长连接。 |
| CLOSE_WAIT | TIME_WAIT 会自己消失,CLOSE_WAIT 不会——见到就去查漏掉的 close()。 |
| 滑动窗口 | 吞吐 = 窗口 / RTT;它就是背压的物理原型。 |
| 拥塞控制 | rwnd 防压垮对端,cwnd 防压垮网络;发送窗口 = min(两者)。 |
| Nagle + 延迟 ACK | 两个局部优化叠加出 40ms 延迟;RPC 一律 TCP_NODELAY。 |
| 粘包 | 不是 bug,是 TCP 的定义(字节流无边界);边界由应用层协议定义,首选长度字段。 |
不用写代码,口述能讲清"机制 + 为什么"即可。答不上来的,回对应章节。
vmstat 里 cs 很高,如何判断是"在等 I/O"还是"线程太多在抢 CPU"?(§1.3)ArrayList 通常远快于 LinkedList,即使复杂度相同?(§2.1)volatile?(§2.3)LongAdder 在高并发下比 AtomicLong 快?说出两个原因。(§2.2 / §3.1)pthread_mutex 在无竞争时会进内核吗?futex 的快路径是什么?(§3.4)synchronized 块里做网络调用"是严重反模式?(§3.5 + §1.3)tcp_tw_recycle?(§6.2)ByteToMessageDecoder 为什么不能标 @Sharable?(§6.6)