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从用户态拷贝到内核态,事件就绪后再拷回来。这来回折腾,效率能高才怪。
避坑指南:我曾经在一个旧项目里接手过select写的网络层,连接数刚过800就开始丢包。排查了半天,发现是select的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);

用法也很清晰:

  1. epoll_create创建一个epoll实例,返回一个文件描述符。
  2. epoll_ctl往这个实例里添加、修改或删除要监控的socket。
  3. epoll_wait等待事件发生,返回就绪的事件列表。

epoll为什么快?三个关键点:

  • 事件驱动,不是轮询:内核只在socket有事件时才回调通知,而不是每次都要遍历所有socket。这就像你给每个水壶装了个铃铛,水开了它会响,而不是你每隔几秒就去检查一遍。
  • 返回就绪列表,不是全量集合epoll_wait只返回有事件发生的socket,数量通常远小于总连接数。你直接处理这些就绪的socket就行,不用遍历全部。
  • mmap减少拷贝:epoll通过内存映射(mmap)在内核和用户空间共享一块内存,避免了数据拷贝的开销。
核心要点:epoll的复杂度是O(1)的——无论你有1万个连接还是10万个连接,每次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模式),否则单线程处理不过来。
小技巧:我建议初学者先从select入手理解多路复用的概念,然后再切换到epoll。因为select的接口更直观,容易理解「多路复用」到底在干什么。等搞明白了,再去看epoll的细节,会发现豁然开朗。

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用得更优雅。