目录
epoll 实例:借助 epoll_create() 函数能够创建一个 epoll 实例,它其实是内核里的一个数据结构,其作用是存储待监控的文件描述符以及相关事件。
事件等待:通过 epoll_wait() 函数,进程会被阻塞,直到有注册的事件发生。此时,该函数会返回一个包含就绪文件描述符的列表。
select
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数
-
nfds
(需要检查的最大文件描述符值 + 1)- 作用:指定需要检查的文件描述符范围,通常是所有监控的文件描述符中的最大值加 1。
- 示例:如果监控的文件描述符为 3、4、7,则
nfds
应为7 + 1 = 8
。
-
readfds
(读文件描述符集合)- 作用:传入需要监控可读事件的文件描述符集合。
- 返回时:集合中包含变为可读的文件描述符。
-
writefds
(写文件描述符集合)- 作用:传入需要监控可写事件的文件描述符集合。
- 返回时:集合中包含变为可写的文件描述符。
-
exceptfds
(异常文件描述符集合)- 作用:传入需要监控异常事件的文件描述符集合。
- 返回时:集合中包含发生异常的文件描述符。
-
timeout
(超时时间)- 作用:指定
select()
函数的阻塞时间。 - 三种情况:
NULL
:永久阻塞,直到有文件描述符就绪。0
:非阻塞模式,立即返回(轮询)。>0
:指定等待的最长时间(秒和微秒)。
- 作用:指定
使用
int nready=select(maxfd,rset,wset,eset,timeout);
fd_set rfds,rset;
FD_ZERO(&rfds);
FD_SET(sockfd,&rfds);
int maxfd = sockfd;
while(1){
rset = rfds;
int nready=select(maxfd+1,&rset,NULL,NULL, NULL);
if(FD_ISSET(sockfd,&rset))
{
struct sockaddr_in clientaddr;
socklen_tlen=sizeof(clientaddr);
int clientfd = accept(sockfd,
(struct sockaddr*)&clientaddr,&len);
printf("sockfd:%d\n",clientfd);
FD_SET(clientfd, &rfds);
maxfd = clientfd;
}
int i =0;
for(i=sockfd+l;i<= maxfd;i ++){
if(FD ISSET(i,&rset)){
char buffer[128]={0};
int countrecv(i,buffer,128,0);
if(count == 0){
FD CLR(i,&rfds);
close(i);
break;
}
send(i,buffer,count,0);
}
}
select 的局限性
- 参数较多,且每次调用前需重新初始化
- 存在内存拷贝开销,由于 select() 的文件描述符集合(fd_set)采用值传递机制,每次调用都需要在用户空间和内核空间之间进行数据拷贝
- 支持的文件描述符数量有限
- 采用线性扫描机制,效率较低
poll
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd { int fd; // 文件描述符 short events; // 关注的事件(输入掩码) short revents; // 返回的就绪事件(输出掩码) };
- 常见事件类型(通过
events
设置):POLLIN
:数据可读(包括连接关闭、半关闭)。POLLOUT
:数据可写。POLLERR
:发生错误(自动关注,无需手动设置)。POLLHUP
:发生挂起(如连接断开)。
参数
(1)struct pollfd *fds
-
- 描述符数组:每个元素表示一个要监控的文件描述符及其关注的事件。
(2)nfds_t nfds
- 监控的描述符数量:即
fds
数组的有效长度。
(3)int timeout
- 超时时间(毫秒):
-1
:永久阻塞,直到有事件发生。0
:立即返回(非阻塞)。>0
:等待指定毫秒数后返回。
返回值
- 正数:就绪的描述符总数(即
revents
非零的描述符数量)。 - 0:超时且无事件发生。
- -1:出错(设置
errno
)。
使用
// 初始化 pollfd 数组
struct pollfd fds = {0};
int nfds = 1; // 初始只有监听套接字
fds[0].fd = listen_fd;
fds[0].events = POLLIN; // 监听可读事件(新连接到来)
while (1) {
// 调用 poll 等待事件
int nready = poll(fds, nfds, -1);
if (nready == -1) {
perror("poll");
break;
}
// 处理监听套接字上的新连接
if (fds[0].revents & POLLIN) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (conn_fd == -1) {
perror("accept");
continue;
}
printf("New connection from %s:%d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 将新连接添加到 pollfd 数组
if (nfds < MAX_EVENTS) {
fds[nfds].fd = conn_fd;
fds[nfds].events = POLLIN; // 监控新连接的可读事件
nfds++;
} else {
printf("Too many connections, max is %d\n", MAX_EVENTS);
close(conn_fd); // 超出最大连接数,关闭新连接
}
}
// 处理其他已连接套接字的可读事件
for (int i = 1; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
char buf[1024];
int n = read(fds[i].fd, buf, sizeof(buf));
if (n <= 0) {
// 客户端关闭连接或发生错误
printf("Connection closed on fd %d\n", fds[i].fd);
close(fds[i].fd);
// 移除 pollfd 数组中的元素(将最后一个元素移到当前位置)
fds[i] = fds[nfds - 1];
nfds--;
i--; // 重新检查当前位置的元素
} else {
// 处理接收到的数据
printf("Received %d bytes on fd %d\n", n, fds[i].fd);
}
}
}
}
free(fds);
对比 select
的差异
select()
:使用fd_set
位图,需手动维护最大描述符值(maxfd
),且每次调用前需重置整个集合。poll()
:使用数组,直接添加 / 删除元素,无需重置,更直观灵活。
poll的局限性
1. 线性扫描开销大
- 问题:每次调用
poll()
返回后,需要遍历所有监控的文件描述符(数组中的nfds
个元素),逐个检查revents
字段是否就绪。 - 时间复杂度:O (n),其中 n 是监控的描述符数量。当连接数很大(如数千或数万)时,遍历操作会成为性能瓶颈。
- 对比:
epoll()
使用事件通知机制(回调),仅关注就绪的描述符,时间复杂度为 O (1)。
2. 内核与用户空间的数据拷贝
- 问题:
poll()
的struct pollfd
数组是用户空间和内核空间之间来回传递的。每次调用poll()
时,内核需要将整个数组从用户空间拷贝到内核空间,返回时再将就绪状态从内核空间拷贝回用户空间。 - 影响:频繁调用
poll()
时,内存拷贝开销显著,尤其是当监控的描述符数量较多时。 - 对比:
epoll()
通过epoll_ctl()
预先在内核中注册描述符,后续只需传递少量事件信息,减少了拷贝开销。
3. 内核实现效率低
- 问题:
poll()
在内核中的实现通常使用轮询(polling)方式,即内核会不断检查所有注册的描述符状态,直到有事件发生或超时。 - 对比:
epoll()
使用事件驱动(event-driven)机制,通过回调函数直接将就绪事件通知给应用程序,避免了无意义的轮询。
4. 水平触发(LT)模式的潜在问题
- 问题:
poll()
仅支持水平触发模式(Level Triggered)。在这种模式下,如果应用程序没有完全处理完就绪描述符上的数据,poll()
会不断通知该描述符就绪,可能导致 “忙等待”。 - 对比:
epoll()
支持边缘触发(Edge Triggered)模式,仅在描述符状态变化时通知一次,强制应用程序必须一次性处理完所有数据,减少了重复通知的开销。
5. 不适合超大规模连接
- 问题:虽然
poll()
没有select()
的FD_SETSIZE
限制,但由于其 O (n) 的时间复杂度和线性扫描机制,当监控的描述符数量超过数千时,性能会显著下降。 - 适用场景:
poll()
更适合中等规模的并发连接(如几百个),对于大规模高并发场景(如 10K+ 连接),epoll()
或kqueue()
是更优选择。
epoll
在 Linux 系统编程里,epoll
是一种用于高效处理大量并发连接的 I/O 多路复用技术。和 select
、poll
相比,它在处理大量文件描述符时,性能表现更为出色。
核心概念
epoll 实例:借助 epoll_create()
函数能够创建一个 epoll 实例,它其实是内核里的一个数据结构,其作用是存储待监控的文件描述符以及相关事件。
#include <sys/epoll.h> int epoll_create(int size);
epoll_create()
参数说明
size
:这个参数用于提示内核需要创建的事件表大小。但必须设置为大于 0 的值。
返回值
如果函数调用成功,将返回一个指向新创建的 epoll 实例的文件描述符;若调用失败,则返回 -1,并设置相应的 errno
。
注册事件:可以使用 epoll_ctl()
函数对文件描述符进行管理,能执行添加、修改或者删除操作。在注册事件时,需要指定感兴趣的事件类型,像 EPOLLIN
(可读事件)、EPOLLOUT
(可写事件)等。
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl()
参数说明
epfd
:由epoll_create()
返回的 epoll 实例文件描述符。op
:要执行的操作,其取值如下:EPOLL_CTL_ADD
:将fd
添加到 epoll 实例中,并注册event
所指定的事件。EPOLL_CTL_MOD
:修改已经注册的fd
的事件为event
所指定的事件。EPOLL_CTL_DEL
:从 epoll 实例中删除fd
,此时event
参数可以设为NULL
。
fd
:需要监控的目标文件描述符。event
:这是一个指向struct 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;
常用事件类型
EPOLLIN
:文件描述符可读。EPOLLOUT
:文件描述符可写。EPOLLET
:设置为边缘触发模式(默认是水平触发模式)。EPOLLONESHOT
:设置为一次性触发事件,事件触发后需要重新注册。
返回值
若函数调用成功,返回 0;若调用失败,返回 -1,并设置 errno
。
事件等待:通过 epoll_wait()
函数,进程会被阻塞,直到有注册的事件发生。此时,该函数会返回一个包含就绪文件描述符的列表。
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_wait()
参数说明
epfd
:epoll 实例的文件描述符。events
:用于存储就绪事件的数组,该数组由用户分配内存。maxevents
:指定events
数组的最大容量,必须大于 0。timeout
:超时时间(单位为毫秒),其取值情况如下:-1
:无限等待,直到有事件发生。0
:立即返回,即使没有就绪事件。>0
:等待指定的毫秒数。
返回值
- 若函数调用成功,返回就绪事件的数量。
- 若超时时间到仍没有事件发生,返回 0。
- 若调用失败,返回 -1,并设置
errno
。
关键要点
非阻塞模式
在使用 epoll 的边缘触发模式时,必须将文件描述符设置为非阻塞模式,以避免在处理大量并发连接时出现阻塞。
-
边缘触发(ET)与水平触发(LT):
- ET 模式:只有当文件描述符的状态发生变化时才会触发事件,这就要求应用程序必须处理完所有数据,否则未处理的数据不会再次触发通知。
- LT 模式:只要文件描述符处于就绪状态,就会持续触发事件。
- 错误处理:在实际应用中,需要处理各种可能的错误情况,如
EPOLLERR
、EPOLLHUP
等事件。
用epoll实现一个简单的TCP服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#define MAX_EVENTS 10
#define BUFFER_SIZE 1024
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl");
return -1;
}
return 0;
}
int main() {
int listen_fd, epfd;
struct sockaddr_in server_addr;
struct epoll_event ev, events[MAX_EVENTS];
int nfds, conn_sock;
char buffer[BUFFER_SIZE];
// 创建监听套接字
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置套接字选项
int opt = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
// 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8888);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(listen_fd, SOMAXCONN) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}
// 设置为非阻塞模式
if (set_nonblocking(listen_fd) == -1) {
exit(EXIT_FAILURE);
}
// 创建 epoll 实例
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 注册监听套接字的读事件
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl: listen_fd");
exit(EXIT_FAILURE);
}
printf("Server started, listening on port 8888...\n");
// 事件循环
while (1) {
// 等待事件发生
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
// 处理就绪事件
for (int i = 0; i < nfds; i++) {
// 处理新连接
if (events[i].data.fd == listen_fd) {
conn_sock = accept(listen_fd, NULL, NULL);
if (conn_sock == -1) {
perror("accept");
continue;
}
if (set_nonblocking(conn_sock) == -1) {
close(conn_sock);
continue;
}
ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
ev.data.fd = conn_sock;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
perror("epoll_ctl: conn_sock");
close(conn_sock);
continue;
}
printf("New connection established\n");
}
// 处理客户端数据
else {
memset(buffer, 0, BUFFER_SIZE);
int n = read(events[i].data.fd, buffer, BUFFER_SIZE);
if (n <= 0) {
// 客户端关闭连接或发生错误
close(events[i].data.fd);
printf("Connection closed\n");
} else {
// 回显数据给客户端
write(events[i].data.fd, buffer, n);
}
}
}
}
// 清理资源
close(listen_fd);
close(epfd);
return 0;
}
总结
总体而言,select
出现最早,接口简单但存在诸多限制;poll
改进了文件描述符数量限制问题,但本质上与 select
一样存在性能瓶颈;epoll
是 Linux 下高效的 I/O 多路复用方案,通过事件驱动和优化的数据结构,解决了前两者在高并发场景下的性能问题,是目前 Linux 网络编程中处理大量并发连接的首选方案 。