2. 操作系统I/O基础
要理解异步网络编程,必须先回到操作系统这一层,弄清楚一次网络读写到底发生了什么。很多“为什么 epoll 比 select 快”“为什么非阻塞还要配合多路复用”的疑问,答案都藏在文件描述符、用户态/内核态以及几种 I/O 模型的机制差异里。
文件描述符:一切 I/O 对象的统一句柄
在类 Unix 系统中,进程打开文件、创建套接字、建立管道,内核都会返回一个非负整数,这就是文件描述符(File Descriptor,fd)。它本质上是进程级“打开文件表”的索引:fd 只是编号,真正的文件状态、读写偏移、引用计数保存在内核的打开文件表项中。标准输入、标准输出、标准错误默认占用 0、1、2,因此第一个 socket 通常从 3 开始。
fd 的关键特性是有限且进程隔离。每个进程有独立的 fd 空间,默认上限由 ulimit -n 决定,常见为 1024。高并发服务动辄需要数万连接,因此调大该限制是第一步。Windows 没有 fd 概念,对应的是 句柄(HANDLE)和 SOCKET,语义类似但接口不同。
用户态与内核态:一次 read 的两次拷贝
进程运行在用户态,无法直接访问网卡、磁盘等硬件,必须通过系统调用陷入内核态。以 read(fd, buf, n) 为例,数据流是:网卡收到数据 → 内核协议栈处理并放入内核 socket 接收缓冲区 → 系统调用把数据从内核缓冲区拷贝到用户缓冲区 → 返回。这里存在两次数据搬运(网卡到内核、内核到用户)和一次态切换,这正是 I/O 开销的主要来源,也是零拷贝技术要优化的对象。
五种 I/O 模型的机制对比
按“等待数据就绪”和“拷贝数据”两个阶段谁来负责,可划分出五种模型:
- 阻塞 I/O:调用 read 后线程睡眠,直到数据就绪并拷贝完成才返回。一个线程只能管一个连接。
- 非阻塞 I/O:fd 设为 O_NONBLOCK,read 立即返回,未就绪时返回 EAGAIN。需要应用不断轮询,空转浪费 CPU。
- I/O 多路复用:用 select/poll/epoll 一次等待多个 fd,就绪后再逐个 read。这是高并发服务的主流方案。
- 信号驱动 I/O:注册 SIGIO,内核就绪时发信号通知,实际使用较少。
- 异步 I/O:如 Linux AIO、Windows IOCP,提交请求后内核完成“等待+拷贝”全过程再通知,应用完全不阻塞。
前四种在“拷贝数据”阶段仍是同步的,只有异步 I/O 是真正的全异步。这也是 IOCP 与 epoll 的本质区别:epoll 是就绪通知,IOCP 是完成通知。
select / poll / epoll 的机制差异
select 使用位图(fd_set)表示关注的 fd,调用时把整个集合从用户态拷贝到内核,内核线性扫描所有 fd 检查就绪状态,返回后应用还要再遍历一遍。它有三个硬伤:fd 数量受 FD_SETSIZE(通常 1024)限制、每次调用全量拷贝、O(n) 扫描。但它跨平台性最好,Windows 也支持。
poll 用 struct pollfd 数组替代位图,去掉了 1024 限制,事件与 fd 分离,语义更清晰。但每次调用仍需把整个数组拷入内核并线性扫描,本质复杂度没变。
epoll 做了三件事改变局面:一是 epoll_create 在内核建立红黑树保存所有被监控的 fd,epoll_ctl 增删改只操作一次,无需每次重复传入;二是内核为就绪 fd 维护一个就绪链表,网卡中断触发回调时把 fd 挂入链表;三是 epoll_wait 直接返回就绪链表,复杂度 O(1),且只返回真正就绪的 fd。它支持水平触发(LT,默认,只要缓冲区有数据就持续通知)和边缘触发(ET,仅在状态变化时通知一次,必须一次读到 EAGAIN)。
BSD/macOS 的 kqueue 与 epoll 思路一致,用 kevent 统一处理文件、socket、信号、定时器。Windows 的 IOCP 则是完成端口模型:应用提交异步读写请求,内核完成后把结果放入完成队列,工作线程取结果处理,天然支持真正的异步 I/O 和线程池扩展。
实践中的选择与参数
Linux 高并发服务首选 epoll,配合 ET 模式减少通知次数,但必须循环读到 EAGAIN,否则会丢事件。示例:一个监听 8080 的服务,用 epoll_create1(0) 创建实例,epoll_ctl(EPOLL_CTL_ADD) 注册监听 fd 关注 EPOLLIN,epoll_wait(epfd, events, 1024, -1) 阻塞等待。返回的 events 数组只含就绪项,遍历处理即可。
int epfd = epoll_create1(0);
struct epoll_event ev, events[1024];
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) accept_conn();
else handle_read(events[i].data.fd); // ET 下需读到 EAGAIN
}
}
跨平台库如 libuv、Boost.Asio 会在底层自动选择 epoll/kqueue/IOCP,业务代码无需关心。但理解这些差异,才能在排查“连接数上不去”“CPU 空转”“事件丢失”时快速定位到是 fd 上限、LT/ET 模式还是拷贝开销的问题。