select、poll、epoll的IO多路复用与reactor事件驱动方案

select、poll、epoll的IO多路复用与reactor事件驱动方案

简介:本文介绍了常用的三种IO复用的常用方案,以及基于epoll事件驱动的reactor架构。

引言

​ 在C/S架构中,客户端向服务器发送请求,服务器对请求解析处理后向客户端发送回应。在少量请求的场景下,采用一请求一线程方案,即对于每一个请求都开辟一个线程用于处理。但随着计算机网络的发展,数以百万计的连接往往会在短时间内发生,此时无限制地开辟线程只会造成资源的大量浪费且运行效率低下。此时引入了IO复用技术,通过管理IO实现同时处理百万并发的连接。目前常用的有selectpollepoll,依次会在下文中介绍。最后基于IO复用的技术上,采用了reactor架构,以事件驱动,做到一条IO可以处理多类事件。

技术细节与代码

socket操作

​ socket在网络通信中扮演了核心的作用,作为应用程序与网络协议栈之间的编程接口,扮演着在不同主机不同进程之间通信的桥梁。

​ 在C语言中,通过文件描述符操作socket套接字。

	int sockfd = socket(AF_INET,SOCK_STREAM,0);

​ 初始化socket,指定协议族和传输方式,此处参数表示在IPv4的基础下采用TCP传输协议。

​ 进一步需要将IP地址和端口等与该套接字进行绑定用于通信。初始化地址结构体:

	struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(2000);//>1023

​ 作为服务器,设定为监听所有的IP地址,并通过端口号2000来访问该服务器。

​ 将地址与套接字进行绑定,并设置为监听状态。

	bind(sockfd,(struct sockaddr*)&servaddr,sizeof(struct sockaddr)))
    listen(sockfd,10);

单线程方案

​ 在一个主线程中处理连接。当服务器端在监听状态时,使用accept()接收客户端的一个请求,得到该请求的一个文件描述符。

	int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len);

​ 文件描述符是一个int类型的值,由系统统一分配,作为内核向应用程序访问资源的抽象手段。默认0---2的fd值被如下文件使用,在这之后的fd都从3开始。当某个fd使用完成在一定时间后可被回收并再次利用。

​ 从accept中获取到一个客户端连接后,recv去取到客户端发来的数据,转而将数据发送回给客户端。

	char buffer[1024] = {0};
    int count = recv(clientfd,buffer,1024,0);
	count = send(clientfd,buffer,count,0);

​ 因为只调用了一次accept方法,该方案只能处理一次来自客户端的连接。

	while(1){
       int clientfd = accept(sockfd,(structsockaddr*)&clientaddr,&len);
       char buffer[1024] = {0};
       int count = recv(clientfd,buffer,1024,0);
       count = send(clientfd,buffer,count,0);
    }

​ 在上述代码下,只有当第一个发起连接的客户端接收到数据后,后续的客户端才能接陆续接收到数据。因为这段代码仅在一个线程中运行,只有当前行的代码执行完成之后才能运行下一步。所以,当有多个用户连接服务器时,所有的客户端都会阻塞在recv()方法处,只有第一个客户端发送数据并接收到响应后其余的客户端才会同步运行。

一请求一线程

​ 与上述方案类似,但在取到客户端文件描述符后,开辟一个线程去处理该请求。

	while(1){
      int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len);
      pthread_t thid;
      pthread_create(&thid,NULL,client_thread,&clientfd);
    }

​ 在回调函数中,处理客户端的请求并响应。

void* client_thread(void* arg){   
    int clientfd = *(int *)arg;   
    while(1){
        char buffer[1024] = {0};
        int count = recv(clientfd,buffer,1024,0);      
        count = send(clientfd,buffer,count,0);
    }
}

​ 一请求一线程在高并发场景下并不能实现功能。

select

​ 将所有的文件描述符使用一个集合统一管理。

	fd_set  rfds,rest;
	FD_ZERO(&rfds);//集合置0
	FD_SET(sockfd,&rfds);//sockfd放入rfds中

​ 每一次调用select时,都会依次遍历fd_set,maxfd值就是用于控制遍历的最大范围。

	int maxfd = sockfd;

​ 在一个主循环中不断调用select监听是否有可读事件。

	int nready = select(maxfd+1,&rset,NULL,NULL,NULL);//最大遍历数 可读 可写 出错 超时时间

​ 使用宏定义判断是否有就绪可读事件:

	if(FD_ISSET(sockfd,&rset)){//判断是否是有数据可读

      int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len);
            
       FD_SET(clientfd,&rfds);

       if(clientfd > maxfd) maxfd = clientfd;
     }

​ 接收到就绪事件时,只有当分配的clientfd超过maxfd才更新该值。将所有的连接放入集合中之后,依次遍历处理。

	int i = 0;
	for(i = sockfd+1;i <= maxfd;i++){//此处i就变成了需要处理的fd
            
            if(FD_ISSET(i,&rset)){
                char buffer[1024] = {0};
                int count = recv(i,buffer,1024,0);
                if(count == 0){
                    printf("client disconnect: %d\n",i);
                    close(i);
                    FD_CLR(i,&rfds);
                    continue;;
                }

                count = send(i,buffer,count,0);
            }  
        }

poll

​ poll将相关属性使用结构体封装。

	struct pollfd {
    	int   fd;         /* 文件描述符 */
    	short events;     /* 请求的事件(监视的事件) */
    	short revents;    /* 返回的事件(实际发生的事件) */
	};

​ 使用一个struct pollfd结构体数组,用于存放所有待处理的fd。使用sockfd作为其在fds集合中的索引值,关注可读事件。

	struct pollfd fds[1024] = {0};
    fds[sockfd].fd = sockfd;
    fds[sockfd].events = POLLIN;
    int maxfd = sockfd;

​ 与select相同,调用poll遍历所有可读事件并返回。

​ 处理客户端的请求IO,使用accpet方法。

	int nready = poll(fds,maxfd+1,-1);//返回就绪事件的数量
	int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len);
         
    fds[clientfd].fd = clientfd;
    fds[clientfd].events = POLLIN;

    if(clientfd > maxfd)  maxfd = clientfd;//maxfd的更新原则

​ 除了客户请求IO其余都是读写IO,recv()接收数据send()向客户端返回数据。

	int i = 0;
    for(i = sockfd+1;i < maxfd;i++){

        if(fds[i].revents & POLLIN){//当前就绪事件可读

        char buffer[1024] = {0};

        int count = recv(i,buffer,1024,0);
        if(count == 0){//连接断开
             printf("client disconnect: %d\n",i);
             close(i);

             fds[i].fd = -1;
             fds[i].events = 0;
             continue;
          }
          printf("RECV: %s\n",buffer);

          count = send(i,buffer,count,0);
          printf("SEND: %d\n",count);
         }
      }

epoll

​ 在poll的基础上,epoll同样是将所有的事件放入集合中统一处理,不同的是epoll不再去遍历所有的事件,而是只关注活跃的事件,节省了遍历事件所要耗费的大量事件。

​ 同样epoll对事件进行了一层结构体封装:

	struct epoll_event {
    	uint32_t     events;  
    	epoll_data_t data;    // 用户数据(通常存储 fd 或指针)
	};

​ events作为监视事件的状态,往往有EPOLLIN、EPOLLOUT等。epoll_data_t用于存储用户数据,往往是文件描述符。

​ epoll_create()创建一个epoll实例取到其对应的文件描述符。使用该文件描述符进行进一步操作。epoll_ctl()用于向epoll实例添加/修改/删除需要监视的文件描述符。

	int epfd = epoll_create(1);

    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = sockfd;
    epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev);//将sockfd交给epoll管理。

​ 创建epoll_event集合存放所有的待处理事件。epoll_wait()方法等待事件就绪,代码会阻塞在这一行直到有事件发生或等待超时,该方法的第四个参数用于控制超时等待时间,-1表示无限阻塞直到事件发生为止。该方法返回就绪事件的数量。

	struct epoll_event events[1024] = {0};
    int nready = epoll_wait(epfd,events,1024,-1);

​ 取到所有就绪事件的数量,循环处理所有的已就绪事件。在本场景下,所有的就绪事件被分类为sockfd和其余的可读fd。对于socket事件,调用accept接收到来自客户端的请求,将客户端请求同样交付给epoll管理。

	if(connfd == sockfd){
        int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len);
        ev.events = EPOLLIN;
        ev.data.fd = clientfd;
        epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev);
     }

​ 对于其余的事件,接收数据并向客户端返回。

	else if(events[i].events & EPOLLIN){

        	char buffer[1024] = {0};

        	int count = recv(connfd,buffer,1024,0);
        	if(count == 0){
        	printf("client disconnect: %d\n",connfd);
        	close(connfd);
        	epoll_ctl(epfd,EPOLL_CTL_DEL,connfd,&ev);
        	continue;
        }
        printf("RECV: %s\n",buffer);

        count = send(connfd,buffer,count,0);
        printf("SEND: %d\n",count);
     }

​ 综上介绍了selectpollepoll如何在并发场景下管理事件。但因为他们在底层的不同原理,往往仅使用于某些场景。

​ select限制了可管理的fd数量,并且只支持水平触发,所以往往在较低并发的场景下使用。epoll相较于poll取缔遍历事件,而是使用事件通知方式,只关注就绪事件大大提高了并发事件的处理能力。

poll select epoll
fd 数量限制 无(取决于系统资源) FD_SETSIZE(1024)
效率 O(n) 遍历 O(n) 遍历 O(1) 事件通知
触发方式 水平触发(LT) 水平触发(LT) 支持 LT/ET
内存使用 每次传递整个数组 每次传递整个 fd_set 内核维护事件表
适用场景 少量 fd 兼容性要求高 高并发(10K+ 连接)

​ 上述方案实现了用一个线程同时监控多个IO,但一个IO中往往不止一种操作。例如服务器接收到客户端请求创建clientfd之后,服务器读取数据并向客户端发送数据是两种不同的事件。reactor则是使用少量线程去处理多个IO的多个事件,通过事件注册时的回调函数所实现。

reactor

​ 对每条IO的每种事件都有对应的回调函数,在注册阶段与回调函数相绑定。在本案例中,创建套接字的IO用于监听连接属于EPOLLIN事件,而accept函数接收到的客户端IO同时需要接收数据并且发送数据。

io 事件 callback
listenfd EPOLLIN accpet_cb
clientfd EPOLLIN recv_cb
clientfd EPOLLOUT send_cb

​ 可以根据需要实现的功能设置回调函数。换句话说每一个IO都有可能会调用这三类函数,所以将IO进行封装。

#define BUFFER_LENGTH     (1024 * 2)

typedef int (*RCALLBACK)(int fd);	

struct conn{
    int fd;

    char rbuffer[BUFFER_LENGTH];
    int rlength;

    char wbuffer[BUFFER_LENGTH];
    int wlength;

    RCALLBACK send_callback;

    union{
        RCALLBACK recv_callback;
        RCALLBACK accept_callback;
    } r_action;

};

​ 因为recv函数与accept函数在同一个IO中不可能同时使用(必须通过accept函数获得clientfd之后,在clientfd中才能使用recv函数),此处使用联合表示这两种方法仅能一次表示一类函数。其中rbuffer和wbuffer分别是读写时的缓冲区,每一个不同的IO都有其不同的缓冲区。

​ reactor是基于IO复用的事件驱动方案,同样需要将fd交付给epoll管理。

​ 将fd和需要处理的事件将fd交给epoll管理,set_event()将fd按照作为参数传入的事件类型(EPOLLIN、EPOLLOUT)注册到epoll中。

初始化并注册事件

int set_event(int fd,int event,int flag){

    if(flag){//1 添加
        struct epoll_event ev;
        ev.events = event;
        ev.data.fd = fd;
        epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev);
    }else {// 修改
        struct epoll_event ev;
        ev.events = event;
        ev.data.fd = fd;
        epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev);

    } 
}

​ 在init_server()函数中,由传入的port参数绑定socket套接字并开启监听,并返回套接字文件操作符。

int init_server(unsigned short port){

    int sockfd = socket(AF_INET,SOCK_STREAM,0);

    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(port);
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

    if(-1 == bind(sockfd,(struct sockaddr*)&servaddr,sizeof(struct sockaddr))){
        printf("bind failed: %s\n",strerror(errno));
    }

    listen(sockfd,10);

    return sockfd;

}

​ 在主函数中,通过init_server()开启服务器监听,sockfd监听请求调用accept函数,所以应该将该fd的回调函数设置为accpet_cb(),最后通过set_event()将IO交给epoll。

	int sockfd = init_server(port);
    conn_list[sockfd].fd = sockfd;
    conn_list[sockfd].r_action.recv_callback = accept_cb;
    set_event(sockfd,EPOLLIN,1);

三个回调函数

​ 回调函数用于处理对应的事件,分别为处理新连接的accpet_cb(),接收用户数据的recv_cb(),向客户端发送数据的send_cb()。

​ accept_cb函数接收到客户端请求,通过event_register初始化struct conn结构体的所有参数,其中clientfd只会对应recv_cb函数,最后将其设置为可读事件注册到epoll中。

int event_register(int fd,int event){

    if(fd < 0) return -1;

    conn_list[fd].fd = fd;
    conn_list[fd].r_action.recv_callback = recv_cb;
    conn_list[fd].send_callback = send_cb;

    memset(conn_list[fd].rbuffer,0,BUFFER_LENGTH);
    conn_list[fd].rlength = 0;

    memset(conn_list[fd].wbuffer,0,BUFFER_LENGTH);
    conn_list[fd].wlength = 0;

    set_event(fd,event,1);

}

​ accept_cb使用accpet()函数接收到来自客户端的请求获取到clientfd,并将客户端连接设置为可读并且边缘触发状态。

int accept_cb(int fd){

    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);

    int clientfd = accept(fd,(struct sockaddr*)&clientaddr,&len);
    if(clientfd < 0){
        printf("accpet errrno: %d --> %s\n", errno, strerror(errno));        
        return -1;
    }

    //设置新连接的信息
    event_register(clientfd,EPOLLIN | EPOLLET);//边缘触发

    return 0;
}

​ 通过上述流程,所有的客户端连接以及服务器套接字都被正确的按照事件状态放入epoll中。

​ recv_cb()来自于EPOLLIN状态的clientfd事件。调用recv()接收来自客户端的数据。

	int recv_cb(int fd){

    memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH );
    int count = recv(fd,conn_list[fd].rbuffer,BUFFER_LENGTH,0);
    if(count == 0){
        printf("client disonnect: %d\n",fd);
        close(fd);

        epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);

        return 0;

    }else if(count < 0){

        printf("count: %d, errno: %d, %s\n",count,errno,strerror(errno));
        close(fd);
        epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);
        return 0;

    }
    conn_list[fd].rlength = count;

    conn_list[fd].wlength = conn_list[fd].rlength;
    memcpy(conn_list[fd].wbuffer,conn_list[fd].rbuffer,conn_list[fd].wlength);

    printf("[%d]RECV: %s\n",conn_list[fd].rlength,conn_list[fd].rbuffer);

    set_event(fd,EPOLLOUT,0);

    return count;
}

set_event(fd,EPOLLOUT,0)将可读事件处理完毕,该fd变成可写事件。

​ send_cb对应EPOLLOUT状态的clientfd,使用send()向客户端发送数据。

int send_cb(int fd){

    int count = 0;
    if (conn_list[fd].wlength != 0) {
		count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
	}
	
	set_event(fd, EPOLLIN, 0);

    return count;

}

主函数调用

​ 基于所有的回调函数,处理所有由epoll_wait()返回的就绪事件。

	while(1){

        struct epoll_event events[1024] = {0};
        int nready = epoll_wait(epfd,events,1024,-1);

        int i = 0;
        for(i = 0;i < nready;i++){

            int connfd = events[i].data.fd;

            if(events[i].events & EPOLLIN){
                conn_list[connfd].r_action.recv_callback(connfd);
            }
            
            if(events[i].events & EPOLLOUT){
                conn_list[connfd].send_callback(connfd);
            }
        }

    }

问题与解决方案

问题一:监控文件描述符、就绪文件描述符在底层分别都采用什么数据结构?

​ 因为常用于高并发场景,监控的fd采用红黑树存储,做到查找、插入、删除都是O(logN)。使用双向链表存储所有的就绪事件,该数据结构可以做到O(1)的插入和删除。

问题二:如何理解select/poll/epoll基于IO处理,reactor基于事件处理。

​ 两者分别代表了不同的处理思路,前者关注的是IO的就绪和处理,后者关注的是事件的分发。select/poll/epoll本质上是使用机制去监控多个文件描述符,当这些IO就绪时通知程序。而reactor模式是基于IO复用的更高模式的架构。将IO与业务分离,一旦某个fd就绪,reactor就会触发注册的回调函数,在回调函数中处理业务逻辑。

总结

​ 本文深入探讨了IO多路复用技术(select/poll/epoll)与Reactor事件驱动模型在网络高并发编程中的应用与实现。从传统的一请求一线程模型出发,到IO多路复用技术。其中,select使用位图轮询(O(n)),poll改进为链表结构,而epoll则通过红黑树管理监控fd(O(logN))和就绪事件双向链表(O(1))实现了质的飞跃。Reactor模式在此基础上更进一步,通过事件循环机制将IO就绪通知与业务逻辑解耦,采用回调函数处理不同事件,使单个IO能处理多类事件。

posted @ 2025-05-15 20:39  +_+0526  阅读(230)  评论(0)    收藏  举报