39、非阻塞IO与多路复用:select/poll/epoll与标准库的配合

说实话,很多C语言开发者写了好几年程序,对文件IO的理解还停留在“打开-读写-关闭”这个层面。单线程处理一个连接没问题,一旦要同时管几百个socket,立马就抓瞎了。我当年第一次做高并发服务器时,就栽在这个坑里——用阻塞IO开了几百个线程,结果系统直接崩了。

今天咱们聊聊非阻塞IO和多路复用。说白了,就是让一个线程能同时盯着成百上千个文件描述符,哪个有数据来了就去处理哪个。这背后的功臣就是select、poll和epoll这三个系统调用。

阻塞IO vs 非阻塞IO

先看个最简单的例子。你用read()读一个socket,如果没数据,线程就卡在那了。这叫阻塞IO。

// 阻塞IO示例
int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, ...);
char buf[1024];
int n = read(fd, buf, sizeof(buf)); // 没数据就卡死在这里

非阻塞IO呢?设置O_NONBLOCK标志后,read()会立即返回。没数据就返回-1,errno设为EAGAINEWOULDBLOCK

// 非阻塞IO示例
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
// 或者用fcntl设置
// fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);

char buf[1024];
int n = read(fd, buf, sizeof(buf));
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    // 没数据,干点别的
    printf("暂时没数据,等会儿再来\n");
}

但问题来了——你总不能一直轮询吧?CPU受不了啊。这时候就需要多路复用登场了。

select:老当益壮但力不从心

select是最早的多路复用方案。它让你同时监控多个文件描述符,哪个就绪了就去处理哪个。

#include <sys/select.h>

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);

struct timeval tv = {5, 0}; // 5秒超时
int ret = select(fd2 + 1, &readfds, NULL, NULL, &tv);

if (ret == -1) {
    perror("select");
} else if (ret == 0) {
    printf("超时了\n");
} else {
    if (FD_ISSET(fd1, &readfds)) {
        // fd1可读
    }
    if (FD_ISSET(fd2, &readfds)) {
        // fd2可读
    }
}
注意:select有三个硬伤。第一,监控的文件描述符数量有限制,默认1024个。第二,每次调用都要把整个fd_set从用户态拷贝到内核态,效率低。第三,返回后你得遍历所有fd才能知道哪些就绪了——O(n)的复杂度。

我记得以前维护一个老项目,用的就是select。连接数一上千,CPU就飙到100%。后来一查,光遍历fd_set就占了80%的开销。

poll:改进有限

poll解决了select的1024限制,但其他问题还在。

#include <poll.h>

struct pollfd fds[2];
fds[0].fd = fd1;
fds[0].events = POLLIN;
fds[1].fd = fd2;
fds[1].events = POLLIN;

int ret = poll(fds, 2, 5000); // 5秒超时

if (ret > 0) {
    for (int i = 0; i < 2; i++) {
        if (fds[i].revents & POLLIN) {
            // fds[i].fd可读
        }
    }
}

poll用pollfd数组代替了fd_set,数量不再受限。但每次调用还是要全量拷贝数组,返回后还是要遍历——O(n)的问题没解决。

epoll:Linux下的王者

epoll是Linux 2.6引入的,专为高并发设计。它有三个核心函数:epoll_createepoll_ctlepoll_wait

#include <sys/epoll.h>

// 创建epoll实例
int epfd = epoll_create1(0);

// 添加监控
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = fd1;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);

// 等待事件
struct epoll_event events[128];
int nfds = epoll_wait(epfd, events, 128, 5000);

for (int i = 0; i < nfds; i++) {
    if (events[i].events & EPOLLIN) {
        int fd = events[i].data.fd;
        // 处理fd
    }
}
epoll的优势:
  • 只返回就绪的fd,不用遍历全部——O(1)复杂度
  • 用红黑树管理监控列表,增删改都是O(log n)
  • 支持边缘触发(ET)和水平触发(LT)两种模式
  • mmap共享内存,减少数据拷贝

我做过一个测试:10万个连接,select需要遍历10万次才能找到就绪的那几个;epoll直接返回就绪列表,可能就几十个。差距是三个数量级。

与标准库的配合

标准C库的freadfwrite等函数默认是阻塞的。想用多路复用,得用原始的系统调用。

但有个技巧:你可以用fdopen把文件描述符包装成FILE*,然后配合fileno获取底层fd。

// 把socket包装成FILE*
int sock = socket(AF_INET, SOCK_STREAM, 0);
FILE *fp = fdopen(sock, "r+");

// 用epoll监控底层fd
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;  // 注意:用fileno(fp)也可以
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);

// 等epoll通知后,再用标准库函数读
struct epoll_event events[1];
epoll_wait(epfd, events, 1, -1);

char buf[1024];
fread(buf, 1, sizeof(buf), fp);  // 这时肯定有数据,不会阻塞
经验之谈:我个人不建议混用标准库和原始系统调用。标准库内部有缓冲区,容易出问题。真要混用,记得用setbuf(fp, NULL)关掉缓冲。

实战:一个简单的echo服务器

下面是一个用epoll实现的echo服务器骨架。我当年做IM项目时,核心逻辑跟这个差不多。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>

#define MAX_EVENTS 128
#define PORT 8888

int main() {
    // 创建监听socket
    int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 128);

    // 创建epoll
    int epfd = epoll_create1(0);
    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[MAX_EVENTS];
    char buf[1024];

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 新连接
                int conn_fd = accept(listen_fd, NULL, NULL);
                fcntl(conn_fd, F_SETFL, O_NONBLOCK);
                ev.events = EPOLLIN | EPOLLET;  // 边缘触发
                ev.data.fd = conn_fd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
            } else {
                // 可读事件
                int fd = events[i].data.fd;
                int n = read(fd, buf, sizeof(buf));
                if (n <= 0) {
                    close(fd);
                } else {
                    write(fd, buf, n);  // echo回去
                }
            }
        }
    }
    return 0;
}
边缘触发(ET)的坑:用ET模式时,必须一次性把数据读完,否则会漏数据。我建议循环读直到返回EAGAIN。水平触发(LT)就没这个问题,但效率稍低。

如何选择?

方案适用场景最大连接数复杂度
select少量连接(几十个)1024O(n)
poll中等连接(几百个)无限制O(n)
epoll高并发(成千上万)无限制O(1)

我的建议是:新项目直接上epoll。别在select和poll上浪费时间了。除非你要兼容老系统——我曾经在AIX上写过代码,那玩意儿只支持poll,epoll想都别想。

知识结构图

非阻塞IO与多路复用知识体系 多路复用 select poll epoll • 最大1024个fd • 全量拷贝fd_set • O(n)遍历 • 无数量限制 • 全量拷贝数组 • O(n)遍历 • 无数量限制 • 只返回就绪fd • O(1)复杂度 新项目推荐:epoll + 非阻塞IO + 边缘触发 兼容老系统时考虑poll,select尽量别用了

嗯,差不多就这些。非阻塞IO和多路复用是高性能网络编程的基石。你想想看,Redis、Nginx、Node.js,底层全是这套东西。搞懂了它们,你才算真正入了高并发的门。


公众号:蓝海资料掘金营,微信deep3321