一、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 的事件类型
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 数量很大时,每次都要把整个数组从用户态拷贝到内核态,这个开销不小。
四、epoll 的概念与原理
epoll 是 Linux 下最高效的 I/O 多路复用机制。它解决了 poll 的两个痛点:
- 避免重复拷贝:epoll 在内核中维护了一个事件表,不需要每次调用都传全部 fd
- 只返回就绪的 fd:poll 要遍历所有 fd 才能找到就绪的,epoll 直接告诉你哪些准备好了
epoll 的原理其实不复杂。它有三个关键操作:
- epoll_create:创建内核事件表
- epoll_ctl:往表里注册、修改、删除 fd
- epoll_wait:等待事件发生
我打个比方:poll 就像你每次去图书馆都要把所有书翻一遍,看看哪些书被借走了。epoll 呢,它有个管理员,你告诉它你关心哪些书,书被借走时管理员会主动通知你。效率高下立判。
五、epoll 的创建(epoll_create)
#include <sys/epoll.h>
int epoll_create(int size);
这个 size 参数在 Linux 2.6.8 之后其实被忽略了,但为了兼容性,建议传一个大于 0 的值。我习惯传 1024 或者 4096。
返回值是一个文件描述符,指向内核中的 epoll 实例。关闭它用 close() 就行。
六、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 的工作模式(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 模式,避免惊群效应
知识体系总览
下面这张图把 poll 和 epoll 的核心知识点串起来了:
好了,poll 和 epoll 的核心内容就这些。记住一句话:poll 是 select 的改良版,epoll 是 poll 的终极版。实际项目中,我建议你直接上 epoll,除非你有跨平台需求。