select与epoll比较

Posted by     "Eric" on Sunday, September 6, 2020

1. 前言

假设要写一个echo服务器,用于对用户从标准输入键入的交互命令做出相应,在这种情况下,服务器必须相应两个独立的I/O事件:1)网络客户端发起连接请求,2)用户在键盘上键入命令行。我们先等待哪个事件呢?没有哪个选择是理想的,如果使用accept等待一个连接请求,就不能相应输入命令,如果read等待一个输入命令,就不能相应任何连接请求。

2. 基于进程的并发编程

我们可以为每个连接开启一个新的线程,也就是accpet到新的连接后,开启一个新的线程完成对用户指令的回显。但是当连接数过多的时候,线程之间的上下文切换将极大的消耗cpu资源。

image-20200907090821189

3. select多路复用

当然我们也可以基于I/O多路复用进行编程,其基本思路就是使用select函数,要求内核挂起进程,只有当一个或多个I/O事件发生后,才将控制权返回给应用程序。简单的讲,select函数有两个输入:一个称为读集合的描述符集合(fdset)和该读集合的基数(n)。select会一直阻塞,直到读集合中至少有一个描述符准备好了可以读。select有个副作用,当其返回时,会修改参数fdset指向读集合的一个子集,我们称为准备好集合,因此我们每次调用select时都要更新读集合。示例代码如下所示:

void echo(int connfd);
void command(void);

int main(int argc, char **argv){
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storange clientaddr;
    fd_set read_st,ready_set;
    
    if(argc != 2){
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(0);
    }
    listenfd = Open_listenfd(argv[1]);
    
    FD_ZERO(&read_set);              // 清空
    FD_SET(STDIN_FILNO, &read_set);  // 将标准输入添加到读集合
    FD_SET(listenfd , &read_set);	 // 将监听描述符添加到读集合
    
    while(1){
        ready_set = read_set;
        select(listenfd+1,&ready_set,NULL,NULL,NULL);
        if(FD_ISSET(SETDIN_FILENO, &ready_set))
            command();
        if(FD_ISSET(listenfd , &ready_set)){
            clientlen = sizeof(stuct sockaddr_storage);
            connfd = accept(listenfd , (SA *)&clientaddr, &clientlen);
            echo(connfd);
            close(connfd);
        }
    }
    
    void command(void){
        char buf[MAXLINE];
        if(!fgets(buf, MAXLINE , stdin))
            exit(0);
        printf("%s",buf);
    }
}

4. epoll多路复用

我们可以看到select多路复用是有一些弊端的,比如它需要将fdset从用户态内存空间贝到内核内存空间,返回时有需要将fdset从内核用户空间拷贝回用户内存空间;另外当select返回后,我们仍需要遍历一遍fdset判断哪些监听符准备好了,然后开启连接。为了解决以上两个弊端,诞生了epoll多路复用器。

epoll多路复用通过3个系统调用为用户提供服务。

  1. epoll_create系统调用
  2. epoll_ctl系统调用
  3. epoll_wait系统调用

当调用epoll_create时,会在内核空间中开辟一个eventpoll结构体,这个结构体中有两个成员与epoll的使用密切相关

struct eventpoll{
    /*红黑树的根节点,存储着添加到epoll中的事件,即监听事件*/
    struct rb_root rbr;
    /*双向链表保存着ready的监听事件,即epoll_wait返回给用户的,满足条件的事件*/
    struct list_head rellist;
}

image-20200907094854837

当我们使用epoll_ctl添加监听事件的时候,会向红黑树中添加新的节点,并且会与设备驱动建立回调关系,当相应的事件发生时,会把该事件放到rdllist双向链表中,当调用epoll_wait时,只需要将rdllist复制到用户内存空间即可,因此epoll_wait的效率非常高。

总结一下epoll高效的本质在于:

  • 减少了用户态和内核态的文件句柄拷贝
  • 使用红黑树存储fd,插入、查找、删除性能不错
  • 不需要遍历fdset得到ready fd

当然,也并不是epoll在所有情况下由于select,例如在如果有少于1024个文件描述符监听,且大多数socket都是出于活跃繁忙的状态,这种情况下,select要比epoll更为高效,因为epoll会有更多次的系统调用,用户态和内核态会有更加频繁的切换。