3、网络编程基础(二):I/O多路复用——select、poll、epoll原理与对比
好,咱们接着聊网络编程。上一章我们讲了阻塞与非阻塞I/O,有同学可能会问:如果我要同时处理成千上万个客户端连接,总不能给每个连接都开一个线程吧?线程切换的开销你想想看,那得多大。
这时候就需要I/O多路复用登场了。说白了,就是一个线程能同时监控多个文件描述符(也就是socket),哪个准备好了就去处理哪个。这就像你一个人盯着几十个水壶,哪个烧开了就去关哪个火,而不是每个水壶配一个人守着。
3.1 为什么需要I/O多路复用?
先说说没有多路复用的时候我们怎么搞。最原始的做法是:每个连接来一个线程,或者用非阻塞I/O轮询。但问题是——
- 多线程模型:连接数一多,线程数就爆炸。线程切换、锁竞争、内存开销,分分钟把服务器搞垮。我记得早年有个项目,上线后连接数冲到5000,服务器直接卡死,最后发现光线程栈就占了几个G的内存。
- 非阻塞轮询:用一个循环不断检查所有socket,每次都要从用户态切到内核态。如果大部分socket都没数据,那CPU全浪费在无意义的系统调用上了。
所以我们需要一种机制:让内核帮我们盯着这些socket,有事件了再通知我们。这就是I/O多路复用的核心思想。
3.2 select——老当益壮,但力不从心
select是最早出现的多路复用函数,很多老代码里还能看到它的身影。它的原型长这样:
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
用法其实不复杂:你把要监控的socket放到一个叫fd_set的集合里,然后调用select。内核会阻塞直到有事件发生,或者超时。返回后你再遍历所有socket,检查哪些还在集合里。
但select有几个硬伤,我在项目中踩过不少坑:
- 最大文件描述符限制:
fd_set默认大小是1024,也就是说你最多只能监控1024个socket。虽然可以改内核参数,但总归是个硬上限。 - 每次都要重新传入集合:select会修改你传入的
fd_set,所以每次调用前都得重新把所有socket加进去。这在大连接数下是个不小的开销。 - 线性遍历:返回后你得遍历整个集合才能知道哪些socket有事件。连接数一多,这个O(n)的遍历就很要命。
- 内核态与用户态的数据拷贝:每次调用select,都要把整个
fd_set从用户态拷贝到内核态,事件就绪后再拷回来。这来回折腾,效率能高才怪。
fd_set溢出了,但代码里没做任何检查。嗯,从那以后我写多路复用代码,第一件事就是加个断言检查最大连接数。
3.3 poll——改进有限,本质未变
poll的出现,主要是为了解决select的1024限制。它用链表来管理文件描述符,理论上没有上限。原型如下:
int poll(struct pollfd *fds,
nfds_t nfds,
int timeout);
每个pollfd结构体包含fd、要监听的事件、以及返回时实际发生的事件。比起select,poll有几个改进:
- 没有最大连接数限制:链表结构,想加多少加多少。
- 事件分离:输入事件和输出事件分开了,不用像select那样每次重新构造集合。
- 接口更清晰:不用再搞三个独立的集合了,一个数组搞定。
但说实话,poll的底层实现和select差不多——依然是线性遍历,依然是内核态用户态数据拷贝。连接数少的时候还好,一旦上了万,性能就直线下降。
我个人习惯是:如果连接数在几千以内,用poll其实够用。但如果你要搞高并发服务器,那还是得看epoll。
3.4 epoll——Linux下的王者
epoll是Linux特有的多路复用机制,也是目前高性能网络服务器的标配。它解决了select和poll的所有痛点。
epoll有三个核心函数:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
用法也很清晰:
epoll_create创建一个epoll实例,返回一个文件描述符。epoll_ctl往这个实例里添加、修改或删除要监控的socket。epoll_wait等待事件发生,返回就绪的事件列表。
epoll为什么快?三个关键点:
- 事件驱动,不是轮询:内核只在socket有事件时才回调通知,而不是每次都要遍历所有socket。这就像你给每个水壶装了个铃铛,水开了它会响,而不是你每隔几秒就去检查一遍。
- 返回就绪列表,不是全量集合:
epoll_wait只返回有事件发生的socket,数量通常远小于总连接数。你直接处理这些就绪的socket就行,不用遍历全部。 - mmap减少拷贝:epoll通过内存映射(mmap)在内核和用户空间共享一块内存,避免了数据拷贝的开销。
epoll_wait的开销基本恒定。而select和poll是O(n),连接数越多越慢。
3.5 三种机制的对比
咱们用一张表来总结一下:
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(可改) | 无上限 | 无上限 |
| 事件通知方式 | 轮询遍历 | 轮询遍历 | 事件回调 |
| 就绪事件获取 | 遍历所有fd | 遍历所有fd | 直接返回就绪列表 |
| 数据拷贝 | 每次全量拷贝 | 每次全量拷贝 | mmap共享内存 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 跨平台性 | 几乎所有平台 | 几乎所有平台 | 仅Linux |
| 适用场景 | 小规模连接 | 中等规模连接 | 大规模高并发 |
3.6 实战中的选择建议
说了这么多,到底该怎么选?我个人的经验是:
- 如果你在写跨平台代码(比如Windows和Linux都要跑),那只能用select或poll。Windows虽然有IOCP,但那是另一套东西了。
- 如果连接数不超过几百,select完全够用。别为了用epoll而用epoll,代码复杂度上去了,收益却不大。
- 如果连接数在几千到几万,果断上epoll。这是它的主场。
- 如果连接数超过十万,epoll依然是首选,但要注意配合事件驱动模型(比如Reactor模式),否则单线程处理不过来。
3.7 一个简单的epoll示例
最后给个代码片段,让大家感受一下epoll的实际用法:
// 创建epoll实例
int epfd = epoll_create(1024);
// 添加一个socket到epoll
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
// 事件循环
struct epoll_event events[1024];
while (1) {
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 有新连接
int conn_fd = accept(listen_fd, ...);
// 把新连接也加入epoll
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
} else {
// 处理已连接socket的数据
handle_client(events[i].data.fd);
}
}
}
注意这里用了EPOLLET(边缘触发模式),这是epoll的一个高级特性。默认是水平触发(LT),和select/poll的行为一样。边缘触发的话,只有状态变化时才通知一次,需要你把数据一次性读完。这个后面讲Reactor模式时会详细说。
好了,这一章就到这里。下一章我们聊聊事件驱动模型,看看怎么把epoll用得更优雅。