第 04 章 设备与 I/O:统一接口下的暗流
前两章,我们讲了”文件”这个抽象如何统一了存储。 这一章,我们把抽象推到极限:把磁盘、网卡、键盘、GPU、随机数发生器都塞进
read/write。然后你会发现:“统一接口”在真实的硬件面前,永远会破功。
因为硬件和硬件之间,差异大到无法用”读”和”写”这两个动词概括。
引子:一个系统调用背后,是一支军队
你写下一行代码:
conn.Read(buf)你以为这是一个”读取”操作。但在硬件层面,它背后是一连串精心编排的协奏:
1. 用户态代码调用 Read
2. 陷入内核,进入 socket 层
3. socket 层发现没数据,把任务挂起
4. 网卡收到数据包,通过 DMA 写入内存
5. 网卡触发硬件中断
6. CPU 响应中断,进入中断处理程序
7. 中断处理程序唤醒内核线程,把数据交给 socket 层
8. socket 层把数据拷贝到用户态 buf
9. 返回用户态
这里涉及了中断、DMA、驱动、协议栈、调度,每一环都是操作系统精心设计的。
这一章,我们就钻到”设备”这一层,看看:
- 为什么设备要分成字符设备和块设备?
- 一次 I/O 到底怎么完成的?DMA 和中断怎么配合?
- 阻塞、非阻塞、异步,这三种 I/O 范式到底差在哪?
- io_uring 凭什么比 epoll 快?
- 以及,这套”设备抽象”面临着哪些无法回避的问题。
给你一句总纲:
设备的本质是”慢”和”异步”。
CPU 以纳秒为单位运转,而设备以微秒(SSD)、毫秒(机械盘)、甚至秒(网络)为单位响应。
操作系统所有的 I/O 设计,本质上都是在回答一个问题:怎么让”快”的 CPU 不被”慢”的设备拖死?
本章路线图
第一幕:设备的分类
Q1 为什么设备要分成字符设备和块设备?
Q2 网络设备为什么是"第三种"?它和文件的根本区别是什么?
第二幕:一次 I/O 的完整旅程
Q3 一次读磁盘,DMA 和中断是怎么配合的?
Q4 DMA 为什么重要?没有 DMA 会怎样?
Q5 中断风暴是什么?NAPI 怎么解决它?
第三幕:三种 I/O 范式
Q6 阻塞、非阻塞、异步,到底差在哪?
Q7 select/poll/epoll 的三次进化,解决了什么?
Q8 io_uring 凭什么更快?它改变了什么范式?
第四幕:抽象的破功与修补
Q9 ioctl 这个"逃生舱"说明了什么?
Q10 设备抽象面临哪些结构性的问题?
第一幕:设备的分类
Q1 为什么设备要分成字符设备和块设备?
Linux 把设备分成两大类(还有网络设备这个第三类,下一问讲):
字符设备(Character Device):
特点:按"字节流"访问,顺序读写,没有"位置"概念
例子:键盘、鼠标、串口、终端、声卡、/dev/urandom、/dev/null
操作:read / write(顺序的,不能 seek)
块设备(Block Device):
特点:按"块"(512B 或 4KB)访问,可以随机定位(seek),有缓存
例子:硬盘、SSD、U盘
操作:read / write / seek,且通常经过 page cache(缓冲)
为什么这么分?因为它们背后的物理特性完全不同:
| 字符设备 | 块设备 | |
|---|---|---|
| 访问单位 | 字节(流) | 块(512B/4KB) |
| 能否定位 | 不能(流式) | 能(seek 到任意块) |
| 是否缓冲 | 一般不缓冲 | 有 page cache |
| 典型延迟 | 不定(键盘等输入) | 微秒~毫秒级 |
| 数据有无”位置” | 无(流过去了就没了) | 有(数据固定在某个块) |
根本区别在于:数据有没有”可寻址的位置”。
- 键盘敲下一个键,这个数据”流过”就没了,没有”第 3 块的第 2 字节”这种概念 → 字符设备
- 磁盘上的数据,固定在第 100 号块,你可以随时去读 → 块设备
这个分类的本质,是”流”和”存储”的区别。
流(stream):数据来了又走,无法回放(键盘、网络、终端) 存储(storage):数据固定在某个位置,可以反复读(磁盘、SSD)
这个区分你在工程里也会遇到:
- 消息队列是”流”(消费了就没了,或者有 offset 才能回放)
- 数据库是”存储”(数据在某个位置,反复查)
- Kafka 是”流 + 存储的混合”(有 offset 可回放,但本质是流)
一个有趣的事实:/dev/null 和 /dev/zero 是字符设备。
/dev/null → 写进去的数据全部丢弃(读返回 EOF)
/dev/zero → 读出来全是 0(写进去的数据丢弃)它们没有物理硬件,是”纯软件实现的字符设备”。这说明:字符设备不是”硬件的分类”,是”接口的分类”——只要实现”流式 read/write”语义,你就是字符设备,不管背后是键盘还是内核里的一个函数。
Q2 网络设备为什么是”第三种”?它和文件的根本区别是什么?
网络设备(网卡)既不是字符设备,也不是块设备。它没有设备文件(不像磁盘有 /dev/sda),你用 ls /dev 找不到你的网卡。
为什么?因为网络 I/O 的语义和文件 I/O 有根本区别:
文件 I/O(磁盘):
数据是"持久的",你读的是"已经存在的数据"
读和写是"对称的"(读写同一个东西)
有"位置"概念(seek)
单播的(一个进程读,不影响其他进程读)
网络 I/O(socket):
数据是"瞬时的",你收的是"别人刚发的数据"
读和写是"不对称的"(你读别人写给你的,你写给别人读的)
没有"位置"(流过去了就没了)
需要"连接"和"地址"(发给谁、从哪收)
所以 socket 用了一套完全不同的系统调用:
// 文件:open/read/write/close
int fd = open("file", O_RDONLY);
read(fd, buf, n);
// socket:socket/bind/listen/accept/connect/sendto/recvfrom
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, &addr, ...);
listen(s, backlog);
int c = accept(s, ...);
recv(c, buf, n, 0);虽然 socket() 返回的也是 fd,你能对它 read/write,但建立连接、绑定地址、监听这些操作,是 read/write 表达不了的。
这里有个很深的洞察:
“一切皆文件”这个抽象,在”存储”上很成功,在”网络”上部分失败。
因为它假设了”读和写是对称的、数据是持久的、访问是单播的”。而网络完全不符合这三个假设。
当一个抽象的核心假设不成立时,强行套用这个抽象,就会漏。 socket 就是”文件抽象”在网络上”漏了”的结果——它保留了 fd(因为 fd 的”整数引用”机制确实好用),但抛弃了 open/read/write 的语义(因为网络语义对不上)。
第二幕:一次 I/O 的完整旅程
Q3 一次读磁盘,DMA 和中断是怎么配合的?
这是理解”操作系统怎么管理设备”的关键。我们拆一次完整的”读磁盘”。
场景: 程序要读文件,数据在磁盘上,page cache 里没有。
步骤 1:程序发起读
read(fd, buf, 4096) → 陷入内核 → VFS → 文件系统 → 块设备层
步骤 2:发起磁盘 I/O 请求
内核把请求放入设备的请求队列(request queue)
块设备层可能做 I/O 调度(合并相邻请求、排序)
步骤 3:DMA 开始搬数据
内核配置 DMA 控制器:"把磁盘的第 N 块,搬到内存地址 M"
(此时数据还没就绪,磁盘正在寻道/旋转)
步骤 4:CPU 去干别的
← 关键!发起 I/O 后,CPU 不傻等,它去调度别的进程
← 当前进程被标记为"等待 I/O",进入睡眠
步骤 5:磁盘就绪,DMA 搬完数据
DMA 控制器把数据从磁盘直接搬到内存(不经过 CPU!)
步骤 6:磁盘触发中断
磁盘(或 DMA 控制器)发一个硬件中断:"我干完了!"
步骤 7:CPU 响应中断
保存当前进程状态 → 进入中断处理程序
中断处理程序:唤醒那个"等待 I/O"的进程
步骤 8:进程被唤醒,继续执行
数据已经在内存里了,read 返回
这段旅程的精髓,是两个机制的配合:
DMA(Direct Memory Access,直接内存访问):
负责"搬数据",让数据绕过 CPU 直接从设备到内存
作用:CPU 不用自己一字节一字节地搬数据
中断(Interrupt):
负责"通知",设备干完了发个信号告诉 CPU
作用:CPU 不用轮询"你好了吗你好了吗"
为什么需要这两个配合?
如果没有 DMA,CPU 要自己搬数据(每读一字节,CPU 从设备寄存器读到内存,循环 4096 次)。这期间 CPU 什么也干不了,被”搬数据”这种低级劳动占用。
如果没有中断,CPU 要轮询设备状态(每隔一段时间问”好了吗”)。这浪费 CPU,而且响应不及时。
有了 DMA + 中断:
CPU 只做两件事:
1. 发起 I/O 请求(快,几微秒)
2. 响应中断,唤醒进程(快,几微秒)
中间的"搬数据"(慢,几毫秒),DMA 代劳,CPU 完全解放。
这就是”异步”的本质:CPU 不等待慢设备,发起请求后去干别的,设备干完用中断通知。
这个模式,你会在一生中反复遇到:
- 前端:发起 AJAX 请求(异步),回调收到响应(中断)
- 后端:发起下游 RPC(异步),future/callback 收到结果
- 消息队列:生产者发消息(异步),消费者消费(事件驱动)
- 硬件:DMA + 中断
核心思想都是:不要让”快”的一方,傻等”慢”的一方。 发起请求,登记一个”完成后的回调”,然后去干别的。
Q4 DMA 为什么重要?没有 DMA 会怎样?
这个问题能帮你理解”零拷贝”和”高性能 I/O”的根基。
没有 DMA 的时代(PIO,Programmed I/O):
CPU 要亲自把数据从设备搬到内存:
// 伪代码:没有 DMA 的读磁盘
for (i = 0; i < 4096; i++) {
buf[i] = read_device_register(); // CPU 从设备寄存器读一字节
}问题:
1. CPU 被"搬数据"这种低级劳动占用
读 4KB 要循环 4096 次,每次都要 CPU 参与
2. 搬数据期间,CPU 干不了别的
4KB 的搬运,可能耗时几百微秒,CPU 全耗在这
3. 效率极低
CPU 的主频(GHz)远快于设备,CPU 大部分时间在"等设备准备好下一字节"
有了 DMA:
CPU 配置好 DMA 控制器:
"源 = 磁盘第 N 块,目标 = 内存地址 M,长度 = 4096 字节,去搬吧"
然后 CPU 就解放了,去调度别的进程。
DMA 控制器(独立的硬件)负责:
从磁盘把 4096 字节搬到内存 M,搬完发中断。
DMA 的价值:
1. CPU 解放:CPU 只花几微秒配置 DMA,剩下的交给硬件
2. 并行:DMA 搬数据的同时,CPU 可以执行别的进程
3. 吞吐高:DMA 能跑满设备带宽,不经过 CPU 这个"中继"
DMA 和”零拷贝”的关系(这是关键,第 10 章会展开):
传统 read + write 的转发(比如文件通过 socket 发送):
磁盘 --DMA--> 内核 page cache --CPU拷贝--> 用户态 buf --CPU拷贝--> socket 缓冲区 --DMA--> 网卡
(第 1 次拷贝) (第 2 次拷贝) (第 3 次)
注意:DMA 只负责”设备和内存之间”的搬运,而”内存和内存之间”的搬运(page cache → 用户态 buf)要 CPU 来做。
所以传统方式有 4 次拷贝(1 次 DMA 读 + 1 次 CPU 拷贝 + 1 次 CPU 拷贝 + 1 次 DMA 写),其中 2 次 CPU 拷贝是纯浪费(数据从内核搬到用户,再从用户搬回内核)。
零拷贝(sendfile/splice)的思路就是:消除那 2 次 CPU 拷贝,让数据尽量只靠 DMA 搬。
sendfile:磁盘 --DMA--> page cache --DMA--> 网卡
(全程 DMA,CPU 只发指令,不搬数据)
这就是”为什么 DMA 重要”:DMA 是”设备 ↔ 内存”的零 CPU 通道,零拷贝技术就是尽量让数据走 DMA 通道,避开 CPU 拷贝。
Q5 中断风暴是什么?NAPI 怎么解决它?
中断风暴(Interrupt Storm) 是高性能网络面临的一个经典问题。
场景:高流量网络服务器,每秒收到 100 万个数据包。
如果每个数据包都触发一次中断:
100 万个中断/秒
每次中断处理:保存上下文 + 处理 + 恢复 ≈ 1-2 微秒
总共:1-2 秒/秒的 CPU 时间,全花在中断上
而且更糟的是:在中断处理期间,其他中断可能被屏蔽,系统响应变慢。
更极端的场景:中断多到 CPU 一直在处理中断,没时间干正经活,这叫”活锁”(livelock)——系统活着,但永远在处理中断,用户请求一个都处理不了。
NAPI(New API)的解决思路:
核心思想:“高流量时,关掉中断,改用轮询;低流量时,恢复中断。”
正常情况(低流量):
中断模式:来一个包,触发一次中断
响应快(微秒级)
高流量(高吞吐):
轮询模式:关掉中断,CPU 主动轮询网卡的队列,批量处理包
一次轮询处理几百个包,摊薄了中断开销
NAPI 的切换逻辑:
1. 中断模式收到一个包 → 触发中断
2. 中断处理程序判断"流量高了" → 关闭中断,注册到轮询队列
3. 内核软中断(softirq)轮询网卡,批量收包
4. 队列清空后 → 重新开启中断
这个设计的精妙之处:它承认”中断”和”轮询”各自适用的场景不同。
| 中断 | 轮询 | |
|---|---|---|
| 低流量 | 好(响应快,不浪费) | 差(一直轮询,浪费 CPU) |
| 高流量 | 差(中断风暴) | 好(批量处理,摊薄开销) |
所以 NAPI 是”自适应”的:根据流量动态切换。
这个”中断 vs 轮询”的权衡,你在工程里也会遇到:
- 消息队列消费:低流量用”推送”(push,类似中断),高流量用”拉取”(pull,类似轮询)
- 前端数据更新:低频用”事件推送”(WebSocket),高频用”轮询”
- 缓存更新:低频用”失效通知”,高频用”短 TTL + 主动拉”
核心原则:频率低,用”通知”(省资源);频率高,用”轮询/批量”(摊薄开销)。 而最高明的设计(像 NAPI)会根据负载动态切换。
第三幕:三种 I/O 范式
Q6 阻塞、非阻塞、异步,到底差在哪?
这是 I/O 领域最基础的三个概念,但很多人分不清(尤其是”非阻塞”和”异步”)。
用”去餐厅吃饭”来类比:
阻塞(blocking):
你点完菜,就坐在座位上傻等,菜不来你啥也不干。
(read 调用阻塞,直到数据就绪)
非阻塞(non-blocking):
你点完菜,问服务员"好了吗?"——"没好"。
你去干别的事,过一会儿又问"好了吗?"——"没好"。
你反复问,直到好了才吃。
(read 调用立刻返回 EAGAIN,你要自己轮询)
异步(asynchronous):
你点完菜,告诉服务员"好了叫我",然后去干别的事。
菜好了,服务员主动叫你。
(发起 read 后立刻返回,数据就绪后内核主动通知你)
用技术语言精确表述:
// 阻塞 I/O
int n = read(fd, buf, size);
// 调用后一直阻塞,直到数据就绪,返回数据
// 期间线程/进程不能干别的
// 非阻塞 I/O(fd 设为 O_NONBLOCK)
int n = read(fd, buf, size);
// 立刻返回:
// 有数据 → 返回数据
// 没数据 → 返回 -1,errno = EAGAIN("暂时没有,你再试试")
// 你需要自己循环重试,或者配合 select/epoll
// 异步 I/O(io_uring、老的 aio)
io_submit(req); // 提交请求,立刻返回
// ... 干别的 ...
io_getevents(...); // 查询哪些请求完成了关键区别(这是重点):
阻塞 vs 非阻塞:区别在"调用本身会不会等"
- 阻塞:read 会等,等到有数据才返回
- 非阻塞:read 不等,没数据就返回 EAGAIN
非阻塞 vs 异步:区别在"谁负责等"
- 非阻塞:你在等(你反复问"好了吗")
- 异步:内核在等(你提交了就去干别的,内核好了通知你)
一句话总结:
阻塞/非阻塞,是”调用方的行为”;同步/异步,是”谁在等待完成”。
- 阻塞 = 同步等待(自己等)
- 非阻塞 = 同步轮询(自己反复查)
- 异步 = 异步完成(别人等,好了通知我)
Go 的巧妙之处(这是重点):
Go 的 conn.Read() 看起来是阻塞的(代码是同步写的),但底层是异步的(netpoller + epoll):
// 你的代码:阻塞式写法
buf := make([]byte, 1024)
n, err := conn.Read(buf) // 看起来"阻塞"了
// 底层:非阻塞 fd + epoll + netpoller
// 1. conn 的 fd 实际是 O_NONBLOCK 的
// 2. Read 没数据时,不真的阻塞线程,而是:
// - 把当前 goroutine 挂到 netpoller 上
// - 让出 P,其他 goroutine 继续跑
// 3. epoll 发现数据就绪,唤醒这个 goroutine
// 4. goroutine 继续,Read 返回这就是 Go 最优雅的地方:用”阻塞的写法”获得”异步的性能”。
程序员的心智负担是同步的(好写、好懂),但底层的性能是异步的(高并发)。
这就是”抽象的价值”的又一例证:好的抽象,让你用简单的心智模型,享受复杂机制的性能。
对比 Rust 的 async/await:性能也好,但程序员要面对
Future、Pin、状态机这些复杂概念。Go 把复杂度藏在了运行时里。
Q7 select/poll/epoll 的三次进化,解决了什么?
这是”怎么同时等待多个 fd”这个问题的三次迭代。理解它们的进化,能帮你理解”高性能网络”的本质。
问题:一个服务器要同时处理 1 万个连接,怎么知道哪些连接有数据可读?
第一代:select
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < 10000; i++) {
FD_SET(fds[i], &readfds); // 把所有 fd 放进集合
}
select(max_fd + 1, &readfds, NULL, NULL, NULL);
// select 返回后,遍历所有 fd,看谁在 readfds 里问题:
1. fd_set 是位图,最大 1024 个 fd(FD_SETSIZE 限制)
→ 超过 1024 个连接就不行了
2. 每次调用 select,都要把整个 fd_set 从用户态拷贝到内核态
→ 1 万个 fd 的位图,每次拷贝(虽然才 1.25KB,但频繁)
3. 内核要遍历所有 fd,检查哪些就绪(O(n))
→ 每次 select,内核遍历 1 万个 fd
4. 返回后,用户还要遍历所有 fd,找到底哪些就绪(O(n))
→ 又是 1 万次检查
第二代:poll
struct pollfd fds[10000];
for (int i = 0; i < 10000; i++) {
fds[i].fd = fds_raw[i];
fds[i].events = POLLIN;
}
poll(fds, 10000, -1);改进:
1. 用 pollfd 数组代替 fd_set 位图,突破了 1024 限制
2. 但仍然:
- 每次拷贝整个数组到内核(O(n))
- 内核遍历所有 fd(O(n))
- 用户遍历所有 fd(O(n))
第三代:epoll(Linux)
int epfd = epoll_create();
struct epoll_event ev;
for (int i = 0; i < 10000; i++) {
ev.events = EPOLLIN;
ev.data.fd = fds[i];
epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i], &ev); // 注册一次!
}
struct epoll_event events[100];
int n = epoll_wait(epfd, events, 100, -1); // 只返回就绪的革命性改进:
1. epoll_ctl 注册一次,之后不用重复注册
→ 避免了每次"把 1 万个 fd 拷贝进内核"
2. 内核维护就绪链表(ready list)
→ 哪个 fd 就绪了,内核把它加到就绪链表
→ epoll_wait 只返回"就绪的"那些 fd,不是全部
→ 内核不用遍历所有 fd,用户也不用遍历所有 fd
3. 复杂度从 O(n) 降到 O(就绪数)
→ 1 万个连接,但只有 10 个有数据,epoll_wait 只返回 10 个
epoll 的核心思想:把”轮询所有 fd”变成”事件通知”。
select/poll: 你每次问内核"所有 fd 里谁就绪了?"→ 内核遍历全部 → 返回全部
epoll: 你注册一次,内核记住;谁就绪了内核记到就绪链表
你问"就绪链表里有什么?"→ 内核直接返回就绪的
epoll 还有两个关键细节:
细节一:LT(Level Triggered,水平触发)vs ET(Edge Triggered,边缘触发)。
LT(默认):只要 fd 还有数据可读,epoll_wait 就每次都返回它
→ 你漏读了一点,下次还会通知你
→ 简单,但可能重复通知
ET:只有 fd 从"无数据"变成"有数据"的瞬间,才通知一次
→ 你必须一次性读完,否则剩下的数据不会再有通知
→ 高效,但要求你配合非阻塞 fd,且要循环读到 EAGAIN
ET 模式必须配合非阻塞 fd,否则一个 fd 数据没读完,ET 不会再通知,但你的阻塞 read 会卡住。
细节二:epoll 的性能陷阱——惊群(thundering herd)。
一个经典的坑:多进程/多线程同时 epoll_wait 同一个 epfd,一个连接就绪,所有等待者都被唤醒,然后只有一个抢到连接,其他又睡回去。白白浪费。
Nginx 用”多进程各自 epoll + SO_REUSEPORT”或者”锁”来解决。Go 的 netpoller 内部也处理了这个问题。
select/poll/epoll 的进化,体现了一个重要的设计原则:
从”轮询”到”事件驱动”。
轮询:主动问,问所有人,浪费。 事件驱动:注册一次,谁变了通知谁,精准。
这个原则在工程里无处不在:
- 数据库变更通知:从”轮询数据库”到”binlog 订阅”
- 配置变更:从”定期拉取”到”watch 推送”(etcd watch)
- 前端:从”定时刷新”到”WebSocket 推送”
记住:凡是”要监控很多目标、但只有少数会变化”的场景,都用事件驱动,不要轮询。
Q8 io_uring 凭什么更快?它改变了什么范式?
io_uring 是 Linux 5.1(2019)引入的,被认为是”异步 I/O 的终极答案”。它快在哪?
先看传统 I/O 的痛点:
传统 read/write:
每次都要"系统调用"→ 陷入内核 → 处理 → 返回用户态
系统调用的开销:~100-500 纳秒(陷入 + 返回 + 检查)
高并发场景(每秒 100 万次 I/O):
100 万次 × 500 纳秒 = 0.5 秒,光系统调用就花了半秒 CPU
io_uring 的核心创新:共享内存环形队列(ring buffer)。
io_uring 建立两个共享内存队列:
SQ(Submission Queue):用户态 → 内核态(提交 I/O 请求)
CQ(Completion Queue):内核态 → 用户态(返回完成结果)
关键:这两个队列是"共享内存",用户态和内核态都能直接读写!
这改变了什么?
传统方式:
用户态 → 系统调用 → 内核态(提交请求)
(每次提交都要"陷入内核",贵)
io_uring:
用户态直接把请求写进 SQ(共享内存,不需要系统调用!)
然后通知内核一次(或者内核自己发现)
内核处理完,把结果写进 CQ(共享内存)
用户态直接读 CQ(不需要系统调用!)
关键区别:提交和完成,都不需要系统调用(大部分时候)。
传统:N 次 I/O = N 次系统调用(N 次陷入内核)
io_uring:N 次 I/O = 批量提交 + 批量完成(可能 0-1 次系统调用)
io_uring 的三大优势:
1. 减少系统调用
批量提交:把 100 个 I/O 请求一次性写进 SQ,通知内核一次
2. 真正的异步
提交后立刻返回,完成时内核写 CQ,你稍后查
不需要"每个 I/O 一个线程/回调"
3. 减少拷贝
配合 registered buffer(预注册的缓冲区),
内核直接读写用户的内存,减少拷贝
一个对比表:
| 同步 read | epoll | io_uring | |
|---|---|---|---|
| 范式 | 阻塞/非阻塞 | 事件驱动 | 真正的异步 |
| 系统调用 | 每次 I/O 一次 | 每次事件一次 | 批量,接近零 |
| 数据拷贝 | 有 | 有 | 可避免(registered buffer) |
| 复杂度 | 低 | 中 | 高 |
| 适用 | 低频 I/O | 高并发网络 | 极致性能 I/O |
io_uring 的代价:
1. 复杂:两个环形队列、内存屏障、提交/完成的管理,心智负担大
2. 安全:共享内存环形队列,曾经暴露过多个安全漏洞
3. 生态:虽然库(liburing)成熟了,但直接用好它需要功力
io_uring 在工程里的应用:
- 数据库:MySQL 8.0.x 实验性支持,RocksDB 有 io_uring 后端
- 存储引擎:ScyllaDB、Ceph 都用了 io_uring
- Go:Go 1.25 之后逐步在内部用 io_uring(读文件等场景)
- Nginx:有 io_uring 的模块(用于静态文件服务)
io_uring 给你的启示(比技术本身重要):
“系统调用”是 I/O 的一笔”税”。当你的 I/O 频繁到一定程度,这笔税就值得用复杂的方案(io_uring)来避。
判断标准:你的 I/O 频率是多少?
- 每秒几百次 → read/write 够用
- 每秒几万次 → epoll
- 每秒几十万次以上 → 考虑 io_uring
这和第 1 章讲的”多级页表 vs TLB”是同一个逻辑:当某个操作的成本占比高到不可忽略,就值得引入缓存/批量/旁路来摊销它。
第四幕:抽象的破功与修补
Q9 ioctl 这个”逃生舱”说明了什么?
上一章提过 ioctl,这一章展开讲,因为它深刻揭示了”统一接口”的局限。
ioctl 是什么:
int ioctl(int fd, unsigned long request, ...);
// 一个"万能"系统调用:对 fd 执行 request 指定的操作
// 参数是变长的,类型不安全为什么需要它?
因为 read/write 表达不了设备的所有操作:
// 查询终端窗口大小(不是读数据,是查属性)
ioctl(fd, TIOCGWINSZ, &winsize);
// 设置串口波特率
ioctl(fd, TCSETS, &termios);
// 查询块设备大小
ioctl(fd, BLKGETSIZE64, &size);
// 设置 socket 缓冲区大小(其实有 setsockopt,但早期也用 ioctl)
ioctl(fd, FIONBIO, &nonblock); // 设置非阻塞
// 弹出光驱
ioctl(fd, CDROMEJECT, NULL);ioctl 的问题:
1. 类型不安全
request 是个 int 操作码,参数是 void*
传错类型,编译器不报错,运行时行为未定义
2. 全局命名空间
成千上万个操作码,没有组织,命名容易冲突
3. 破坏抽象
说好"一切皆 read/write",结果开了个"什么都能干"的后门
调用方必须知道"这个设备的 ioctl 有哪些操作码"才能用
Linus Torvalds 的原话(大意):
“ioctl 是 Unix 设计中最丑陋的部分之一。它本质上是一个’我们不知道怎么把这个操作塞进统一的接口,所以开个后门’的垃圾桶。”
ioctl 说明了什么?
任何”统一抽象”都有一个极限:当被抽象的东西差异太大时,统一接口就不够用了,你必须开”逃生舱”。
逃生舱本身不是问题(现实需要它),问题是:逃生舱开得太多、太随意,抽象就名存实亡了。
这个教训在工程里的映射:
你的"统一接口"(比如一个 RPC 网关、一个数据访问层),
当遇到"特殊需求"时,你是:
A. 给特殊需求开个后门(类似 ioctl)
B. 扩展统一接口(让接口能表达新需求)
C. 重新审视:这个抽象是不是该拆分了?
好的实践:
- 后门要少,且要显式标记("这是逃生舱,慎用")
- 后门多了,说明抽象该重构了
- 就像 Linux:ioctl 还在,但新功能尽量用 sysfs、netlink 等
更结构化的接口,而不是继续往 ioctl 里堆
Q10 设备抽象面临哪些结构性的问题?
最后泼冷水。设备 I/O 这套设计,有几个根子上的矛盾:
问题一:中断的不可控性。
中断是”硬件强插进来的”,它不管你 CPU 正在干什么。
中断的三大问题:
1. 中断延迟:高优先级中断会延迟低优先级的中断和进程
2. 中断风暴:高流量下中断把 CPU 淹没(Q5 讲过)
3. 中断的"软实时"困境:你无法精确控制"中断什么时候来"
→ 这对实时系统是致命的
问题二:设备速度的”量级鸿沟”。
CPU 寄存器: ~0.3ns
CPU L1 缓存: ~1ns
主存: ~100ns
NVMe SSD: ~50-100μs(比主存慢 1000 倍)
机械硬盘: ~10ms(比主存慢 10 万倍)
网络(同机房):~0.1-1ms
网络(跨地域):~100ms+
这个鸿沟意味着:任何”同步等待设备”的设计,都是在浪费 CPU 的 99.99% 时间。
所以现代 I/O 的核心就是”异步化”——但这个异步化引入了复杂性(回调、状态机、io_uring),而这些复杂性本身又有代价。
问题三:抽象的”最小公分母”困境。
设备差异太大,统一接口只能覆盖”共性”,覆盖不了”特性”:
共性(read/write 能表达的):读数据、写数据
特性(read/write 表达不了的):
- SSD 的 TRIM(告诉 SSD 哪些块可回收)
- 磁盘的 SMART(健康状态)
- GPU 的复杂计算(CUDA)
- RDMA 网卡的远程内存访问
这些特性要么走 ioctl(丑陋),要么绕过内核(GPU 用用户态驱动,RDMA 用用户态库)。
问题四:内核态 vs 用户态驱动的两难。
内核态驱动(传统):
优点:安全、统一、有保护
缺点:一个 bug 就内核崩溃(蓝屏/panic);开发慢(内核开发门槛高)
用户态驱动(DPDK、SPDK):
优点:性能极致(绕过内核)、开发快(用户态调试)
缺点:放弃内核的保护、要自己管理设备
高性能网络(DPDK)和高性能存储(SPDK)都选择了”用户态驱动”,绕过内核。
这背后的逻辑是:
内核的 I/O 栈,为了"通用"和"安全",牺牲了性能(层层抽象、拷贝、锁)。
当你追求极致性能(每秒千万级包、百万级 IOPS),
内核的通用 I/O 栈就成了瓶颈,你必须绕过它。
但绕过的代价是巨大的:你要自己实现内核原本提供的一切(调度、内存管理、中断处理、安全隔离)。
这是一个深刻的两难,也是理解”系统设计”的关键:
通用性和极致性能,永远是对立的。
- 通用方案(内核 I/O 栈):好用、安全、够快(对 99% 场景)
- 极致方案(DPDK/SPDK):快 10 倍,但你要自己扛起通用方案帮你做的一切
你的选择,取决于你在”通用”和”极致”的光谱上站哪。
这跟第 1 章讲的”什么时候该自己实现缓存”是同一个道理:当你需要内核没有的领域知识(或内核通用方案成为瓶颈)时,就绕过它。
思考题
思考题 1
面试官问:“epoll 为什么比 select 快?“请从”数据拷贝次数、时间复杂度、事件通知方式”三个角度完整回答,并说明 ET 和 LT 模式的区别以及使用场景。
参考答案
核心答案:epoll 把”每次都要遍历所有 fd”变成了”只返回就绪的 fd”,从 O(n) 降到 O(就绪数),且避免了重复拷贝。
角度一:数据拷贝次数。
select/poll:
每次调用,都要把"要监控的 fd 集合"从用户态拷贝到内核态
(select 拷贝 fd_set 位图,poll 拷贝 pollfd 数组)
调用频率越高,拷贝开销越大
→ 每次 select 都拷贝一次"全部 fd"
epoll:
epoll_ctl 注册一次,内核就记住了 fd(用红黑树存)
epoll_wait 不需要再拷贝 fd 集合
→ 只有"注册/删除"时才拷贝一次
→ 监控期间,零拷贝
角度二:时间复杂度。
select/poll:O(n)
n = 监控的 fd 总数
每次调用,内核都要遍历所有 fd,检查谁就绪
→ 1 万个连接,每次 select 都要遍历 1 万次
epoll:O(k)
k = 就绪的 fd 数
内核维护"就绪链表",fd 就绪时被加入链表
epoll_wait 只从就绪链表取,不用遍历所有 fd
→ 1 万个连接,只有 10 个有数据,epoll_wait 只处理 10 个
角度三:事件通知方式。
select/poll:轮询(polling)
用户每次都要"问"内核:"谁就绪了?"
内核被动地每次遍历一遍
epoll:事件驱动(event-driven)
用户注册一次,内核主动把就绪的 fd 记到就绪链表
用户"等"就绪链表有东西(而不是每次都问)
关键理解:epoll 的本质是”从轮询到事件驱动”的转变。
ET 和 LT 的区别:
LT(Level Triggered,水平触发,默认):
只要 fd 还有数据可读,epoll_wait 就会一直返回它
例:fd 有 100 字节,你读了 50 字节,下次 epoll_wait 还会返回这个 fd
(因为还有 50 字节没读)
优点:不容易漏(你漏了,下次还会通知)
缺点:可能重复通知(同一个 fd 反复出现在结果里)
适用:简单场景,不想处理"必须读干净"的逻辑
ET(Edge Triggered,边缘触发):
只有 fd 状态"变化"的瞬间才通知一次
例:fd 从"无数据"变成"有数据"→ 通知一次
如果你只读了 50 字节,还剩 50 字节,不会再通知!
优点:高效(每个事件只通知一次)
缺点:容易漏(必须一次性读干净)
适用:高性能场景,配合非阻塞 fd + 循环读到 EAGAIN
ET 模式的标准用法(关键):
// ET 模式必须:
// 1. fd 设为非阻塞
// 2. 循环读到 EAGAIN 才停(一次性读干净)
for (;;) {
n = read(fd, buf, sizeof(buf));
if (n < 0) {
if (errno == EAGAIN) break; // 读完了
// 真正的错误
}
if (n == 0) { /* EOF */ break; }
// 处理数据
}为什么 ET 必须配合非阻塞?
因为 ET 只通知一次。如果你用阻塞 read,而这次通知后数据没读干净(比如你只 read 了一次),剩下的数据就”永远不会再被通知”了,你的阻塞 read 会永远卡住。
非阻塞 read 让你能循环读到 EAGAIN(“暂时没有数据了”),确保一次性读干净。
追问:为什么 ET 效率更高?
因为 LT 模式下,一个 fd 只要还有数据,就会反复出现在 epoll_wait 的结果里。这导致:
- epoll_wait 返回的集合里可能有大量”还没处理完”的 fd
- 内核要反复检查这些 fd 的状态
- 用户要反复处理这些”上次没处理完”的 fd
ET 模式避免了这些,每个事件只通知一次,强制你”一次处理干净”。
追问:Go 用 LT 还是 ET?
Go 的 netpoller 用的是 ET 模式 + 非阻塞 fd。因为 Go 需要”精确控制”每个连接的读写,LT 的重复通知对 Go 来说是不必要的开销。
(注意:这是一个容易被问到的细节。Go runtime 内部 epoll 用的是 EPOLLET,但 Go 的开发者通常不需要关心,因为 netpoller 把这些细节都藏起来了。)
这道题考察什么:
- 不是背”epoll 比 select 快”,而是说出快在哪(拷贝、复杂度、通知方式)
- 理解 O(n) vs O(就绪数) 的区别(这是核心)
- 理解 ET/LT 的语义差异(这是实操)
- 知道 ET 必须配合非阻塞(这是细节,区分”用过”和”懂”)
思考题 2
你的服务要做一个”高性能 TCP 代理”(转发流量),每秒要处理 10 万个连接。你会选择什么 I/O 模型?Go 的 netpoller、裸 epoll、还是 io_uring?为什么?
参考答案
先算账,明确需求:
10 万连接,每秒处理。关键指标:
- 连接数:10 万(高并发)
- 每连接的活跃度:未知(这是关键!)
如果是"长连接但大部分空闲"(比如 IM、物联网设备):
每时刻只有 1% 的连接活跃(1000 个在收发数据)
→ epoll 的 O(就绪数) 特性完美契合
如果是"所有连接都很活跃"(比如代理转发大流量):
每时刻 10 万个都在收发
→ 瓶颈在数据搬运和 CPU,不在"等待就绪"
方案对比:
方案 A:Go + netpoller(推荐首选)。
func proxy(conn net.Conn) {
defer conn.Close()
upstream, _ := net.Dial("tcp", backend)
defer upstream.Close()
// 双向转发(用 io.Copy 或自己写)
go io.Copy(upstream, conn) // 客户端 → 后端
go io.Copy(conn, upstream) // 后端 → 客户端
}优点:
- 代码极简(阻塞式写法,异步底层)
- 10 万个 goroutine = 几十 MB 内存,毫无压力
- netpoller 内部就是 epoll,性能已经很好
- 开发效率高,bug 少
缺点:
- 每个连接 2 个 goroutine + 数据拷贝(io.Copy 有用户态/内核态拷贝)
- 极致性能场景(百万连接、饱和吞吐)可能不够
结论:10 万连接,Go 的方案完全够用,这是最务实的选择。
方案 B:裸 epoll(C/C++)。
优点:
- 性能天花板最高(无运行时开销)
- 精细控制(可以优化拷贝、内存池)
缺点:
- 开发复杂(状态机、连接管理、内存管理全自己写)
- bug 风险高(内存泄漏、竞态、边界条件)
结论:除非你对性能有极致要求,否则裸 epoll 的性价比不高。10 万连接对 epoll 来说不是问题,但对开发者来说是巨大的心智负担。
方案 C:io_uring。
优点:
- 批量化 I/O,减少系统调用
- 真正的异步,零拷贝潜力
缺点:
- 复杂(环形队列、屏障、生命周期管理)
- 生态相对新
- 对于”TCP 代理”这种场景,io_uring 的优势(减少系统调用)可能被”数据搬运”的成本掩盖
结论:io_uring 更适合”大量小 I/O”(比如数据库、存储),对”TCP 代理”这种”连接多但单连接数据量不定”的场景,收益不确定。
我的最终答案:
首选:Go + netpoller(方案 A)
理由:
1. 10 万连接对 Go 来说不是压力(goroutine 轻量)
2. netpoller 底层就是 epoll,性能已经足够
3. 开发效率和正确性远高于裸 epoll
4. "高性能代理"的瓶颈往往不在 I/O 模型,而在:
- 数据拷贝(可以用 splice/零拷贝优化)
- 连接管理(保活、超时、优雅关闭)
- 背压(下游慢时怎么办)
只有在满足以下条件时才考虑裸 epoll / io_uring:
- Go 的方案实测达不到性能目标(要有 benchmark 证据)
- 团队有 C/C++ 高手,能驾驭裸 epoll 的复杂度
- 场景是"海量小包 + 极致吞吐"(比如 DDoS 清洗、核心网关)
一个重要的工程判断(这比选型本身重要):
不要”为了高性能而高性能”。
很多人一上来就选裸 epoll、io_uring、DPDK,觉得”Go 不够快”。但:
- 10 万连接,Go 的 netpoller 实测能扛(单机几十万连接没问题)
- 真正的瓶颈可能在业务逻辑、下游、数据库,不在 I/O 模型
- 裸 epoll 的开发成本是 Go 的 10 倍,bug 率也是 10 倍
正确顺序:先用最简方案(Go)→ 压测 → 找到真实瓶颈 → 针对瓶颈优化。
而不是:先假设 Go 不够快 → 上裸 epoll → 花了 3 个月 → 发现瓶颈根本不在 I/O。
补充:如果真到了要优化的程度,Go 里也能做零拷贝:
// 用 splice 做零拷贝转发(需要 Linux 支持)
// 通过 syscall 或者 golang.org/x/sys/unix 包
// 把 socket → socket 的转发变成内核态 splice,避免用户态拷贝但这是”最后 10% 的优化”,先确保前面 90% 做对了。
思考题 3
“中断和轮询,哪个更好?“这是个陷阱题。请说明为什么它是陷阱,并给出正确的回答框架。
参考答案
为什么是陷阱题:
因为”中断好还是轮询好”这个问题本身是错的——没有绝对的”更好”,只有”在什么负载下更好”。
中断和轮询各有优势,取决于”事件频率”:
事件频率低:中断更好
- 中断"按需响应",没事件不消耗 CPU
- 轮询会"空转",浪费 CPU
事件频率高:轮询更好
- 中断风暴:事件太频繁,CPU 全在响应中断
- 轮询批量处理:一次轮询处理多个事件,摊薄开销
正确的回答框架:
第一步:指出这是个"负载相关"的问题,没有绝对答案
第二步:分别说明中断和轮询的适用场景
中断(Interrupt):
机制:设备主动通知 CPU
优点:响应快(微秒级)、低负载时零开销
缺点:高负载时中断风暴、中断延迟不可控
适用:低频事件(键盘、鼠标、低频 I/O)
轮询(Polling):
机制:CPU 主动查设备状态
优点:高负载时批量处理、无中断风暴
缺点:低负载时空转浪费、响应延迟(取决于轮询间隔)
适用:高频事件(高吞吐网络、繁忙磁盘)
第三步:给出"自适应"这个最优解
最好的方案是动态切换(这就是 NAPI 的做法):
- 低流量:中断模式(响应快)
- 高流量:切到轮询模式(批量处理)
- 流量下降:切回中断
这个框架为什么重要?
因为它体现了一个通用的工程思维:“二选一”的问题,往往可以通过”自适应”来解决。
类似的例子:
"缓存该设多久?"
→ 自适应 TTL(根据访问频率动态调整)
"连接池该多大?"
→ 动态扩缩(根据负载自动调整)
"该用推送还是拉取?"
→ 混合(低频推送,高频拉取,或动态切换)
"限流该用固定窗口还是滑动窗口?"
→ 都有缺陷,业界用"自适应限流"(如 BBR 拥塞控制)
更深的洞察:
中断 vs 轮询,本质是”推”(push)和”拉”(pull)的区别。
- 推(中断):事件源主动通知,适合”事件少”(否则通知风暴)
- 拉(轮询):消费者主动获取,适合”事件多”(批量拉取)
而最优解往往是”推拉结合 + 动态切换”。
这道题在考察什么:
- 知不知道”没有银弹”,答案取决于负载
- 能不能说清”中断”和”轮询”各自的边界条件
- 知不知道”自适应”这种更高阶的解法
- 能不能把”中断/轮询”抽象成”推/拉”这个更普适的模型
能答出第 4 层,就是”精通”级别——你不仅知道这两个机制,还知道它们是一个更普遍模式的实例。
思考题 4(开放题)
现代高性能网络和存储(DPDK、SPDK)选择”绕过内核,用户态驱动”。这意味着放弃了内核提供的什么?又得到了什么?你认为什么情况下值得做这个交换?
参考答案
开放题,考察你对”通用 vs 极致”这个权衡的理解深度。
先搞清楚 DPDK/SPDK 在做什么:
传统 I/O 路径:
应用 → 系统调用 → 内核协议栈 → 驱动 → 硬件
(层层抽象,有保护,有拷贝,有锁)
DPDK/SPDK 路径:
应用 → 用户态驱动 → 硬件
(绕过内核,直接操作硬件)
放弃了什么(内核提供的”服务”):
1. 安全隔离
内核态驱动有特权级保护,用户态驱动没有
→ DPDK 的 bug 可能直接破坏硬件状态、甚至系统
2. 统一的资源管理
内核帮你管理:内存、中断、设备初始化、DMA 配置
→ 用户态驱动要自己管理这些
3. 通用性
内核驱动适配所有设备、所有场景
→ 用户态驱动要针对特定网卡/磁盘定制
4. 保护机制
内核防止:恶意程序、越界访问、资源泄漏
→ 用户态驱动全靠自己约束
5. 调试和观测工具
perf、strace、eBPF 这些工具针对内核 I/O 栈
→ 用户态驱动没有这些现成工具
得到了什么(性能):
1. 零系统调用
数据面不经过内核,没有陷入/返回的开销
2. 零拷贝
数据直接进用户态内存,不经内核缓冲区
3. 无锁
每个核独立处理自己的队列,无锁竞争
4. 精细控制
可以精确控制缓存、内存布局、批处理策略
性能差异(量级):
传统 Linux 网络栈:~100 万 pps(每秒包数,单核)
DPDK:~1000 万 - 5000 万 pps(单核)
传统存储:~10 万 IOPS(单核)
SPDK:~100 万+ IOPS(单核)
差距:10-50 倍
什么情况下值得做这个交换?
值得绕过内核,如果:
1. 性能是核心竞争力
例:DDoS 清洗、高频交易、核心网关、云存储后端
你的业务"卖的就是性能",慢 10 倍就是死
2. 有专业团队
用户态驱动是"高手活",要懂硬件、懂 DMA、懂缓存
没有团队,做不好,反而引入 bug
3. 场景简单可控
数据面逻辑简单(转发、读写块),控制面可以慢
如果业务逻辑复杂,绕过的收益会被业务逻辑淹没
4. 能承担风险
用户态驱动的 bug 可能导致系统崩溃、数据损坏
不值得绕过,如果:
1. 你的瓶颈不在 I/O(在业务逻辑、数据库、下游)
2. 性能要求"够用就行"(内核 I/O 栈已经够快)
3. 团队不熟悉底层
4. 需要内核的安全隔离(多租户、不可信代码)
一个关键的判断框架:
先问”我的瓶颈真的在 I/O 吗?”
90% 的情况下,瓶颈不在 I/O 栈,而在业务逻辑、数据库查询、下游服务。 花 3 个月用 DPDK 重写,结果瓶颈根本不在网络,是巨大的浪费。
只有当你用 perf/压测证明了”内核 I/O 栈就是瓶颈”,才值得考虑绕过它。
更深的洞察(这是这道题的精髓):
“绕过内核”和”自己实现缓存”(第 3 章)是同一个决策的不同表现:
当通用机制(内核 I/O 栈、page cache)成为瓶颈,或者你拥有通用机制没有的领域知识时,才值得”接管”。
而”接管”的代价,是你必须自己承担通用机制原本帮你做的一切(安全、隔离、管理、调试)。
这个权衡,贯穿了整个操作系统和整个软件工程:
- 用不用虚拟内存(实时系统不用)
- 用不用 page cache(数据库用 O_DIRECT)
- 用不用内核 I/O 栈(DPDK 不用)
- 用不用框架(极致性能场景裸写)
“通用”和”极致”是两个端点,你永远在它们之间选位置。 而选择的前提,是清楚地知道:通用方案帮你做了什么、极致方案要你自己做什么。
一个具体的决策清单(可复用):
考虑"绕过/自实现"时,问自己:
1. 实测过吗?
□ 有没有用 perf 证明瓶颈就在这一层?
2. 领域知识?
□ 我有没有内核没有的信息(访问模式、优先级、数据关系)?
3. 团队能力?
□ 有没有人能驾驭"自实现"的复杂度?
4. 收益/代价?
□ 绕过能快多少?自实现的成本(开发、bug、维护)是多少?
5. 风险?
□ 自实现的 bug 会导致什么?能承受吗?
全部答”是”,才值得做。缺一项,都该谨慎。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 04 章 · 设备与 I/O:统一接口下的暗流 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【设备分类的本质】 │
│ 字符设备 = 流(无位置),块设备 = 存储(可寻址) │
│ 网络设备 = 第三种(不对称、瞬态、需连接)→ socket 是"漏了的文件"│
│ │
│ 【一次 I/O 的配合】 │
│ DMA 负责"搬数据"(设备↔内存,绕过 CPU) │
│ 中断 负责"通知"(设备完成,唤醒 CPU) │
│ 核心:不让快的 CPU 傻等慢的设备 │
│ │
│ 【三种 I/O 范式】 │
│ 阻塞 = 自己等 │
│ 非阻塞 = 自己反复查(EAGAIN) │
│ 异步 = 别人等,好了通知我 │
│ Go 的优雅:阻塞写法 + 异步底层(netpoller) │
│ │
│ 【I/O 多路复用的进化】 │
│ select → poll → epoll:从 O(n) 到 O(就绪数) │
│ 本质:从"轮询"到"事件驱动" │
│ epoll 的 LT/ET:重复通知 vs 边缘通知(ET 必须非阻塞) │
│ │
│ 【io_uring 的革命】 │
│ 共享内存环形队列(SQ/CQ),提交和完成零系统调用 │
│ 把"系统调用"这笔税降到了接近零 │
│ │
│ 【核心权衡】 │
│ 通用(内核 I/O 栈)vs 极致(DPDK/SPDK) │
│ 绕过的代价:放弃安全、隔离、管理、调试工具 │
│ 判断:先证明瓶颈真的在 I/O,再考虑绕过 │
│ │
│ 【可迁移的原则】 │
│ 1. 事件频率低→通知(中断/推送),频率高→轮询/批量(NAPI 自适应)│
│ 2. 监控多但变化少→事件驱动,不要轮询 │
│ 3. 系统调用的税,频率够高就值得用批量/旁路摊销 │
│ │
│ 【一句话】 │
│ 设备是"慢"和"异步"的,所有的 I/O 设计都在回答: │
│ 怎么让快的 CPU 不被慢的设备拖死? │
│ │
│ 【第一部完 · 第二部预告】 │
│ 资源、执行流、文件、设备都搞清楚了, │
│ 但它们是"共享"的——下一部讲并发, │
│ 讲多核时代"共享状态"是怎么被管理和维护秩序的。 │
└──────────────────────────────────────────────────────────────┘