一、poll 函数的使用

poll 这个东西,说白了就是 select 的升级版。我刚开始做网络编程时,用的都是 select,后来发现它有几个硬伤——比如最大文件描述符限制、每次都要重新设置集合。poll 把这些坑都填上了。

先看 poll 的函数原型:

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数解释:

  • fds:pollfd 结构体数组,你要监控的文件描述符都放这里
  • nfds:数组的大小,说白了就是你要监控多少个 fd
  • timeout:超时时间,单位毫秒。-1 表示阻塞等待,0 表示立即返回

核心是这个 pollfd 结构体:

struct pollfd {
    int   fd;         // 要监控的文件描述符
    short events;     // 你关心的事件类型
    short revents;    // 实际发生的事件类型(由内核填充)
};

使用套路很简单:填好 fd 和 events,调用 poll,然后检查 revents。我在项目中遇到过有人忘记初始化 revents,结果一直读到垃圾数据,排查了半天。

小技巧:每次调用 poll 之前,记得把 revents 清零。虽然内核会覆盖它,但养成好习惯总没错。

二、poll 的事件类型

poll 支持的事件类型比 select 丰富多了。常用的有这些:

事件宏 含义 使用场景
POLLIN 有数据可读 普通 socket 接收数据
POLLOUT 可以写入数据 发送缓冲区有空闲
POLLERR 发生错误 连接异常断开
POLLHUP 对方关闭连接 收到 FIN 包
POLLNVAL 文件描述符未打开 fd 已经被关闭

嗯,这里要注意:POLLIN 和 POLLHUP 有时候会同时返回。你想想看,对方发完数据就关闭连接,这时候你既要读数据,又要处理关闭。我曾经在这个坑里摔过——只判断了 POLLIN,没处理 POLLHUP,结果数据读完了还在傻等。

三、poll 与 select 的对比

我直接说结论:能用 poll 就别用 select。为什么?

  • 没有最大 fd 限制:select 默认 1024,poll 只受内存限制
  • 不需要重新设置集合:select 每次调用都要重新 FD_SET,poll 只需要改 events
  • 事件类型更丰富:select 只有读、写、异常三种,poll 有 POLLRDHUP、POLLPRI 等
  • 性能更好:select 每次都要遍历所有 fd,poll 也是遍历,但数据结构更高效

但 poll 也不是完美的。它和 select 一样,都是「水平触发」模式,而且当 fd 数量很大时,每次都要把整个数组从用户态拷贝到内核态,这个开销不小。

核心区别:select 用位图表示 fd,poll 用数组。位图有上限,数组没有。就这么简单。

四、epoll 的概念与原理

epoll 是 Linux 下最高效的 I/O 多路复用机制。它解决了 poll 的两个痛点:

  1. 避免重复拷贝:epoll 在内核中维护了一个事件表,不需要每次调用都传全部 fd
  2. 只返回就绪的 fd:poll 要遍历所有 fd 才能找到就绪的,epoll 直接告诉你哪些准备好了

epoll 的原理其实不复杂。它有三个关键操作:

  • epoll_create:创建内核事件表
  • epoll_ctl:往表里注册、修改、删除 fd
  • epoll_wait:等待事件发生

我打个比方:poll 就像你每次去图书馆都要把所有书翻一遍,看看哪些书被借走了。epoll 呢,它有个管理员,你告诉它你关心哪些书,书被借走时管理员会主动通知你。效率高下立判。

注意:epoll 是 Linux 特有的,不支持其他 Unix 系统。如果你的程序要跨平台,还是得用 poll 或 select。

五、epoll 的创建(epoll_create)

#include <sys/epoll.h>

int epoll_create(int size);

这个 size 参数在 Linux 2.6.8 之后其实被忽略了,但为了兼容性,建议传一个大于 0 的值。我习惯传 1024 或者 4096。

返回值是一个文件描述符,指向内核中的 epoll 实例。关闭它用 close() 就行。

现代用法:也可以用 epoll_create1(0),它比 epoll_create 更干净,还支持 EPOLL_CLOEXEC 标志。

六、epoll 的事件注册(epoll_ctl)

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op 参数有三个:

  • EPOLL_CTL_ADD:注册新的 fd
  • EPOLL_CTL_MOD:修改已注册的 fd 事件
  • EPOLL_CTL_DEL:删除已注册的 fd

epoll_event 结构体:

struct epoll_event {
    uint32_t     events;    // 事件类型
    epoll_data_t data;      // 用户数据
};

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

events 可以组合多个标志,比如 EPOLLIN | EPOLLET 表示边缘触发的可读事件。data 字段很灵活,我一般直接存 fd,方便后续处理。

我曾经犯过一个错误:在 EPOLL_CTL_DEL 之后,忘记把 fd 从自己的管理列表中移除,结果后面又重复添加,导致 epoll_ctl 返回 EEXIST。排查了好久才发现。

七、epoll 的事件等待(epoll_wait)

int epoll_wait(int epfd, struct epoll_event *events, 
               int maxevents, int timeout);

这个函数会阻塞等待事件发生。events 数组用来接收就绪的事件,maxevents 是数组大小。timeout 单位毫秒,-1 表示一直等。

返回值是就绪的事件数量。你只需要遍历 events 数组的前 n 个元素就行,不用像 poll 那样遍历全部。

性能关键:epoll_wait 只返回就绪的 fd,这是它比 poll 快的最根本原因。当你有 10 万个连接,但只有 10 个活跃时,epoll 只需要处理 10 个,poll 却要遍历 10 万个。

八、epoll 的工作模式(LT/ET)

这是 epoll 最核心的概念,也是面试必考题。

水平触发(LT)

这是默认模式。只要 fd 上有数据可读,epoll_wait 就会一直返回它。你想想看,如果你只读了部分数据,下次调用 epoll_wait 还会通知你。这种模式不容易丢事件,但效率稍低。

边缘触发(ET)

只有状态发生变化时才通知。比如缓冲区从空变成有数据,或者从满变成有空闲。如果你没读完数据,下次不会再通知你,除非又有新数据到来。

ET 模式效率更高,但使用起来要小心:

  • 必须用非阻塞 I/O,否则读数据时可能阻塞
  • 必须循环读取直到返回 EAGAIN,否则会漏数据

我在项目中遇到过有人用 ET 模式但没循环读,结果只收到一次通知,后面数据全丢了。嗯,这个坑踩过的人不少。

特性 LT(水平触发) ET(边缘触发)
通知次数 只要条件满足就通知 状态变化时通知一次
使用难度 简单,不容易出错 复杂,需要循环处理
性能 一般
推荐场景 连接数少,逻辑简单 高并发,连接数多

九、epoll 综合案例

下面是一个简单的 TCP 服务器,用 epoll 的 ET 模式处理多个客户端连接:

#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 1024
#define PORT 8888

int set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        exit(1);
    }

    if (listen(listen_fd, 128) < 0) {
        perror("listen");
        exit(1);
    }

    // 创建 epoll 实例
    int epfd = epoll_create1(0);
    if (epfd < 0) {
        perror("epoll_create");
        exit(1);
    }

    // 注册监听 fd,使用 ET 模式
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    // 设置非阻塞
    set_nonblock(listen_fd);

    struct epoll_event events[MAX_EVENTS];
    char buffer[1024];

    printf("Server started on port %d\n", PORT);

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 处理新连接
                while (1) {
                    struct sockaddr_in client_addr;
                    socklen_t client_len = sizeof(client_addr);
                    int client_fd = accept(listen_fd, 
                        (struct sockaddr*)&client_addr, &client_len);
                    
                    if (client_fd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 没有更多连接了
                        }
                        perror("accept");
                        break;
                    }

                    // 设置非阻塞并注册到 epoll
                    set_nonblock(client_fd);
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = client_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
                }
            } else {
                // 处理客户端数据
                int client_fd = events[i].data.fd;
                while (1) {
                    ssize_t n = read(client_fd, buffer, sizeof(buffer) - 1);
                    if (n < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 数据读完了
                        }
                        // 出错,关闭连接
                        close(client_fd);
                        break;
                    } else if (n == 0) {
                        // 对方关闭连接
                        close(client_fd);
                        break;
                    } else {
                        buffer[n] = '\0';
                        printf("Received: %s", buffer);
                        // 回显数据
                        write(client_fd, buffer, n);
                    }
                }
            }
        }
    }

    close(epfd);
    close(listen_fd);
    return 0;
}

这个例子展示了 ET 模式的核心用法:

  • 所有 fd 都设置非阻塞
  • accept 和 read 都用 while 循环,直到返回 EAGAIN
  • 监听 fd 也用 ET 模式,避免惊群效应
重要提醒:ET 模式下,accept 也要循环处理。否则如果有多个连接同时到达,你只 accept 了一个,剩下的就丢了。我刚开始写 epoll 时就犯过这个错。

知识体系总览

下面这张图把 poll 和 epoll 的核心知识点串起来了:

I/O 多路复用:poll 与 epoll 知识体系 poll • 函数原型:poll(pollfd[], nfds, timeout) • 事件类型:POLLIN / POLLOUT / POLLERR • 与 select 对比:无 1024 限制 • 缺点:每次遍历全部 fd epoll • epoll_create:创建内核事件表 • epoll_ctl:注册/修改/删除事件 • epoll_wait:等待就绪事件 • 只返回就绪 fd,效率更高 epoll 工作模式 LT(水平触发):条件满足就通知 ET(边缘触发):状态变化才通知 核心原则:连接数少用 poll,高并发用 epoll;简单场景用 LT,追求性能用 ET

好了,poll 和 epoll 的核心内容就这些。记住一句话:poll 是 select 的改良版,epoll 是 poll 的终极版。实际项目中,我建议你直接上 epoll,除非你有跨平台需求。