上篇文章我们介绍了两种服务端接收多个TCP连接的实现方式:分别基于多线程和多进程实现,我们知道,进程和i西安城的创建都是有开销的,如果需要同时维护上万连接,这两种方式都是扛不住的,此时,需要使用更加节省资源,更加高效的方式:I/O多路复用
I/O多路复用是Linux中用于处理多个I/O操作的机制,使得单个线程或进程可以同时监视多个文件描述符,以处理多路I/O请求。它主要通过一下系统调用实现:select、poll和epoll
作用和意义:
节省资源:IO多路复用允许单个进程或线程同时监视多个文件描述符,而不是为每个I/O操作创建一个线程或进程。只需要维护文件描述符,极大地提高了节约了资源,减少了系统开销。应用场景
监控系统:例如日志监控、数据库连接池管理等需要同时监视多个输入源的系统。
聊天系统:实时聊天应用程序需要高效地管理多个用户的消息。
网络服务器:例如HTTP服务器、FTP服务器等需要同时处理大量客户端请求的场景。
效率高:使用IO多路复用省去了进程或线程上下文切换的开销,提升了处理效率,减少了系统资源(如内存和CPU时间)的消耗,从而提高了应用程序的整体性能和响应速度。
简化编程模型:尽管IO多路复用增加了代码的复杂性,但它简化了高并发程序的设计,使得程序员可以更容易地管理多个I/O操作,而不必处理大量的线程同步问题。
IO多路复用是Linux系统中处理多路I/O操作的重要技术,它通过提高资源利用率和系统性能,为开发高效、高并发的应用程序提供了强大的支持。
select和poll底层都是基于线性结构实现的,需要对文件描述符集执行多次遍历和拷贝,效率低下,而epoll底层是基于红黑树(一种平衡二叉树)实现的,且通过维护就绪事件链表,效率更高,本文只介绍epoll。
在 epoll 的使用中,有两种事件触发模式:边缘触发(Edge Triggered, ET)和水平触发(Level Triggered, LT)。这两种模式决定了 epoll 如何通知应用程序有事件发生。可以类比单片机的边缘触发和电平触发。
水平触发是epoll的默认模式。在这种模式下,只要文件描述符上有未处理的事件,epoll就会不断通知应用程序。
边缘触发模式下,当文件描述符从未就绪状态变为就绪状态时,epoll 会通知应用程序。如果应用程序没有在通知后及时处理事件(例如,读出所有可读的数据),epoll 不会再次通知,除非文件描述符再次从未就绪变为就绪状态。即只在状态变化时通知一次,因而叫边缘触发。
下面介绍一下几个核心函数:
一,epoll_create/epoll_create1
#include <sys/epoll.h>
int epoll_create(int size); // 已废弃(size参数被忽略)
int epoll_create1(int flags); // 推荐使用
参数:flages:通常设为0(或EPOLL_CLOEXEC使fd在exec时自动关闭)
返回值:成功:epoll实例的文件描述符(epfd)
失败:-1(设置errno)
作用:创建一个epoll实例,内核会分配数据结构用于管理监控的文件描述符
二,epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数
epfd:epoll_create1返回的文件描述符
op: 操作类型(三选一)EPOLL_CTL_ADD: 添加新的监控项EPOLL_CTL_MOD: 修改已有监控项EPOLL_CTL_DEL: 删除监控项(此时event参数可忽略)
fd: 需要监控的文件描述符(如socket)
event: 指向epoll_event结构体的指针(见下文)
- 返回值
成功:0
失败:-1(设置errno) - 作用
向epoll实例注册/修改/删除需要监控的文件描述符及事件类型
struct epoll_event结构体:
typedef union epoll_data {
void *ptr;
int fd; // 常用:存储关联的文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; // 监控的事件类型
epoll_data_t data; // 用户数据(通常用data.fd存储fd)
};
- 关键事件标志
EPOLLIN: 文件描述符可读
EPOLLOUT: 文件描述符可写
EPOLLET: 设置为边缘触发(Edge Triggered)模式
EPOLLERR: 发生错误(默认监控,无需显式设置)
三,epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 参数
epfd: epoll实例的文件描述符
events: 指向用户分配的数组,用于接收就绪事件
maxevents:events数组的大小(最大返回事件数)
timeout: 超时时间(毫秒)-1: 无限等待0: 立即返回(非阻塞)>0: 等待指定毫秒数
- 返回值
成功:就绪的事件数量(>0)
超时:0
失败:-1(设置errno) - 作用
阻塞等待 监控的文件描述符上有事件发生,将就绪事件填充到events数组
四,fcntl(用于设置非阻塞IO)
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
// 获取当前文件状态标志
int flags = fcntl(fd, F_GETFL, 0);
// 设置非阻塞模式(必须与原有标志合并)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
- 作用
将文件描述符设置为非阻塞模式(对边缘触发ET模式至关重要)
------------------------epoll_test.c------------------------
#include <sys/socket.h> // 提供socket相关函数
#include <sys/types.h> // 提供基本数据类型(如socklen_t)
#include <netinet/in.h> // 提供sockaddr_in等网络地址结构
#include <stdio.h> // 标准输入输出(printf, perror等)
#include <stdlib.h> // 标准库函数(malloc, exit等)
#include <string.h> // 字符串操作(memset, strcpy等)
#include <arpa/inet.h> // 提供inet_ntoa等IP地址转换函数
#include <pthread.h> // 线程库(本例未使用,可移除)
#include <unistd.h> // POSIX API(close, read, write等)
#include <sys/epoll.h> // epoll核心头文件
#include <fcntl.h> // 文件控制(fcntl函数)
#include <errno.h> // 错误号定义(EAGAIN等)
// 定义常量
#define SEVER_PORT 6666 // 服务器监听端口
#define BUFFER_SIZE 1024 // 读写缓冲区大小
#define MAX_EVENTS 10 // epoll_wait一次最多处理的事件数
/*
* 错误处理宏:检查函数返回值
* 用法:handle_error("函数名", 返回值);
* 作用:如果返回值<0,打印错误信息并退出程序
*/
#define handle_error(cmd, result) \
if (result < 0) \
{ \
perror(cmd); \ // 打印系统错误信息
exit(EXIT_FAILURE); \ // 退出程序
}
// 全局缓冲区指针
char *read_buf = NULL; // 用于存储从客户端读取的数据
char *write_buf = NULL; // 用于存储要发送给客户端的数据
/*
* 初始化读写缓冲区
* 作用:分配内存并清零
*/
void init_buf()
{
// 为读缓冲区分配内存
read_buf = malloc(sizeof(char) * BUFFER_SIZE);
// 检查内存分配是否成功
if (!read_buf)
{
printf("服务端读缓存创建异常,断开连接\n");
perror("malloc server read_buf"); // 打印详细错误
exit(EXIT_FAILURE); // 失败则退出
}
// 为写缓冲区分配内存
write_buf = malloc(sizeof(char) * BUFFER_SIZE);
if (!write_buf)
{
printf("服务端写缓存创建异常,断开连接\n");
free(read_buf); // 释放已分配的读缓冲区
perror("malloc server write_buf");
exit(EXIT_FAILURE);
}
// 将两个缓冲区内容清零
memset(read_buf, 0, BUFFER_SIZE);
memset(write_buf, 0, BUFFER_SIZE);
}
/*
* 清空指定缓冲区
* 作用:将缓冲区内容全部置为0
* 参数:buf - 要清空的缓冲区指针
*/
void clear_buf(char *buf)
{
memset(buf, 0, BUFFER_SIZE);
}
/*
* 设置socket为非阻塞模式
* 作用:避免I/O操作阻塞线程
* 参数:sockfd - 要设置的socket文件描述符
*/
void set_nonblocking(int sockfd)
{
// 获取当前文件状态标志
int opts = fcntl(sockfd, F_GETFL);
if (opts < 0)
{
perror("fcntl(F_GETFL)"); // 获取失败
exit(EXIT_FAILURE);
}
// 添加非阻塞标志
opts |= O_NONBLOCK;
// 应用新设置
int res = fcntl(sockfd, F_SETFL, opts);
if (res < 0)
{
perror("fcntl(F_SETFL)");
exit(EXIT_FAILURE);
}
}
/*
* 主函数
*/
int main(int argc, char const *argv[])
{
// 初始化读写缓冲区
init_buf();
// 声明变量
int sockfd, client_fd, temp_result; // sockfd:监听socket, client_fd:客户端socket
struct sockaddr_in server_addr, client_addr; // 服务器和客户端地址结构
// 清零地址结构体(避免残留数据)
memset(&server_addr, 0, sizeof(server_addr));
memset(&client_addr, 0, sizeof(client_addr));
// 配置服务器地址
server_addr.sin_family = AF_INET; // IPv4协议族
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网络接口
server_addr.sin_port = htons(SEVER_PORT); // 设置端口号(转换为网络字节序)
// 创建监听socket
sockfd = socket(AF_INET, SOCK_STREAM, 0); // TCP socket
handle_error("socket", sockfd); // 检查错误
// 绑定地址到socket
temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
handle_error("bind", temp_result);
// 将socket设为监听状态,128是连接队列最大长度
temp_result = listen(sockfd, 128);
handle_error("listen", temp_result);
// 将监听socket设为非阻塞(epoll必需)
set_nonblocking(sockfd);
// 声明epoll相关变量
int epollfd, nfds; // epollfd:epoll实例描述符, nfds:就绪事件数量
struct epoll_event ev, events[MAX_EVENTS]; // ev:单个事件, events:事件数组
// 创建epoll实例
epollfd = epoll_create1(0); // 0表示默认标志
handle_error("epoll_create1", epollfd); // 注意:宏中拼写错误已修正
// 配置监听socket的事件
ev.data.fd = sockfd; // 关联文件描述符
ev.events = EPOLLIN; // 监听可读事件(新连接到来)
// 注意:监听socket不使用ET模式,避免漏处理新连接
// 将监听socket添加到epoll实例
temp_result = epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);
handle_error("epoll_ctl", temp_result);
// 客户端地址结构长度(用于accept)
socklen_t cliaddr_len = sizeof(client_addr);
// 主事件循环
while (1)
{
// 等待事件发生,-1表示永久阻塞
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
handle_error("epoll_wait", nfds);
// 遍历所有就绪事件
for (int i = 0; i < nfds; i++)
{
// 情况1:监听socket就绪(有新连接)
if (events[i].data.fd == sockfd)
{
// 接受新连接
client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);
handle_error("accept", client_fd);
// 将新连接设为非阻塞(ET模式必需)
set_nonblocking(client_fd);
// 打印客户端连接信息
printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n",
inet_ntoa(client_addr.sin_addr), // 转换IP地址为字符串
ntohs(client_addr.sin_port), // 转换端口号为本地字节序
client_fd); // 客户端socket描述符
// 配置新连接的事件
ev.data.fd = client_fd;
ev.events = EPOLLIN | EPOLLET; // 可读事件 + 边缘触发模式
// 将新连接添加到epoll
epoll_ctl(epollfd, EPOLL_CTL_ADD, client_fd, &ev);
}
// 情况2:客户端socket可读
else if (events[i].events & EPOLLIN)
{
int count = 0, send_count = 0; // 读写字节数
client_fd = events[i].data.fd; // 获取客户端描述符
/*
* ET模式关键:必须循环读取直到无数据
* 原因:ET只在状态变化时通知一次,必须一次性读完所有数据
*/
while ((count = recv(client_fd, read_buf, BUFFER_SIZE, 0)) > 0)
{
// 打印收到的数据
printf("receive message from client_fd: %d: %s\n", client_fd, read_buf);
// 清空读缓冲区(为下次读取准备)
clear_buf(read_buf);
// 准备回复数据
strcpy(write_buf, "received~\n"); // 修正拼写错误
// 发送回复
send_count = send(client_fd, write_buf, strlen(write_buf), 0);
handle_error("send", send_count);
// 清空写缓冲区
clear_buf(write_buf);
}
// 处理recv返回的不同情况
if (count == -1 && errno == EAGAIN)
{
// 非阻塞模式下无数据可读(正常情况)
printf("来自客户端client_fd: %d当前批次的数据已读取完毕,继续监听\n", client_fd);
}
else if (count == 0)
{
// 客户端关闭连接
printf("客户端client_fd: %d请求关闭连接......\n", client_fd);
// 发送关闭确认
strcpy(write_buf, "received your shutdown signal\n");
send_count = send(client_fd, write_buf, strlen(write_buf), 0);
handle_error("send", send_count);
clear_buf(write_buf);
// 从epoll中移除该描述符
printf("从epoll中移除client_fd: %d\n", client_fd);
epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);
// 关闭连接
printf("释放client_fd: %d资源\n", client_fd);
close(client_fd); // 直接关闭即可,无需先shutdown
}
else if (count == -1)
{
// 其他错误处理
perror("recv error");
epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);
close(client_fd);
}
}
// 注意:本例未处理可写事件(EPOLLOUT),适合小数据回复场景
}
}
/* 以下代码在正常运行中不会执行(死循环),仅作资源清理示例 */
printf("释放资源\n");
close(epollfd); // 关闭epoll实例
close(sockfd); // 关闭监听socket
free(read_buf); // 释放缓冲区
free(write_buf);
return 0;
}
核心流程详解(按执行顺序)
初始化阶段
- 分配缓冲区 :
init_buf()创建1024字节的读写缓冲区 - 创建监听socket :
socket()创建TCP socketbind()绑定0.0.0.0:6666listen()设置连接队列长度为128
- 设置非阻塞 :
set_nonblocking(sockfd)确保监听socket非阻塞 - 创建epoll实例 :
epoll_create1(0)创建监控中心 - 注册监听socket :
- 设置事件类型:
EPOLLIN(新连接到来) - 通过
epoll_ctl(ADD)添加到epoll实例
- 设置事件类型:
事件循环(核心)
- 等待事件 :
epoll_wait()阻塞等待事件发生 - 处理就绪事件 :
- 新连接事件 (监听socket就绪):
accept()接受新连接set_nonblocking()设置客户端socket为非阻塞epoll_ctl(ADD)注册新连接,事件类型:EPOLLIN | EPOLLET
- 数据可读事件 (客户端socket就绪):
- ET模式关键 :循环调用
recv()直到返回EAGAIN - 每收到一条消息立即回复
"received~\n" - 处理连接关闭:
- 收到
count=0表示客户端关闭 - 发送确认消息
epoll_ctl(DEL)从epoll移除描述符close()释放资源
- 收到
- ET模式关键 :循环调用
- 新连接事件 (监听socket就绪):
当一个服务器要同时服务1000给客户端时:
传统方式:为每个客户端开1个线程 ->1000个线程 ->系统崩溃(线程创建/切换开销太大)
epoll方案:1个线程监控所有客户端 -> 只有客户端发送消息时才处理 ->资源消耗极低
步骤一:创建监控中心
int epollfd = epoll_create1(0); // 创建1个监控中心
- 作用:向系统申请1个"监控器",后续所有socket都注册到这里
步骤二:注册监听socket
ev.data.fd = sockfd; // 告诉监控器:这是监听socket
ev.events = EPOLLIN; // 监控"有新客户端要连接"事件
epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev); // 添加到监控中心
- 为什么:当有新客户端连服务器时,系统会通知这个监控器
步骤三:无限循环等待事件
int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
- 作用 :线程卡在这里,直到有客户端发消息或新连接
-1表示:永远等,直到有事发生nfds= 本次有多少客户端需要处理(比如3个客户端同时发消息,nfds=3)
步骤四:处理事件(两种情况)
情况A:有新客户端连接(监听socket被触发)
if (events[i].data.fd == sockfd) {
client_fd = accept(...); // 接受新连接
set_nonblocking(client_fd); // 设置非阻塞(必须!)
ev.events = EPOLLIN | EPOLLET; // 监控"收消息"事件 + 边缘触发
epoll_ctl(epollfd, EPOLL_CTL_ADD, client_fd, &ev); // 添加到监控
}
- 关键点 :
EPOLLET= 边缘触发(高效模式,但必须配合非阻塞)- 每个新客户端都会被加入监控中心
情况B:老客户端发消息了(已连接的socket被触发)
else if (events[i].events & EPOLLIN) {
while ( (count = recv(...)) > 0 ) { // 循环收数据
printf("收到: %s", read_buf);
send(client_fd, "received~", ...); // 立刻回复
}
if (count == 0) { // 客户端断开
epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL); // 从监控移除
close(client_fd); // 关闭连接
}
}
- 关键点 :
- 必须用
while循环收数据(边缘触发要求) - 收到
count=0表示客户端主动断开
- 必须用
步骤五:资源清理
close(epollfd); // 关闭监控中心
close(sockfd); // 关闭监听socket
free(read_buf); // 释放内存
- 注意:正常运行时不会执行到这里(死循环),仅用于程序退出时清理