【Linux | 网络编程】02 IO多路复用
4 IO多路复用
4.1 IO模型
传统I/O(输入/输出)是相对于内存而言的:输入即文件到内存,输出即内存到文件。
socket通信中,每个socket的文件描述符fd对应于内核中的一块缓冲区(读缓冲区+写缓冲区)。这里的I/O则是对缓冲区的操作。
常见的几种IO模型:
- 阻塞I/O(Blocking I/O)
- 等待与拷贝阶段全程阻塞,进程挂起,不占用 CPU;
- 例如:
accept()、read()、recv()默认都是阻塞 I/O; - 优点:代码简单;
- 缺点:一个进程只能处理一个连接,高并发下会卡死(比如 100 个客户端连接,只能串行处理)。
- 非阻塞I/O(Non-blocking I/O)
- 等待数据就绪阶段非阻塞:每次调用
read()会立即返回,没数据就返回-1,并设置errno=EAGAIN/EWOULDBLOCK;进程需要轮询调用read(),直到数据就绪; - 把套接字设置为非阻塞模式(
O_NONBLOCK); - 优点:一个进程能处理多个连接;
- 缺点:轮询会占用大量CPU资源(空等也在跑循环),效率低。
- 等待数据就绪阶段非阻塞:每次调用
- I/O多路复用(I/O Multiplexing)
- 进程通过
epoll等函数监听多个套接字,只有当至少一个套接字数据就绪时,函数才返回;本质是 “阻塞在多路复用函数上”,而非阻塞在单个read()/write()上; - 最常用的高并发模型,例如
select,poll,epoll; - 优点:一个进程能高效处理上万连接(高并发核心方案),CPU利用率高;
- 缺点:代码比阻塞I/O复杂,需要理解事件驱动逻辑。
- 进程通过
- 信号驱动I/O(Signal-driven I/O)
- 数据就绪时,内核给进程发
SIGIO信号,进程在信号处理函数中调用read()拷贝数据;等待阶段非阻塞,拷贝阶段阻塞; - 通过信号
SIGIO实现:先注册信号处理函数,然后进程继续执行; - 优点:无需轮询,等待阶段不占用CPU;
- 缺点:信号处理逻辑复杂,高并发下信号会 “扎堆”,难以处理,实际中很少用。
- 数据就绪时,内核给进程发
- 异步I/O(Asynchronous I/O)
- 等待与拷贝阶段全程非阻塞:进程调用
aio_read()后直接返回,内核完成 “等待数据+拷贝到用户缓冲区”后,通过回调/信号通知进程;进程全程不用参与I/O操作,直到内核通知操作完成。 - 最理想的 I/O 模型,例如
aio_read,aio_write; - 优点:完全非阻塞,CPU利用率最高;
- 缺点:Linux下支持不够完善,代码复杂度高。
- 等待与拷贝阶段全程非阻塞:进程调用
4.2 select
4.2.1 select函数
select函数允许程序同时监视多个文件描述符,检测它们的状态变化(例如,数据可读或可写),从而高效地管理多个I/O操作而不需为每个操作创建独立线程或进程。这种能力尤其在网络通信和服务器编程中提高了并发性能。select还支持非阻塞I/O,允许程序在等待I/O事件的同时执行其他任务,有效利用CPU资源。
基本原型如下:
intselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,structtimeval*timeout);参数:
- nfds:监控的文件描述符集合中最大文件描述符的值加1。在使用select函数时,必须确保这个参数正确设置,以便函数能监视所有相关的文件描述符。
- readfds, writefds, exceptfds:这三个参数分别代表读、写和异常监视的文件描述符集合。它们使用fd_set类型表示,这是一种通过位图来管理文件描述符的数据结构。无需监视则传
NULL。以下是对fd_set操作的常用宏定义:FD_SET(fd, &set):将文件描述符fd添加到集合set中;FD_CLR(fd, &set):从集合set中移除文件描述符fd;FD_ISSET(fd, &set):检查文件描述符fd是否已被加入集合set;FD_ZERO(&set):清空集合set中的所有文件描述符。- 当timeout为
NULL时,select会无限等待,直到至少有一个文件描述符就绪。 - 当timeout设置为0时(即tv_sec和tv_usec都为0),
select会立即返回,用于轮询。 - 设置具体的时间,select将等待直到该时间过去或者有文件描述符就绪。
timeout:这是一个指向timeval结构的指针,该结构用于设定select等待I/O事件的超时时间。结构定义如下:
structtimeval{long tv_sec;// secondslong tv_usec;// microseconds};timeout的设定有三种情况:
返回值:
- 大于0:表示就绪的文件描述符数量,即有多少文件描述符已经准备好进行I/O操作。
- 等于0:表示超时,没有文件描述符在指定时间内就绪。
- 小于0:发生错误。返回-1,并设置errno。
4.2.2 调用流程
- 始化文件描述符集合:使用fd_set类型的集合来监控不同的I/O操作(读、写、异常),操作这些集合可使用相关宏。
- 调用
select函数:传入文件描述符集合和超时时间,允许在超时或有描述符就绪时返回,避免无限等待。 - 阻塞与等待I/O事件:
select阻塞程序执行,直至至少一个文件描述符就绪或超时。 - 检查就绪的文件描述符:当
select返回后,检查各文件描述符集合的状态,确定哪些文件描述符准备好进行读、写或异常处理。 - 循环监控:如果继续监控是必要的,重置文件描述符集合并重新调用
select,这支持持续监控多个I/O源。
4.2.3 select实现实时对话
server服务端:
#include<func.h>#defineCONNS_MAX1024intmain(int argc,char*argv[]){signal(SIGPIPE, SIG_IGN);ARGC_CHECK(argc,3);int ret =0;int listenfd =socket(AF_INET, SOCK_STREAM,0);ERROR_CHECK(listenfd,-1,"socket");printf("listenfd : %d.\n", listenfd);int on =1; ret =setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,&on,sizeof(int));ERROR_CHECK(ret,-1,"setsockopt");structsockaddr_in serveraddr;memset(&serveraddr,0,sizeof(serveraddr)); serveraddr.sin_family = AF_INET; serveraddr.sin_port =htons(atoi(argv[2])); serveraddr.sin_addr.s_addr =inet_addr(argv[1]); ret =bind(listenfd,(conststructsockaddr*)&serveraddr,sizeof(serveraddr));ERROR_CHECK(ret,-1,"bind"); ret =listen(listenfd,10);ERROR_CHECK(ret,-1,"listen");printf("server start listening!\n"); fd_set rdset;FD_ZERO(&rdset);char buff[1000]={0};int maxfd = listenfd;int conns[CONNS_MAX]={0};while(1){FD_ZERO(&rdset);FD_SET(listenfd,&rdset);for(int i =0; i < CONNS_MAX;++i){if(conns[i]!=0){FD_SET(conns[i],&rdset);}}select(maxfd +1,&rdset,NULL,NULL,NULL);if(FD_ISSET(listenfd,&rdset)){structsockaddr_in clientaddr;memset(&clientaddr,0,sizeof(clientaddr));socklen_t len =sizeof(clientaddr);int peerfd =accept(listenfd,(structsockaddr*)&clientaddr,&len);printf("%s:%d has connected, peerfd: %d.\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port), peerfd);for(int i =0; i < CONNS_MAX;++i){if(conns[i]==0){ conns[i]= peerfd;break;}}if(maxfd < peerfd){ maxfd = peerfd;}}for(int i =0; i < CONNS_MAX;++i){if(conns[i]!=0){if(FD_ISSET(conns[i],&rdset)){memset(buff,0,sizeof(buff)); ret =recv(conns[i], buff,sizeof(buff),0);if(ret ==0){close(conns[i]); conns[i]=0;continue;}printf("recv msg %d: %s\n", conns[i], buff);send(conns[i], buff,strlen(buff),0);}}}}printf("byebye.\n");close(listenfd);return0;}client客户端:
#include<func.h>intmain(int argc,char*argv[]){ARGC_CHECK(argc,3);int ret =0;int clientfd =socket(AF_INET, SOCK_STREAM,0);ERROR_CHECK(clientfd,-1,"socket");structsockaddr_in serveraddr;memset(&serveraddr,0,sizeof(serveraddr)); serveraddr.sin_family = AF_INET; serveraddr.sin_port =htons(atoi(argv[2])); serveraddr.sin_addr.s_addr =inet_addr(argv[1]); ret =connect(clientfd,(conststructsockaddr*)&serveraddr,sizeof(serveraddr));ERROR_CHECK(ret,-1,"connect");printf("connection success!\n"); fd_set rdset;FD_ZERO(&rdset);char buff[1000]={0};while(1){FD_ZERO(&rdset);FD_SET(clientfd,&rdset);FD_SET(STDIN_FILENO,&rdset);select(clientfd +1,&rdset,NULL,NULL,NULL);if(FD_ISSET(STDIN_FILENO,&rdset)){memset(buff,0,sizeof(buff)); ret =read(STDIN_FILENO, buff,sizeof(buff));if(ret ==0){break;}send(clientfd, buff,strlen(buff)-1,0);printf("send successfully!\n");}if(FD_ISSET(clientfd,&rdset)){memset(buff,0,sizeof(buff)); ret =recv(clientfd, buff,sizeof(buff),0);if(ret ==0){break;}printf("recv msg: %s\n", buff);}}printf("byebye.\n");return0;}4.2.4 select的限制
- 支持能够监听的文件描述符数量有限(32位系统为1024,64位系统为2048);
- fd_set位图不能重用,每次循环前都需要进行重置;
- 每次调用
select时,都需要把fd位图从用户态拷贝到内核态,且都需要在内核遍历传递进来的所有fd,这个开销在fd很多时会很大; - 用户态必须遍历所有已经建立连接的fd,查找到真正发生了事件的fd采取进行相应处理,效率不高。
4.3 epoll
4.3.1 epoll基本原理
epoll可以监听多个设备的就绪状态,让进程或者线程只在有事件发生之后再执行真正的读写操作。epoll可以在内核态空间当中维持两个数据结构:监听事件集合和就绪事件队列。监听事件集合用来存储所有需要关注的设备(即文件描述符)和对应操作(比如读、写、挂起和异常等等),当监听的设备有事件产生时,硬件会采用中断等方式通知操作系统,操作系统会将就绪事件拷贝到就绪事件队列中,并且找到阻塞在epoll_wait的线程让其就绪。监听事件集合通常是一个红黑树,就绪事件队列是一个线性表。
和select相比,epoll的优势如下:
- 除了水平触发,还支持边缘触发;
- 监听事件集合容量很大,有多少内存就能放下多少文件描述符;
- 监听事件集合常驻内核态,调用
epoll_wait函数不会修改监听性质,不需要每次将集合从用户态拷贝到内核态; - 监听事件和就绪事件的状态分为两个数据结构存储,当
epoll_wait就绪之后,用户可以直接遍历就绪事件队列,而不需要在所有事件当中进行轮询。
有了这些优势之后, epoll逐渐取代了select的市场地位,尤其是在管理巨大量连接的高并发场景中, epoll的性能要远超select 。
4.3.2 核心函数
4.3.2.1 epoll_create1()
epoll_create1()/epoll_create()函数用来创建一个epoll实例:
#include<sys/epoll.h>intepoll_create(int size);intepoll_create1(int flags);// flag 一般直接指定为0// 返回值 成功 --> 大于0(一个文件描述符epfd), 失败 --> -1(设置errno)这个epoll实例中就包含监听事件集合和就绪设备集合。
4.3.2.2 epoll_ctl()
epoll_ctl用于调整监听事件集合,可以向其中添加、修改或删除相关文件描述符:
intepoll_ctl(int epfd,int op,int fd,structepoll_event*event);// epfd 指定epoll的实例// op 操作类型,可以是:// EPOLL_CTL_ADD 将文件描述符fd注册到epfd中// EPOLL_CTL_MOD 修改已注册文件描述符fd的事件类型// EPOLL_CTL_DEL 从epfd中删除文件描述符fd// fd 要操作的目标文件描述符// event 一个指向epoll_event结构体的指针,用于指定fd感兴趣的事件类型// 返回值 成功 --> 返回0(一个文件描述符epfd), 失败 --> -1(设置errno)epoll_event结构体定义如下:
structepoll_event{ __uint32_t events;// 要监听的事件epoll_data_t data;};typedefunion epoll_data {void*ptr;int fd;// 关注的文件描述符uint32_t u32;uint64_t u64;}epoll_data_t;联合体:n个字段共享同一片地址空间,同一时刻只能表示一个字段。
events常用的事件类型有:
EPOLLIN:文件描述符可读(有数据到达);EPOLLOUT:文件描述符可写(可以发送数据);EPOLLET:边缘触发 (Edge Triggered)模式,是epoll最强大的特性之一;EPOLLLT:水平触发 (Level Triggered)模式(默认);EPOLLERR:文件描述符发生错误;EPOLLHUP:文件描述符被挂断(对端关闭连接);EPOLLONESHOT:一次性事件,事件触发后自动取消监听,需要重新MOD才能再次监听。
4.3.2.3 epoll_wait()
epoll_wait用于使线程陷入阻塞,直到监听的设备就绪或者超时:
intepoll_wait(int epfd,structepoll_event*events,int maxevents,int timeout);// epfd 指定epoll的实例// events 用户态空间的struct epoll_event数组首地址// maxevents 数组的长度j// timeout 超时事件,单位是毫秒;设置为-1表示无限等待// 返回值 大于0 --> 就绪的文件描述符的数量// 等于0 --> epoll_wait超时 // 小于0 --> 发生了错误,返回-1,设置errnoepoll_wait是一个阻塞式函数。
4.3.3 epoll与select
- 对于文件描述符的监听
- select每次执行之前都需要重新进行设置 ;
- epoll只需要监听一次,后续不再需要再次监听。
- 底层实现
- select底层是采用数组(位图),其监听的文件描述符是有上限的;
- epoll底层实现采用的是红黑树+就绪链表,其监听的文件描述符没有上限,一般情况下与内存有关 。
- 内核态轮询机制
- select每一次轮询时,都要对监听的所有fd都去询问一次是否就绪;
- epoll具有回调机制,一旦监听的某一个文件描述符上发生了事件,会主动通知epoll进行处理,尤其是针对于大并发的请求,效率远远高于select。
- 用户态轮询
select返回之后需要对已经建立好的连接轮询,确认其是否在已经就绪的fd_set中;epoll_wait返回之后,相应的就绪文件描述符已经拿到了,不再需要轮询所有已经建立好的连接。
4.3.4 epoll实现实时对话
server服务端:
#include<func.h>#defineCONNS_MAX1024intmain(int argc,char*argv[]){signal(SIGPIPE, SIG_IGN);ARGC_CHECK(argc,3);int ret =0;int listenfd =socket(AF_INET, SOCK_STREAM,0);ERROR_CHECK(listenfd,-1,"socket");printf("listenfd : %d.\n", listenfd);int on =1; ret =setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,&on,sizeof(int));ERROR_CHECK(ret,-1,"setsockopt");structsockaddr_in serveraddr;memset(&serveraddr,0,sizeof(serveraddr)); serveraddr.sin_family = AF_INET; serveraddr.sin_port =htons(atoi(argv[2])); serveraddr.sin_addr.s_addr =inet_addr(argv[1]); ret =bind(listenfd,(conststructsockaddr*)&serveraddr,sizeof(serveraddr));ERROR_CHECK(ret,-1,"bind"); ret =listen(listenfd,10);ERROR_CHECK(ret,-1,"listen");printf("server start listening!\n");int epfd =epoll_create1(0);ERROR_CHECK(epfd,-1,"epoll_create1");structepoll_event ev; ev.events = EPOLLIN; ev.data.fd = listenfd; ret =epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd,&ev);ERROR_CHECK(ret,-1,"epoll_ctl");structepoll_event*pEventArr =(structepoll_event*)calloc(CONNS_MAX,sizeof(structepoll_event));while(1){int nready =epoll_wait(epfd, pEventArr, CONNS_MAX,-1);if(nready ==-1&& errno == EINTR){continue;}elseif(nready ==0){printf("timeout!\n");}elseif(nready ==-1){break;}else{for(int i =0; i < nready;++i){int fd = pEventArr[i].data.fd;if(fd == listenfd){structsockaddr_in clientaddr;memset(&clientaddr,0,sizeof(clientaddr));socklen_t len =sizeof(clientaddr);int peerfd =accept(listenfd,(structsockaddr*)&clientaddr,&len);printf("%s:%d has connected, peerfd: %d.\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port), peerfd);structepoll_event ev; ev.events = EPOLLIN; ev.data.fd = peerfd;epoll_ctl(epfd, EPOLL_CTL_ADD, peerfd,&ev);}else{if(pEventArr[i].events == EPOLLIN){char buff[1024]={0}; ret =recv(fd, buff,sizeof(buff), MSG_DONTWAIT);if(ret ==0){ ev.data.fd = fd; ret =epoll_ctl(epfd, EPOLL_CTL_DEL, fd,&ev);ERROR_CHECK(ret,-1,"epoll_ctl");close(fd);printf("byebye %d.\n", fd);continue;}printf("recv msg %d: %s\n", fd, buff);send(fd, buff,strlen(buff),0);}}}}}close(listenfd);close(epfd);return0;}client客户端可以使用select实现。
4.3.5 非阻塞操作
之前所了解的读(read)操作都是阻塞的,即调用该函数时,如果硬件没有将数据拷贝到内核缓冲区当中时,线程会主动陷入阻塞。为此,操作系统为用户提供非阻塞版本的read:当读取内核缓冲区数据时,如果没有数据, read会直接返回-1,并将errno的数值设置为EAGAIN。非阻塞操作通常会配合循环一起使用以实现一种同步非阻塞的IO模型。
实现非阻塞有两种方式:
将某一个文件描述符设置为非阻塞,使用fcntl函数可以将已打开文件增加一个非阻塞选项:
intfcntl(int fd,int cmd,.../* arg */);//cmd F_GETFL 获取状态作为返回值//cmd F_SETFL arg STATUS 将文件状态设置为新状态示例:
int flags =fcntl(peerfd, F_GETFL,0);// 读取peerfd当前的文件状态标志 flags |= O_NONBLOCK;// 给标志添加 O_NONBLOCK 属性fcntl(peerfd, F_SETFL, flags);直接对read/recv函数的第四个参数进行设置MSG_DONTWAIT,只作用于这一次调用。
recv(fd, buff,sizeof(buff), MSG_DONTWAIT);send(fd, buff,strlen(buff), MSG_DONTWAIT);4.3.6 两种工作模式
4.3.6.1 水平触发(LT)
只要缓冲区有数据可读(或可写),epoll_wait就会一直就绪,直到数据读完(或写完)。
- 优点: 简单,不容易丢事件。即使只读了一部分数据,下次
epoll_wait依然会通知,直到缓冲区清空。 - 缺点:如果没有一次性处理完所有数据,可能会被重复通知,导致不必要的系统调用。
select/epoll默认情况下都是采用水平触发。
4.3.6.2 边缘触发(ET)
只有当文件描述符状态发生变化时(例如从不可读变为可读,或者有新数据到来),epoll_wait才会通知一次。如果不去接收数据,之后epoll不会再通知进行处理。
- 优点:效率更高。只通知一次,避免重复通知,减少系统调用次数,适合处理大量并发连接。
- 缺点:要求程序一次性处理完所有数据。如果只读了一部分,下次
epoll_wait不会再通知你,剩下的数据就会滞留在内核缓冲区,导致数据丢失或逻辑错误。
采用边缘触发之后,消息处理的时机变得不确定了,因此设置消息处理的条件。此时可以将recv函数的第四个参数设置为MSG_PEEK,只是去窥探一下内核接收缓冲区,并将数据拷贝到用户态缓冲区(当第四个参数flags值为0时,将内核接收缓冲区中的数据拷贝到了用户态,同时还移走了内核中的数据)。
epoll的ET模式几乎总是和非阻塞 I/O 结合使用。当epoll_wait在ET模式下通知某个文件描述符可读时,应该在一个循环中尽可能多地读取所有可用数据,直到read()返回EAGAIN或EWOULDBLOCK(表示当前没有更多数据可读了)。这样做是为了避免数据在内核缓冲区堆积,导致程序无法感知到未处理的数据。例如:
while(1){int nready =epoll_wait(epfd, pEventArr, CONNS_MAX,-1);if(nready ==-1&& errno == EINTR){continue;}elseif(nready ==0){printf("timeout!\n");}elseif(nready ==-1){break;}else{for(int i =0; i < nready;++i){int fd = pEventArr[i].data.fd;if(fd == listenfd){structsockaddr_in clientaddr;memset(&clientaddr,0,sizeof(clientaddr));socklen_t len =sizeof(clientaddr);int peerfd =accept(listenfd,(structsockaddr*)&clientaddr,&len);printf("%s:%d has connected, peerfd: %d.\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port), peerfd);int flag =fcntl(peerfd, F_GETFL,0); flag |= O_NONBLOCK;fcntl(peerfd, F_SETFL, flag);structepoll_event ev; ev.events = EPOLLIN | EPOLLET; ev.data.fd = peerfd;epoll_ctl(epfd, EPOLL_CTL_ADD, peerfd,&ev);}else{if(pEventArr[i].events == EPOLLIN){char buff[1024]={0}; ret =recv(fd, buff,sizeof(buff), MSG_PEEK);if(ret ==0){ ev.data.fd = fd; ret =epoll_ctl(epfd, EPOLL_CTL_DEL, fd,&ev);ERROR_CHECK(ret,-1,"epoll_ctl");close(fd);printf("byebye %d.\n", fd);continue;}printf("ret: %d\n", ret);printf("recv msg %d: %s\n", fd, buff);if(ret >15){// 当内核缓冲区数据长度大于15字节时才会进行处理消息的操作// 此处为移走内核中数据recv(fd, buff, ret,0);send(fd, buff,strlen(buff),0);}}}}}}4.3.7 epoll实现聊天室
server服务端:
#include<func.h>#defineCONNS_MAX1024int clientfds[CONNS_MAX]={0};intmain(int argc,char*argv[]){signal(SIGPIPE, SIG_IGN);ARGC_CHECK(argc,3);int ret =0;memset(clientfds,0,sizeof(clientfds));int listenfd =socket(AF_INET, SOCK_STREAM,0);ERROR_CHECK(listenfd,-1,"socket");printf("listenfd : %d.\n", listenfd);int on =1; ret =setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,&on,sizeof(int));ERROR_CHECK(ret,-1,"setsockopt");structsockaddr_in serveraddr;memset(&serveraddr,0,sizeof(serveraddr)); serveraddr.sin_family = AF_INET; serveraddr.sin_port =htons(atoi(argv[2])); serveraddr.sin_addr.s_addr =inet_addr(argv[1]); ret =bind(listenfd,(conststructsockaddr*)&serveraddr,sizeof(serveraddr));ERROR_CHECK(ret,-1,"bind"); ret =listen(listenfd,10);ERROR_CHECK(ret,-1,"listen");printf("server start listening!\n");int epfd =epoll_create1(0);ERROR_CHECK(epfd,-1,"epoll_create1");structepoll_event ev; ev.events = EPOLLIN; ev.data.fd = listenfd; ret =epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd,&ev);ERROR_CHECK(ret,-1,"epoll_ctl");structepoll_event*pEventArr =(structepoll_event*)calloc(CONNS_MAX,sizeof(structepoll_event));while(1){int nready =epoll_wait(epfd, pEventArr, CONNS_MAX,-1);if(nready ==-1&& errno == EINTR){continue;}elseif(nready ==0){printf("timeout!\n");}elseif(nready ==-1){break;}else{// 新连接到来for(int i =0; i < nready;++i){int fd = pEventArr[i].data.fd;if(fd == listenfd){structsockaddr_in clientaddr;memset(&clientaddr,0,sizeof(clientaddr));socklen_t len =sizeof(clientaddr);int peerfd =accept(listenfd,(structsockaddr*)&clientaddr,&len);printf("%s:%d has connected, peerfd: %d.\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port), peerfd);int flag =fcntl(peerfd, F_GETFL,0); flag |= O_NONBLOCK;fcntl(peerfd, F_SETFL, flag);structepoll_event ev; ev.events = EPOLLIN | EPOLLET; ev.data.fd = peerfd;epoll_ctl(epfd, EPOLL_CTL_ADD, peerfd,&ev);for(int i =0; i < CONNS_MAX;++i){if(clientfds[i]==0){ clientfds[i]= peerfd;break;}}char welcome_msg[1024]={0};snprintf(welcome_msg,sizeof(welcome_msg),"[system] %s:%d (fd: %d) joined the chat!\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port), peerfd);for(int i =0; i < CONNS_MAX;++i){if(clientfds[i]!=0&& clientfds[i]!= peerfd){send(clientfds[i], welcome_msg,strlen(welcome_msg),0);}}}else{if(pEventArr[i].events == EPOLLIN){char buff[1024]={0};ssize_t recv_len;while((recv_len =recv(fd, buff,sizeof(buff)-1,0))>0){// 正常处理客户端消息 buff[recv_len]='\0';printf("recv msg %d: %s\n", fd, buff);char send_buff[2048]={0};snprintf(send_buff,sizeof(send_buff),"[client %d]: %s", fd, buff);for(int i =0; i < CONNS_MAX;++i){if(clientfds[i]!=0&& clientfds[i]!= fd){send(clientfds[i], send_buff,strlen(send_buff),0);}}memset(buff,0,sizeof(buff));}if(recv_len ==0){// 客户端断开连接 ev.data.fd = fd; ret =epoll_ctl(epfd, EPOLL_CTL_DEL, fd,&ev);ERROR_CHECK(ret,-1,"epoll_ctl");char leave_msg[1024]={0};snprintf(leave_msg,sizeof(leave_msg),"[system] client(fd: %d) left the chat!\n", fd);for(int i =0; i < CONNS_MAX;++i){if(clientfds[i]!=0&& clientfds[i]!= fd){send(clientfds[i], leave_msg,strlen(leave_msg),0);}}for(int i =0; i < CONNS_MAX;++i){if(clientfds[i]== fd){ clientfds[i]=0;break;}}close(fd);printf("byebye %d.\n", fd);continue;}}}}}}close(listenfd);close(epfd);return0;}