G
N
I
D
A
O
L

网络编程(3)—— IO多路复用

IO多路复用

之前我们已经实现了服务端与客户端一对一连接并发送/接收数据的模型,那么如果需要与多台客户端进行通信该怎么办呢?

(1)启动多个服务端进程,仍然保持服务器与客户端的一对一关系 —— 显然不合理

(2)使用多线程,让服务端的每个子线程与一个客户端进行连接 —— 需要进行频繁的CPU上下文切换,开销太大

(3)使用IO多路复用 —— 可以单线程处理大量的客户端连接

IO 多路复用是一种同步 IO 模型,它允许一个进程(不需要多线程)同时监听多个套接字文件描述符的事件,从而实现高效的 IO 操作。

操作系统允许单个进程或线程同时监视多个文件描述符的一种技术。当其中的一个或多个文件状态变为非阻塞状态(例如: 文件由阻塞态, 变得可读、可写或有异常待处理)时,该进程或线程会收到一个对应通知。而我们的逻辑中收到这个通知, 就可以根据对应变得可读的文件描述符处理是读取标准输入, 还是接收对端数据, 还是都处理。

这样, 就允许程序同时处理多个文件就绪, 或者称为谁就绪就处理谁,而不是只能按照固定处理顺序处理每一个任务,从而提高效率。

原理:

  • 应用程序通过系统调用(如 select、poll、epoll 等)向内核注册需要监听的文件描述符以及感兴趣的事件(如读事件、写事件)。
  • 内核会不断地检查这些文件描述符的状态,当有事件发生时,内核会通知应用程序。
  • 应用程序接收到通知后,就可以对发生事件的文件描述符进行相应的 IO 操作。

原理图

思想:原本所有的监听与访问请求都由服务器操作,使用IO多路复用的手段后,不再由服务器自己监视客户端的连接,取而代之的是由内核来监视。

在这里插入图片描述

可使用的有三种方法:select、poll、epoll

这三种方式诞生的过程实际是一种演化,select最先诞生,但其监听能力有上限(1024),poll能够突破这个上限,但本质原理与select没有区别。

epoll方法是最后提出的方法,理论上监听的文件描述符的个数是没有上限的(当然实际上受到硬件的限制,真实使用中监听十几万个没问题)

select的使用

select的最基本的原理, 就是把要监视的文件描述符, 构建一个文件描述符监听集合; 这个集合交给select, select促使操作系统内核, 通过轮询的方式监听这个文件描述符集合。直到监听集合中, 至少有一个文件按照条件就绪(条件:预设的监听是读就绪OR写就绪...), 这一次的select监听宣告结束, 并携带就绪的文件描述符集合返回, 继续执行用户书写的代码逻辑。

虽然应用进程需要遍历文件描述符集合来确定哪些就绪。但这个 “遍历” 是在 内核通知就绪之后 进行的,属于 被动检查,而非应用进程主动轮询硬件设备。

函数接口

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
           struct timeval * timeout);

select函数参数

  • nfds:监控的文件描述符集里最大文件描述符+1,因为此参数会告诉内核检测 前多少个文件描述符的状态(文件描述符0/1/2/3...)

  • fd_set:本质是个位图。能够监听的文件描述符的个数上限是1024,如果全都用int保存,需要4096个字节的空间。实际上只需要用一位就可以保存关键信息,0代表不监听,1代表监听,这样以来只需要1024位,也就是128个字节的空间。

​ 第二参数实际是指向位图的指针,对某个文件描述符进行监听,只需要将相应的位设为1就可以了。

​ readfs/writes/exceptfds:监控有读数据/写数据/异常发生到达文件描述符集合,三个都是传入传出参数。

平时大多数情况下没有监听写事件或者异常事件,所以第三、四个参数一般直接传了空指针。

  • timeout:定时阻塞监控时间

​ 3种情况:
​ 1、NULL,永远等下去,一直阻塞直到所监听的文件描述符集合中有一个或多个文件描述符发生相应事件
​ 2、传入非零的struct timeval结构体指针,等待固定时间
​ 3、传入struct timeval结构体指针,但设置timeval里时间均为0,select函数不会阻塞,而是立即返回,一般放在循环中实现轮询。

struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};

通过这个监听集合,我们就可以实现对多个socket的同时监听

当监听集合完成一次遍历,发现有套接字处于就绪状态,也就是某些套接字的缓冲区中有数据需要处理时,就会返回一个就绪码(处于就绪状态的套接字数量),并通过传入传出参数传出一个就绪集合(就绪的套接字的位码置1,未就绪的套接字的位码置0)

select函数返回值
成功:所监听的所有的监听集合中,满足条件的总数 —— 就绪的文件描述符个数
失败:返回-1.

如下,监听到有三个事件就绪,select函数返回值就是3,但具体是读事件还是写事件,还需要后续判断

注:

位图可以通过下标访问,为什么不直接进行随机访问呢?

select 使用的位图(fd_set)虽然可以通过下标访问,但它本身没有记录哪些文件描述符处于就绪状态的额外信息。当 select 函数返回后,操作系统只是修改了位图中对应就绪文件描述符的位为 1,但没有直接标记出具体哪些位发生了变化,也就是不知道就绪的下标。所以只能通过遍历位图,用 FD_ISSET 宏函数逐个检查文件描述符对应的位是否为 1 ,才能确定哪些文件描述符就绪。

还有几个相关的宏

void FD_ZERO(fd_set *set);//将位图中所有的位清空为0
void FD_SET(int fd, fd_set *set);//将文件描述符fd设置到位图中
void FD_CLR(int fd, fd_set *set);//将文件描述符fd从位图中清除
int FD_ISSET(int fd, fd_set *set);//判断fd是否在位图中,如果在说明就绪了,否则说明没有就绪

INADDR_ANY 是一个特殊的 IPv4 地址表示,值为 0.0.0.0 ,代表不确定地址

泛指本机的意思,表示本机的所有IP,因为有些电脑不止一块网卡,如果某个应用程序只监听某个端口,那么其他端口过来的数据就接收不了

代码实现

/* ************************************************************************
> File Name:     sel_server.c
> Author:        hyj
> mail:          
> Created Time:  Fri 17 Jul 2026 02:24:13 PM CST
> Description:	 服务器代码,客户端可用之前的,或者用命令行
				 nc 127.0.0.1 8080
 ************************************************************************/
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<netinet/ip.h>
#include<string.h>
#include<stdlib.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<sys/select.h>

#define PORT 8080
#define BUFFERSIZE 128
#define CLIENT_MAX_NUM 1024
#define handle_error(msg)\
    do{perror(msg); exit(EXIT_FAILURE);}while(0);

int main(){

    // 客户端数量
    int i;
    // 临时变量
    int sockfd;

    // 创建socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(listenfd == -1){
        handle_error("Listenfd Failed!\n");
    }
    // 声明客户端socket
    int clientfd;
    // 创建数据缓冲区, 并初始化
    char buffer[BUFFERSIZE];
    memset(buffer, 0, BUFFERSIZE);
    // 声明接收数据的字符数量
    int read_cnt = 1;
    // 创建接收客户端ID的缓冲区
    char client_ip[INET_ADDRSTRLEN];


    // 地址复用
    int opt1 = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt1, sizeof(opt1));

    // 端口复用
    int opt2 = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, &opt2, sizeof(opt2));

    // 设置服务器地址
    struct sockaddr_in seraddr;
    memset(&seraddr, 0, sizeof(seraddr));
    seraddr.sin_family = AF_INET;           // 协议族
    seraddr.sin_port = htons(PORT);         // 端口号
    seraddr.sin_addr.s_addr = INADDR_ANY;   // 监听所有窗口0.0.0.0(填一个0也是一样的)

    // 声明客户端地址信息结构体并初始化
    struct sockaddr_in cliaddr;
    memset(&cliaddr, 0, sizeof(cliaddr));
    //  定义soket长度大小
    socklen_t ser_and_cli = sizeof(cliaddr);

    // bind
    if(bind(listenfd, (struct sockaddr *)&seraddr, sizeof(seraddr)) == -1){
        handle_error("Bind Failed");
    }

    printf("Socket has Created <fd:%d> Waiting Client Connect\n", listenfd);

    // 监听
    if(listen(listenfd, CLIENT_MAX_NUM) == -1){
        handle_error("Listen Error");
    }

    // select类型IO多路复用
    // 创建客户端数组的位图
    int client[FD_SETSIZE];     // 辅助数组,用来节省遍历的开销
    int maxfd = listenfd;       // 当前被监听的描述符最大值
    int maxi = -1;              // 数组中已使用的最大索引
    // 位图
    fd_set allset;        // 监听集合
    fd_set rset;          // 满足条件集合

    for(int i = 0; i< FD_SETSIZE; ++i){
        client[i] = -1;
    }

    // 清空allset
    FD_ZERO(&allset);
    // 讲listenfd放在allset中进行监听
    FD_SET(listenfd, &allset);
    // 监听就绪的文件描述符
    int nready;
    while(1){

        rset = allset;
#if 0
        // 查看一下当前客户端连接情况
        printf("----------------------------------\n");
        for(int k = 0; k <= maxi; k++){
            printf("%d ", client[k]);
        }
        printf("maxfd = %d\n------------------------------\n", maxfd);
#endif
        
        // 使用select进行阻塞式监听,返回值大于0代表有满足
        nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
        if(nready < 0){
            handle_error("Select Error");
        }


        // 如果监听到listenfd, 则添加一个客户端
        if(FD_ISSET(listenfd, &rset)){
            // 获取新客户端的文件描述符
            clientfd = accept(listenfd, (struct sockaddr *)&cliaddr, &ser_and_cli);
            if(clientfd == -1){
                handle_error("Accept Error");
            }
            printf("Join new client %s:%d, fd = %d\n",
                    inet_ntop(AF_INET, &cliaddr.sin_addr, client_ip, sizeof(client_ip)),
                    ntohs(cliaddr.sin_port), clientfd);

            // 将新的连接添加到位图中
            for(i = 0; i < FD_SETSIZE; ++i){
                if(client[i] < 0){
                    client[i] = clientfd;
                    break;
                }

            }
            // i超过了监听上限
            if(i == FD_SETSIZE){
                handle_error("Too Many Clients");
            }

            // 将连接的客户端放入allset进行管理
            FD_SET(clientfd, &allset);

            // 更新maxfd
            maxfd = (clientfd > maxfd ? clientfd : maxfd);

            // 更新maxi
            maxi = (i > maxi ? i : maxi);
            // 处理完走出循环
            if(nready-- == 0){
                continue;
            }
        }


        // 处理已经连接的客户端数据收发
        for(i = 0; i <= maxi; ++i){
            // 全部处理完毕则退出
            if((sockfd = client[i]) < 0){
                continue;
            }

            // 有数据传输需求
            if(FD_ISSET(sockfd, &rset)){
                // 断开连接
                if((read_cnt = read(sockfd, buffer, BUFFERSIZE)) == 0){
                    // 关闭文件描述符
                    close(sockfd);
                    printf("client[%d] closer connection\n", sockfd);
                    // 更新位图
                    FD_CLR(sockfd, &allset);
                    client[i] = -1;


                    // 重新计算maxfd
                    if(sockfd == maxfd){
                        maxfd = listenfd;
                        for(int j = 0; j <= maxi; ++j){
                            if(client[j] > maxfd){
                            maxfd = client[j];
                            }
                        }
                    }
                }
                else if(read_cnt > 0){
                    buffer[read_cnt] = '\0';
                    printf("Receive from client[%d]: %s\n", sockfd, buffer);
                    //write(sockfd, buffer, read_cnt);
                    memset(buffer, 0, BUFFERSIZE);
                }

                if(nready-- == 0){
                    break;
                }

            }

        }

    }

      return 0;
}

优缺点

  • 文件描述符上限(1024),同时监听的文件描述符1024个,历史原因,不好修改,除非重新编译Linux内核
  • 当监听的文件描述符个数比较稀疏的时候(比如3, 600, 1023),循环判断比较麻烦
  • 监听集合与满足监听条件的集合是同一个,需要将原有集合保存

poll的使用

函数接口

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd
{
    int fd; /* file descriptor */
    short events; /* requested events */ //POLLIN/POLLOUT/POLLERR
    short revents; /* returned events */ //返回结果
};

fds:文件描述符数组。

events:POLLIN/POLLOUT/POLLERR 对应读事件、写事件、异常事件

nfds:监控数组中有多少文件描述符需要被监控。

timeout 毫秒级等待

​ -1:阻塞等,#define INFTIM -1 Linux中没有定义此宏

​ 0:立即返回,不阻塞进程

​ >0:等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值。

函数返回值:满足监听条件的文件描述符的数目。

代码实现

/* ************************************************************************
> File Name:     poll_server.c
> Author:        hyj
> mail:          
> Created Time:  Sat 18 Jul 2026 02:01:50 PM CST
> Description:
 ************************************************************************/
#include<stdio.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<netinet/ip.h>
#include<stdlib.h>
#include<string.h>
#include<poll.h>
#include<signal.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<errno.h>
#define PORT 8080
#define BUFFERSIZE 128
#define LISTEN_MAX_NUM 1024


#define handle_error(msg){\
    do{perror(msg); exit(EXIT_FAILURE);} while(0);}

int main(){

    // 创建socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(listenfd < 0){
        handle_error("Socket Error");
    }


    // 准备监听地址
    struct sockaddr_in ser_addr;
    memset(&ser_addr, 0, sizeof(ser_addr));
    ser_addr.sin_family = AF_INET;
    ser_addr.sin_port = htons(PORT);
    ser_addr.sin_addr.s_addr = INADDR_ANY;

    // 创建客户端地址结构体
    struct sockaddr_in cli_addr;
    memset(&cli_addr, 0 , sizeof(cli_addr));
    // 结构体长度
    socklen_t ser_and_cli = sizeof(cli_addr);


    // 地址复用
    int opt1 = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt1, sizeof(opt1));
    // 端口复用
    int opt2 = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, &opt2, sizeof(opt2));

    // bind
    if(bind(listenfd, (struct sockaddr *)&ser_addr, sizeof(ser_addr)) < 0){
        handle_error("Bind Error");
    }

    // listen
    if(listen(listenfd, LISTEN_MAX_NUM) < 0){
        handle_error("Listen Error");
    }

    // 创建客户端数组
    struct pollfd client[LISTEN_MAX_NUM];

    // 放入listenfd进行监听
    client[0].fd = listenfd;
    client[0].events = POLLIN;  // 读事件

    // 声明索引,最大访问索引,最大文件描述符
    int idx, maxi, maxfd;
    // 初始化client数组
    for(idx = 1; idx < LISTEN_MAX_NUM; ++idx){
        client[idx].fd = -1;
    }

    maxi = 0;

    // 创建数据缓冲区
    char buffer[BUFFERSIZE];
    memset(buffer, 0, BUFFERSIZE);
    // 创建就绪事件数量
    int nready;
    // 创建客户端文件描述符
    int clientfd;
    // 创建一个缓冲区接收客户机IP
    char client_ip[INET_ADDRSTRLEN];

    int read_cnt;
    printf("Waiting Clients Connect...\n");

    while(1){
        // 使用poll监听
        nready = poll(client, maxi + 1, -1);

        if(nready < 0){
            handle_error("Poll Error");
        }
        // 监听到listenfd,则代表有新的客户端连接
        if(client[0].revents & POLLIN){
            clientfd = accept(listenfd, (struct sockaddr *)&cli_addr, &ser_and_cli);
            if(clientfd == -1){
                handle_error("Accept Error");
            }
            printf("client <%s:%d> connect, fd = %d\n",
                    inet_ntop(AF_INET,&cli_addr.sin_addr,client_ip,INET_ADDRSTRLEN),
                    ntohs(cli_addr.sin_port),
                    clientfd);


            // 将与新客户端的连接的文件描述符加入数组
            for(idx = 1; idx < LISTEN_MAX_NUM; ++idx){
                if(client[idx].fd < 0){
                    client[idx].fd = clientfd;
                    client[idx].events = POLLIN;
                    break;
                }
            }
            // 连接客户端数量超过上线
            if(idx == LISTEN_MAX_NUM){
                handle_error("override LISTEN_MAX_NUM");
            }
            // 更新最大索引值
            maxi = idx > maxi ? idx :maxi;

            if(--nready == 0){
                continue;
            }
        }

        // 监听到连接的客户端,与客户端进行数据交互
        for(idx = 1; idx <= maxi; ++idx){
            // 跳过已经关闭的连接
            if((clientfd = client[idx].fd) < 0){
                continue;
            }
            if(client[idx].revents & POLLIN){
                // 处理接收的数据
                read_cnt = read(clientfd, buffer, BUFFERSIZE - 1);
                // 断开连接
                if(read_cnt == 0){
                    printf("<fd:%d> closed connection\n", clientfd);
                    close(clientfd);
                    client[idx].fd = -1;
                }
                // 接收到数据
                else if(read_cnt > 0){
                    //buffer[read_cnt] = '\0';
                    printf("receive from<fd:%d>: %s \n", clientfd, buffer);
                }
                // 读错误
                else if(read_cnt < 0){
                    //handle_error("Read Error");
                    // 可恢复错误
                    if(errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK){
                        continue;
                    }
                    // 不可恢复错误
                    else{
                        perror("read error");
                        close(clientfd);
                        client[idx].fd = -1;
                    }
                }

                if(--nready == 0){
                    break;
                }
            }
        }
    }
   return 0;
}

优缺点

优缺点:

  • 突破监听文件描述符1024的上限(结构体数组用来存放监听的文件描述符)
  • 监听与返回的集合分离(events和revents分离)
  • 搜索范围变小
  • 监听1000个文件描述符,但是只有3个满足条件,这样也需要全部遍历,效率依旧低。

epoll的使用

是Linux下IO多路复用接口select/poll的增强版本,能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不是迫使开发者每次等待事件之前都必须重新准备要侦听的文件描述符集合,另一个原因是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历哪些被内核IO事件唤醒而加入Ready队列的描述符集合就行了。

函数接口

epoll_create 函数

创建一个 epoll 实例,返回一个文件描述符,后续的 epoll_ctl 和 epoll_wait 函数将使用这个文件描述符来操作和等待事件。

参数:
size:在早期的 epoll 实现中,该参数用于告知内核要监听的文件描述符的大致数量,帮助内核提前分配所需的内存。但从 Linux 2.6.8 开始,这个参数被忽略了,不过为了兼容性,仍需传入一个大于 0 的值。

返回值:
成功时,返回一个非负的 epoll 实例的文件描述符。失败时,返回 -1,并设置 errno 来指示错误类型。

#include <sys/epoll.h>
int epoll_create(int size); //随便传一个大于0的值
//size:参数size用来告知内核监听的文件描述符的个数,与内存大小有关。
//返回值:文件描述符

epoll_ctl 函数
对 epoll 实例所监听的文件描述符进行控制操作,包括注册、修改和删除监听的事件。

参数:

  • epfd:epoll_create 函数返回的 epoll 实例的文件描述符,用于指定要操作的 epoll 实例。
  • op:指定要执行的操作类型,有以下三种取值:
    EPOLL_CTL_ADD:将指定的文件描述符 fd 添加到 epoll 实例中,并监听其指定的事件。
    EPOLL_CTL_MOD:修改已经添加到 epoll 实例中的文件描述符 fd 所监听的事件。
    EPOLL_CTL_DEL:将指定的文件描述符 fd 从 epoll 实例中删除,不再监听其事件。
  • fd:要操作的文件描述符,通常是一个套接字描述符或其他可监听的文件描述符。
  • event:指向 struct epoll_event 结构体的指针,用于指定要监听的事件类型和相关的数据。

在内核中,每个 fd对应的驱动对象会注册一个 就绪回调函数

回调函数的作用:当 fd 状态变化(如 socket 接收缓冲区有数据),驱动会触发该回调函数,通知 epoll 内核模块。

返回值:
成功时,返回 0。失败时,返回 -1,并设置 errno 来指示错误类型。

//控制某个epoll监控的文件描述符上的事件:注册、修改、删除
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//epfd:epoll_create函数返回值
//op:EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL  添加/修改/删除文件描述符  背后是红黑树
//fd:将哪个文件描述符以op的方式加在以epfd建立的树上
//event:告诉内核需要监听的事情。
struct epoll_event
{
    uint32_t events; //EPOLLIN/EPOLLOUT/EPOLLERR
	epoll_data_t data;
};

typedef union epoll_data
{
	void *ptr;//存储任意类型指针,通常是自定义结构体指针(通过自定义结构体保存一些连接状态、用户数据等,如果事件处理需要时才进行设置)
	int fd;//存储对应的文件描述符,方便在事件处理时直接使用 —— 对应epoll_ctl函数的第三个参数
	uint32_t u32;//存储事件类型等
	uint64_t u64;
} epoll_data_t;

epoll_wait 函数
等待 epoll 实例中所监听的文件描述符上有事件发生。当有事件发生时,将这些事件信息复制到 events 数组中。

参数

  • epfd:epoll_create 函数返回的 epoll 实例的文件描述符,用于指定要等待事件的 epoll 实例。
  • events:指向 struct epoll_event 结构体的指针,用于存储发生事件的文件描述符的相关信息。相当于将就绪的文件描述符构成一个就绪列表。
  • maxevents:指定 events 数组的最大元素个数,即最多能返回多少个事件。可以将就绪的文件描述符个数直接传入。
  • timeout:指定等待事件发生的超时时间,单位为毫秒。有以下几种取值情况:
    -1:表示无限等待,直到有事件发生才返回。
    0:表示立即返回,无论是否有事件发生。
    大于 0 的值:表示等待指定的毫秒数,如果在这段时间内有事件发生则返回,否则超时返回。

返回值
成功时,返回发生事件的文件描述符的数量;超时返回时,返回 0;失败时,返回 -1,并设置 errno 来指示错误类型。

//等待所监控文件描述符上有事件的产生
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
//events:用来存内核得到事件的集合(这里是个传出参数)
//maxevents:告知内核这个events有多大
//timeout:是超时时间
	//-1:阻塞
	//=0:立即返回,非阻塞
	//>0:指定毫秒
//返回值:成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1

原理总结:

用户通过 epoll_ctl 注册 FD 时:

  • 内核将 FD 添加到红黑树中,并为其关联一个 事件结构体(epitem),记录关注的事件类型。
  • 在内核中,每个 FD 对应的驱动对象会注册一个 就绪回调函数
  • 回调函数的作用:当 FD 状态变化(如 socket 接收缓冲区有数据),驱动会触发该回调函数,通知 epoll 内核模块。

当用户调用 epoll_wait 时,内核进入阻塞状态:

  • 对于每个注册的 FD,其对应的内核驱动(如网络设备驱动)会在 数据就绪时自动触发回调函数

  • 回调函数执行逻辑

    (1)检查该 FD 的事件是否满足注册时的条件(如可读事件是否发生)。

    (2)若满足,将该 FD 及其事件类型添加到 就绪列表 中,并唤醒 epoll_wait 调用。

核心特点

  • 内核无需主动遍历红黑树,而是通过驱动的回调函数被动接收就绪事件,并将其存入就绪列表。
  • epoll_wait 的时间复杂度仅与就绪事件的数量 (M) 相关((O(M))),而非总 FD 数 (N)。

代码实现

/* ************************************************************************
> File Name:     epoll_server.c
> Author:        hyj
> mail:          
> Created Time:  Sat 18 Jul 2026 04:06:05 PM CST
> Description:
 ************************************************************************/
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<netinet/ip.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
#include<unistd.h>
#define BUFFERSIZE 128
#define PORT 8080
#define LISTEN_MAX_NUM 1000

#define handle_error(msg)\
    do{perror(msg);exit(EXIT_FAILURE);} while(0)


int main(){
    // 创建套接字
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(listenfd < 0){
        handle_error("Socket Error");
    }

    // 创建IP地址
    struct sockaddr_in ser_addr;
    memset(&ser_addr, 0, sizeof(ser_addr));
    ser_addr.sin_family = AF_INET;
    ser_addr.sin_port = htons(PORT);
    ser_addr.sin_addr.s_addr = INADDR_ANY;

    // 地址复用
    int opt1 = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt1, sizeof(opt1));
    // 端口复用
    int opt2 = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, &opt2, sizeof(opt2));

    // bind
    if(bind(listenfd, (struct sockaddr *)&ser_addr, sizeof(ser_addr)) == -1){
        handle_error("Bind Error");
    }

    // listen
    if(listen(listenfd, LISTEN_MAX_NUM) == -1){
        handle_error("Listen Error");
    }

    // epoll: 使用红黑树和就绪列表
    int epfd = epoll_create(LISTEN_MAX_NUM);
    if(epfd == -1){
        handle_error("Epoll Create Error");
    }

    // 创建epoll事件的结构体
    struct epoll_event evt, ep[LISTEN_MAX_NUM];
    // 对evt赋值,用来放在红黑树上监听
    evt.events = EPOLLIN;
    evt.data.fd = listenfd;
    /* evt.data.ptr = ; */

    // 将文件描述符放到红黑树上监听
    if(epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &evt) == -1){
        handle_error("epoll_ctl error");
    }

    // 创建数据缓冲区
    char buffer[BUFFERSIZE];
    memset(buffer, 0 , BUFFERSIZE);
    // 就绪客户端数量
    int nready;

    int idx, clientfd;
    socklen_t cli_and_ser;
    struct sockaddr_in client_addr;
    memset(&client_addr, 0, sizeof(client_addr));

    char client_ip[INET_ADDRSTRLEN];

    int read_cnt;

    printf("Waiting clients connect...\n");

    while(1){
        // 阻塞监听
        nready = epoll_wait(epfd, ep, LISTEN_MAX_NUM, -1);
        if(nready < 0){
            handle_error("Epoll_wait Error");
        }

        // 遍历监听事件
        for(idx = 0; idx < nready; ++idx){
            // 异常处理
            if(!(ep[idx].events & EPOLLIN)){
                continue;
            }
            // 如果监听到listenfd,则代表有新客户端连接
            if(ep[idx].data.fd == listenfd){
                cli_and_ser = sizeof(client_addr);

                clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &cli_and_ser);
                if(clientfd == -1){
                    handle_error("Accept Error");
                }
                printf("new client <%s:%d> connect, fd = %d\n",
                        inet_ntop(AF_INET, &client_addr.sin_addr,client_ip,INET_ADDRSTRLEN),
                        ntohs(client_addr.sin_port), clientfd);

                // 将客户端文件描述符放到红黑树上监听
                evt.events = EPOLLIN;
                evt.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &evt);
            }

            // 不是listenfd,则是其它客户端发送数据
            else{
                // 获取响应的文件描述符
                clientfd = ep[idx].data.fd;
                read_cnt = read(clientfd, buffer, BUFFERSIZE);
                // 客户端关闭连接
                if(read_cnt == 0){
                    printf("<fd:%d> closed connection\n", clientfd);
                    if(epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, NULL) == -1){
                        handle_error("epoll_ctl() error");
                    }
                    close(clientfd);
                }
                // 出现读错误
                else if(read_cnt < 0){
                    if(epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, NULL) == -1){
                        perror("read error and epoll_ctl error");
                    }
                    close(clientfd);
                }
                // 正常读写数据
                else{
                    printf("receive from <fd:%d> %s\n", clientfd, buffer);
                    memset(buffer, 0, BUFFERSIZE);
                }

            }
        }
    }return 0;
}

优缺点

  • 文件描述符数目没有上限:通过epoll_ctl()来注册一个文件描述符,内核中使用红黑树的数据结构来管理所有需要监控的文件描述符。
  • 基于事件就绪通知方式:一旦被监听的某个文件描述符就绪,内核会采用类似于callback的回调机制,迅速激活这个文件描述符,这样随着文件描述符数量的增加,也不会影响判定就绪的性能。
  • 维护就绪队列:当文件描述符就绪,就会被放到内核中的一个就绪队列中,这样调用epoll_wait获取就绪文件描述符的时候,只要取队列中的元素即可,操作的时间复杂度恒为O(1)。

图解

在这里插入图片描述

类型区别

水平触发(level-trggered)

只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,epoll默认的模式是LT。

边缘触发(edge-triggered)

当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知。执行一次读操作后可能并没有将所有内容都读完,但是不会再发出可读信号。剩余数据就会产生滞留。边缘触发的设计哲学就是要求应用程序接到通知后一直读,直到 read/recv 返回 EAGAIN(现在没有数据可读,请稍后再试),表明缓冲区已空,才结束(一般还会将对应的文件描述符设为非阻塞模式)。

这样下一次的有新的数据到来时,就又可以使读缓冲区由空转为非空,发出提醒。epoll的每一次提醒都是一次系统调用,边缘触发减少了系统调用的次数,能够提高程序的性能。而水平触发的效果更安全、更稳定,所以绝大多数场景下还是使用水平触发。

设置边缘触发的方式

struct epoll_event ev;
ev.events = EPOLLIN;          // 水平触发(LT),默认就是 LT
// 或者
ev.events = EPOLLIN | EPOLLET; // 边缘触发(ET)
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
posted @ 2026-07-18 17:19  星夜夏空  阅读(12)  评论(0)    收藏  举报
公众号二维码
微信公众号