2. 网络I/O模型精讲
说到高并发网络编程,I/O多路复用是个绕不开的话题。我记得刚入行那会儿,公司有个网关服务,连接数一上万就卡得不行。当时我翻来覆去调参数,最后发现瓶颈不在业务逻辑,就在I/O模型上。嗯,今天我们就来聊聊select、poll和epoll这些事儿。
从阻塞I/O说起
先想想最原始的方式。你写一个socket服务端,每来一个连接就fork一个进程或者创建一个线程去处理。这种方式在连接数少的时候还行,连接一多,线程切换的开销就能把CPU吃光。说白了,这就是「一个连接一个线程」的模型,资源消耗太大了。
那有没有办法让一个线程同时监控多个socket?有,这就是I/O多路复用。它允许我们用一个线程去检查多个socket的状态,哪个有数据可读、哪个可以写,我们就去处理哪个。
select:老前辈的局限
select是最早出现在Unix上的多路复用函数。它的接口长这样:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
你传三个集合进去:想监控可读的fd、想监控可写的fd、想监控异常的fd。然后select会阻塞,直到有事件发生或者超时。
听起来不错对吧?但我在项目中踩过它的坑,问题不少:
- fd数量限制:fd_set的大小是固定的,默认1024。你想想看,现在随便一个服务连接数就上万,1024哪够用?虽然可以改内核宏重新编译,但太麻烦了。
- 每次都要重新传入集合:select会修改你传进去的fd_set,所以每次调用前你都得重新构造一遍。这在大规模fd的场景下,拷贝开销就很可观了。
- O(n)的轮询:内核不知道哪些fd就绪了,它得把整个集合遍历一遍。连接数越多,这个遍历就越慢。
poll:改进有限
poll的出现解决了select的1024限制。它用链表来管理fd,不再有上限:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 关注的事件
short revents; // 实际发生的事件
};
你看,poll把fd和事件封装成一个结构体数组,每次调用不用重新构造了。但它的核心问题没变——仍然是O(n)的轮询。内核还是得遍历所有fd才能知道哪些就绪了。
| 对比项 | select | poll |
|---|---|---|
| 最大fd数 | 1024(默认) | 无限制 |
| 数据结构 | 位图(fd_set) | 链表(pollfd数组) |
| 事件传递方式 | 值-结果参数 | 独立字段(events/revents) |
| 时间复杂度 | O(n) | O(n) |
| 跨平台性 | 几乎所有Unix | 大部分Unix,Windows不支持 |
说白了,poll只是把select的1024限制去掉了,但O(n)的轮询问题还在。连接数一上万,每次调用poll都要遍历几万个fd,CPU就全耗在轮询上了。
epoll的诞生
epoll是Linux 2.6内核引入的,专为解决select/poll的性能问题而生。它的设计思路完全不同——不是每次调用都去轮询所有fd,而是让内核主动通知你哪些fd就绪了。
epoll有三个核心函数:
int epoll_create(int size); // 创建一个epoll实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件
我个人习惯把epoll的工作流程总结成三步:
- 创建:epoll_create创建一个epoll实例,内核会分配一块空间来存储你注册的fd。
- 注册:epoll_ctl把你要监控的fd加进去,可以指定是读事件、写事件还是边缘触发等。
- 等待:epoll_wait阻塞等待,有事件发生时,内核直接把就绪的fd列表拷贝到用户空间。
事件驱动模型详解
epoll之所以高效,底层依赖的是事件驱动机制。它有两种触发模式:
- 水平触发(LT):只要fd还有数据可读,epoll_wait就会一直返回它。这是默认模式,和select/poll的行为一致。
- 边缘触发(ET):只有当fd的状态发生变化时(比如从不可读变成可读),epoll_wait才返回一次。之后即使还有数据没读完,也不会再通知你。
我建议你在高并发场景下使用边缘触发。为什么?因为水平触发每次都会返回所有可读的fd,如果数据没读完,下次epoll_wait还会返回它,这就造成了重复通知。而边缘触发只通知一次,你必须一次性把数据读完。
下面这张图展示了epoll的事件驱动流程:
epoll的核心优势总结
对比select/poll,epoll的优势很明显:
- O(1)的事件通知:只返回就绪的fd,不遍历所有fd。
- 无上限:fd数量只受系统内存限制,轻松支持数十万连接。
- 内存映射:内核和用户空间共享一块内存,减少数据拷贝。
- 回调机制:每个fd注册时绑定回调函数,事件发生时直接触发,不需要轮询。