操作系统学习笔记 · 第 23 课 · 网络 I/O 模型
上一课我们写了个 TCP echo 服务器,但它有个致命毛病:一次只能服务一个客户端。第一个客户端连进来不说话,服务器就永远卡在 read 上,第二个人怎么等都进不来。这一课,我们解决这个问题——让一个进程同时伺候成千上万个客户端。核心武器,就是网络 I/O 模型:阻塞、非阻塞、多路复用、异步。从"一个接线员"说起
想象你是客服接线员,负责接电话。有两种工作方式:
- 死等式:接起一个电话,就只守着这一个,直到对方挂断,你才接下一个。其他来电全在排队——这就是阻塞 I/O;
- 前台式:你雇一个前台,帮你盯着所有电话线,哪条线响了就告诉你,你只处理响的那条——这就是多路复用。
上一课的服务器,就是"死等式接线员"。这一课,我们教它雇个前台。
23.1 阻塞 I/O 的痛点
一句话理解:上节课的服务器一次只服务一个客户端——accept和read都会卡住,第二个客户端只能排队。
回顾上一课服务器的流程:
accept卡住——等第一个客户端来;- 来了之后
read卡住——等这个客户端发数据。
问题就出在第二步:第一个客户端连进来后不说话,服务器就永远卡在 read 上,既不能去 accept 新的连接,也服务不了别人。
一句话:阻塞 I/O 让一个进程一次只能"专注"于一个连接,其余全靠排队。这在高并发场景下是灾难。
23.2 四种 I/O 模型
先总览,把四种模型摆在桌面上:
| 模型 | 怎么做 | 特点 |
|---|---|---|
| 阻塞 I/O | 一直等数据 | 简单,一次只能一个 |
| 非阻塞 I/O | 没数据立刻返回,自己轮询 | 不卡但 CPU 空转 |
| 多路复用 | select/poll/epoll 同时盯多个 fd | 一个进程管很多连接 |
| 异步 I/O | 发起后内核干完再通知 | 最彻底,编程复杂 |
一句话理解:阻塞是"死等",非阻塞是"反复敲门",多路复用是"请个前台帮你盯所有房间",异步是"事情办完我给你打电话"。
- 阻塞:
read没数据就睡着,简单但低效; - 非阻塞:
read没数据立刻返回,你自己反复去试——不卡住,但 CPU 空转、费劲; - 多路复用:把一堆 fd 交给内核,内核告诉你"哪些就绪了",你只处理就绪的——一个进程管很多连接;
- 异步:发起 I/O 后彻底不管,内核干完了通知你——最彻底,但编程最复杂。
这一课重点讲多路复用,因为它是现代高性能服务器的基石。
23.3 select:多路复用的起点
一句话理解:把一堆 fd 交给内核,内核告诉你"哪些可读/可写了",你再只处理就绪的。核心就三个词:FD_ZERO/FD_SET(设置集合)、select(等待)、FD_ISSET(判断谁就绪)。
select 是 Unix 里最早的多路复用接口,用法固定三步:
FD_ZERO+FD_SET:把你要监听的 fd,一个个放进一个"集合"里;select:把集合交给内核,阻塞等待,直到"有 fd 就绪"才返回;FD_ISSET:遍历检查,看具体是哪个 fd就绪了,然后处理它。
核心思想:一次等一堆,而不是一个一个死等。 这就是"雇前台"的实现。
缺点也很明显:
- fd 数量有上限(默认 1024);
- 每次调用都要重新拷贝整个 fd 集合进内核;
- 内核用线性扫描检查谁就绪——连接越多越慢。
23.4 poll / epoll:一步步变强
select 有天花板,于是有了更强的两代:
poll:
用数组代替 select 的位图,去掉数量上限。但本质上仍是线性扫描,连接多了照样慢。
epoll(Linux 的杀手锏):
epoll_ctl 注册一次,内核用红黑树 + 就绪链表管理,事件驱动,海量连接也快——Linux 高并发服务器的标配(Nginx、Redis 都用)。三者的演进,一句话概括:
select 是"每次都拿名单从头点名",epoll 是"谁举手就通知我"。
- select:每次重新传名单、从头扫一遍;
- poll:名单变成无限长,但还是要从头扫;
- epoll:登记一次,谁举手(就绪)直接通知,不用扫。
连接越多,epoll 的优势越碾压。
23.5 异步 I/O 与事件循环
最后,把这个思想上升到"异步"的高度:
一句话理解:Python 的 asyncio、Node.js 都是"事件循环 + 非阻塞"的异步模型,底层思想与 epoll 一脉相承——一个线程驱动成千上万个并发任务。你现在用的很多技术,本质都是同一套:
- Python 的
asyncio:一个事件循环(event loop),不断"收集就绪的事件 → 分发处理"; - Node.js:同样的事件循环模型;
- Redis / Nginx:底层就是 epoll。
一句话:"事件循环 + 非阻塞"是现代高并发的通用范式,epoll 是它在 Linux 上的底层引擎。理解它,你就理解了 asyncio、Node.js 为什么能"单线程扛万连"。
动手实验:C 语言 select 多客户端 echo
// lesson23.c —— 用 select 同时服务多个客户端
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(void) {
int srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
listen(srv, 5);
fd_set rfds;
int clients[10] = {0}; // 最多记录 10 个客户端
printf("select 服务器监听 8080……\n");
while (1) {
FD_ZERO(&rfds);
FD_SET(srv, &rfds);
int maxfd = srv;
for (int i = 0; i < 10; i++)
if (clients[i] > 0) { FD_SET(clients[i], &rfds); if (clients[i] > maxfd) maxfd = clients[i]; }
int ready = select(maxfd + 1, &rfds, NULL, NULL, NULL);
if (ready < 0) { perror("select"); return 1; }
if (FD_ISSET(srv, &rfds)) { // 新连接来了
int c = accept(srv, NULL, NULL);
for (int i = 0; i < 10; i++) if (clients[i] == 0) { clients[i] = c; break; }
printf("新客户端加入 fd=%d\n", c);
}
for (int i = 0; i < 10; i++) { // 处理就绪的客户端
int c = clients[i];
if (c > 0 && FD_ISSET(c, &rfds)) {
char buf[256];
int n = read(c, buf, sizeof(buf) - 1);
if (n <= 0) { close(c); clients[i] = 0; printf("客户端 fd=%d 离开\n", c); }
else { buf[n] = '\0'; write(c, buf, n); }
}
}
}
return 0;
}Python 高层对照版(同样的多客户端 echo,只用几行):
# lesson23.py —— selectors 多客户端 echo(对照)
import selectors, socket
sel = selectors.DefaultSelector() # 自动选 epoll/kqueue/select
def accept(sock, mask):
conn, _ = sock.accept()
sel.register(conn, selectors.EVENT_READ, echo)
def echo(conn, mask):
data = conn.recv(1024)
if data:
conn.send(data)
else:
sel.unregister(conn); conn.close()
srv = socket.socket()
srv.bind(("127.0.0.1", 8080)); srv.listen(5)
sel.register(srv, selectors.EVENT_READ, accept)
while True:
for key, mask in sel.select(): # 只处理就绪的
key.data(key.fileobj, mask)运行:
gcc lesson23.c -o lesson23 && ./lesson23 # 终端 1
# 另开多个终端,同时连过去发消息
python3 -c "import socket;[socket.create_connection(('127.0.0.1',8080)).sendall(b'hi\n') for _ in range(3)]"开多个客户端同时连,观察:服务器能同时回显所有人——这就是多路复用的效果。
对比上一课:这次没有一个客户端能"卡死"服务器了,因为 select 一次性盯着"监听 socket + 所有客户端 socket",谁就绪处理谁。深入点:两个进阶话题
① epoll 的两种触发
- 水平触发(LT):只要这个 fd 还有数据没读完,就一直通知你。默认模式,简单不易出错;
- 边缘触发(ET):只在状态变化时(比如"没数据→有数据")通知一次。性能更高,但要求你一次把数据读干净,否则可能漏掉。
一句话:LT 省心,ET 高效但要求高。 高性能服务器常配合非阻塞 I/O 用 ET。
② C10K 问题
一台服务器怎么扛 1 万并发连接?答案就是 epoll + 事件驱动 + 非阻塞。这是高并发服务器的起点。
"1 万连接"曾经是个难题——如果用"一连接一进程/线程",1 万个进程/线程的开销是天文数字。而一个线程 + epoll 就能轻松扛下。这就是 epoll 的价值所在。
小结与思考题
这一课,我们解决了"一个服务器只能服务一个人"的问题:
- 阻塞 I/O:一次专注一个连接,其余排队;
- 四种 I/O 模型:阻塞(死等)、非阻塞(反复试)、多路复用(前台盯)、异步(办完通知);
- select:一次等一堆,但有上限、要拷贝、线性扫描;
- poll/epoll:去掉上限 → 事件驱动,epoll 是海量连接标配;
- 事件循环:asyncio、Node.js 的底层思想,与 epoll 一脉相承。
留三个问题:
- 阻塞 I/O 和多路复用本质区别?
- select 相比 epoll 有哪些不足?
- Python 的
selectors/asyncio底层靠什么?
现在我们知道:一个线程 + epoll 就能扛万连。但问题是——真的所有场景都该用这一个线程吗?如果任务很重(比如 CPU 密集计算),单线程会不会成为瓶颈?多进程、多线程、事件驱动,到底怎么选?下一课,我们站在更高处,看高并发服务器的三种模型,以及 Reactor(反应器)这个经典设计模式。