zoukankan      html  css  js  c++  java
  • I/O多路复用之select、poll、epoll详解(+Redis)

    目前支持I/O多路复用的系统调用有 select,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

    I/O多路复用优势和适用场景:

    I/O多路复用的优势在于,当处理的消耗对比IO几乎可以忽略不计时,可以处理大量的并发IO,而不用消耗太多CPU/内存。这就像是一个工作很高效的人,手上一个todo list,他高效的依次处理每个任务。这比每个任务单独安排一个人要节省。典型的例子是nginx做代理,代理的转发逻辑相对比较简单直接,那么IO多路复用很适合。相反,如果是一个做复杂计算的场景,计算本身可能是个 指数复杂度的东西,IO不是瓶颈。那么怎么充分利用CPU或者显卡的核心多干活才是关键。

    此外,IO多路复用适合处理很多闲置的IO,因为IO socket的数量的增加并不会带来进(线)程数的增加,也就不会带来stack内存,内核对象,切换时间的损耗。因此像长链接做通知的场景非常适合。

    文件描述符fd

    文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念

    文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

    select

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

    select 函数监视的文件描述符分3类,分别是readfds、writefds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

    优点:

    1. select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点

    缺点:

    1. 单个进程能够监视的文件描述符的数量存在最大限制,它由FD_SETSIZE设置,默认值是1024。
      可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

      一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。

      32位机默认是1024个。64位机默认是2048.

    2. fd集合在内核被置位过,与传入的fd集合不同,不可重用。
      重复进行FD_ZERO(&rset); FD_SET(fds[i],&rset);操作

    3. 每次调⽤用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。

    4. 同时每次调用select都需要在内核遍历传递进来的所有fd标志位,O(n)的时间复杂度,这个开销在fd很多时也很大。

    例:

      sockfd = socket(AF_INET, SOCK_STREAM, 0);
      memset(&addr, 0, sizeof (addr));
      addr.sin_family = AF_INET;
      addr.sin_port = htons(2000);
      addr.sin_addr.s_addr = INADDR_ANY;
      bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
      listen (sockfd, 5); 
     
      for (i=0;i<5;i++) 
      {
        memset(&client, 0, sizeof (client));
        addrlen = sizeof(client);
        // 创建了5个文件描述符(5个数,代表文件描述符的编号,随机) socket可以接受5个客户端连接,存到fds中
        fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
        if(fds[i] > max)
        	max = fds[i]; // 存入最大值
      }
    //-------------------fds ,max准备完毕----------------------
      while(1){
    	FD_ZERO(&rset);
      	for (i = 0; i< 5; i++ ) {
      		FD_SET(fds[i],&rset); // rset 是 bitmap  0110010101000... //1 2 5 7 9
      	}
     
       	puts("round again");
        // max+1最大文件描述符+1; 读文件描述符集合;写文件描述符集合;异常文件描述符集合;超时时间
        //select就是将rset从用户态拷贝到内核态,内核负责判断每一个fd是否有数据来。
        //无数据阻塞;有数据来后,把相应的bit置位,返回
    	select(max+1, &rset, NULL, NULL, NULL);
     
    	for(i=0;i<5;i++) { //遍历rset,判断fd对应的rset位 被置位了
    		if (FD_ISSET(fds[i], &rset)){
    			memset(buffer,0,MAXBUF);
    			read(fds[i], buffer, MAXBUF); //读取数据
    			puts(buffer);
    		}
    	}	
      }
    

    poll

    int poll (struct pollfd *fds, unsigned int nfds, int timeout);
    

    不同与select使用三个位图bitmap来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

    struct pollfd {
        int fd; 		/* file descriptor */
        //读 POLLIN; 写POLLOUT;
        short events;   /* requested events to watch 要监视的event*/
        short revents;  /* returned events witnessed 发生的event*/
    };
    

    pollfd结构包含了要监视的event和发生的event,不再使用select “参数-值” 传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

    优点:

    1. poll用pollfd数组代替了bitmap,没有最大数量限制。(解决select缺点1)
    2. 利用结构体pollfd,每次置位revents字段,每次只需恢复revents即可。pollfd可重用。(解决select缺点2)

    缺点:

    1. 每次调⽤用poll,都需要把pollfd数组从用户态拷贝到内核态,这个开销在fd很多时会很大。(同select缺点3)

    2. 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。(同select缺点4)

    例:

    for (i=0;i<5;i++) 
      {
        memset(&client, 0, sizeof (client));
        addrlen = sizeof(client);
        pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
        pollfds[i].events = POLLIN; // 读 POLLIN
      }
      sleep(1);
    // ---------------------------
      while(1){
      	puts("round again");
        //pollfds:pollfd的数组; 数组中有5个元素;超时时间
        //将rset从用户态拷贝到内核态
        //无数据,阻塞;有数据,将相应的pollfd.revents置位,返回
    	poll(pollfds, 5, 50000);
     
    	for(i=0;i<5;i++) {		//遍历 判断
    		if (pollfds[i].revents & POLLIN){
    			pollfds[i].revents = 0;
    			memset(buffer,0,MAXBUF);
    			read(pollfds[i].fd, buffer, MAXBUF);
    			puts(buffer);
    		}
    	}
      }
    

    epoll

    epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

    1. epoll操作过程

    epoll操作过程需要三个接口,分别如下:

    int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    

    1. int epoll_create(int size);

    创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。

    当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

    当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
    eventpoll结构体如下所示:

    struct eventpoll{
    	...
        // 红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件
        struct rb_root rbr;
        // 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件
        struct list_head rdlist;
        ...
    }
    

    每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

    而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

    2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    函数是对指定描述符fd执行op操作。

    用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上

    • epfd:是epoll_create()的返回值。
    • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
    • fd:是需要监听的fd(文件描述符)
    • epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
    struct epoll_event {
      __uint32_t events;  /* Epoll events */
      epoll_data_t data;  /* User data variable */
    };
    
    //events可以是以下几个宏的集合:
    EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    EPOLLOUT:表示对应的文件描述符可以写
    EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    EPOLLERR:表示对应的文件描述符发生错误
    EPOLLHUP:表示对应的文件描述符被挂断
    EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
    EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
    

    在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示 :

    struct epitem{
        struct rb_node rbn;       //红黑树节点
        struct list_head rdllink; //双向链表节点
        struct wpoll_filefd ffd;  //事件句柄信息
        struct evntpoll *ep;	  //指向其所属的eventpoll对象
        struct epoll_event event; //期待发生的事件类型   
    }
    

    当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户 。

    3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

    等待epfd上的io事件,最多返回maxevents个事件。

    通过回调函数内核会将 I/O 准备好的描述符添加到rdlist双链表管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。

    参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

    2.工作模式

    epoll对文件描述符的操作有两种模式:LT (level trigger)(默认)ET (edge trigger)。LT模式是默认模式。

    LT模式与ET模式的区别如下:

    • LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
    • ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

    1. LT模式

    LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的

    2. ET模式

    ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

    ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

    3. 总结

    假如有这样一个例子:

    1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
    2. 这个时候从管道的另一端被写入了2KB的数据
    3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
    4. 然后我们读取了1KB的数据
    5. 调用epoll_wait(2)......

    LT模式:
    如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能受到通知。

    ET模式:
    如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。

    当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,

    读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:

    while(rs){
        buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
        if(buflen < 0){
            // 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
            // 在这里就当作是该次事件已处理处.
            if(errno == EAGAIN){
                 break;
            }
            else{
                return;
            }
        }
        else if(buflen == 0){
           // 这里表示对端的socket已正常关闭.
        }
    
       if(buflen == sizeof(buf){
           rs = 1;   // 需要再次读取
       }
       else{
           rs = 0;
       }
    }
    

    Linux中的EAGAIN含义

    Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。

    例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
    又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

    3. 代码演示

    下面是一段不完整的代码且格式不对,意在表述上面的过程,去掉了一些模板代码。

    //添加监听描述符事件
    add_event(epollfd,listenfd,EPOLLIN);
    
    //循环等待
    for ( ; ; ){
        //该函数返回已经准备好的描述符事件数目
        ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
        //处理接收到的连接
        handle_events(epollfd,events,ret,listenfd,buf);
    }
    
    //事件处理函数
    static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
    {
         int i;
         int fd;
         //进行遍历;这里只要遍历已经准备好的io事件。num并不是当初epoll_create时的FDSIZE。
         for (i = 0;i < num;i++)
         {
             fd = events[i].data.fd;
            //根据描述符的类型和事件类型进行处理
             if ((fd == listenfd) &&(events[i].events & EPOLLIN))
                handle_accpet(epollfd,listenfd);
             else if (events[i].events & EPOLLIN)
                do_read(epollfd,fd,buf);
             else if (events[i].events & EPOLLOUT)
                do_write(epollfd,fd,buf);
         }
    }
    
    //添加事件
    static void add_event(int epollfd,int fd,int state){
        struct epoll_event ev;
        ev.events = state;
        ev.data.fd = fd;
        epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
    }
    
    //处理接收到的连接
    static void handle_accpet(int epollfd,int listenfd){
         int clifd;     
         struct sockaddr_in cliaddr;     
         socklen_t  cliaddrlen;     
         clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);     
         if (clifd == -1)         
         perror("accpet error:");     
         else {         
             printf("accept a new client: %s:%d
    ",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);                       //添加一个客户描述符和事件         
             add_event(epollfd,clifd,EPOLLIN);     
         } 
    }
    
    //读处理
    static void do_read(int epollfd,int fd,char *buf){
        int nread;
        nread = read(fd,buf,MAXSIZE);
        if (nread == -1)     {         
            perror("read error:");         
            close(fd); //记住close fd        
            delete_event(epollfd,fd,EPOLLIN); //删除监听 
        }
        else if (nread == 0)     {         
            fprintf(stderr,"client close.
    ");
            close(fd); //记住close fd       
            delete_event(epollfd,fd,EPOLLIN); //删除监听 
        }     
        else {         
            printf("read message is : %s",buf);        
            //修改描述符对应的事件,由读改为写         
            modify_event(epollfd,fd,EPOLLOUT);     
        } 
    }
    
    //写处理
    static void do_write(int epollfd,int fd,char *buf) {     
        int nwrite;     
        nwrite = write(fd,buf,strlen(buf));     
        if (nwrite == -1){         
            perror("write error:");        
            close(fd);   //记住close fd       
            delete_event(epollfd,fd,EPOLLOUT);  //删除监听    
        }else{
            modify_event(epollfd,fd,EPOLLIN); 
        }    
        memset(buf,0,MAXSIZE); 
    }
    
    //删除事件
    static void delete_event(int epollfd,int fd,int state) {
        struct epoll_event ev;
        ev.events = state;
        ev.data.fd = fd;
        epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
    }
    
    //修改事件
    static void modify_event(int epollfd,int fd,int state){     
        struct epoll_event ev;
        ev.events = state;
        ev.data.fd = fd;
        epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
    }
    
    //注:另外一端我就省了
    

    4. epoll总结

    在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

    优点:

    1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

      select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。

    2. epoll是内核空间用一个 红黑树维护所有的fd,epoll_wait 通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,只把就绪的fd用链表复制到用户空间。

    3. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

      如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

    1. 不用重复传递。我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

    2. 在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。

      epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

    3. 极其高效的原因:
      这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

      这个准备就绪list链表是怎么维护的呢?
      当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。
      上面这句可以看出,epoll的基础就是回调!

      如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

    Redis IO多路复用技术

    redis 是一个单线程却性能非常好的内存数据库, 主要用来作为缓存系统。 redis 采用网络IO多路复用技术来保
    证在多连接的时候, 系统的高吞吐量。

    为什么Redis中要使用I/O多路复用呢?

    首先,Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都
    是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其
    它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现的。

    select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个
    描述符就绪,能够通知程序进行相应的操作。
    redis的io模型主要是基于epoll实现的,不过它也提供了 select和kqueue的实现,默认采用epoll 。

    为什么 Redis 使用了单线程 IO 多路复用,为什么那么快?

    1. cpu 处理相比于 IO 可以忽略
      每个客户端建立连接时,都需要服务端为其 创建 socket 套接字,建立连接。
      然后该客户端的每个请求都要经历以下几步:
      (1)等待请求数据数据从客户端发送过来
      (2)将请求数据从内核复制到用户进程的缓冲区(buffer)
      (3)对请求数据进行处理(对于 redis 而言,一般就是简单的 get/set)

      由于操作简单+只涉及内存,所以第(3)步的处理很简单、很快,主要时间耗在(1)步,所以,如果采用普通 BIO 模式,每个请求都要经历这几步,那么处理十万条数据,就要在(1)步花费大量的时间,这样的话,qps 一定很低。

      所以就采用了更高效的 IO 多路复用模式,即,将(1)步统一交给第三方(也就是操作系统,操作系统提供了 select、poll、epoll、kqueue、iocp等系统调用函数),结合 redis 的单线程,现在整个处理流程是这样的:
      一下子来了一堆请求,线程将这些请求都交给操作系统去处理,让操作系统帮忙完成第(1)步,等到这些请求里的一个或多个走完了第(1)步,就将一个集合交给这个线程,并说,我这里收集到了几个走完第一步的请求,你去走(2)、(3)步吧。于是线程拿着这个集合去遍历,等遍历结束之后。又去检查操作系统那儿有没有(这个线程自己维护了一个 while 循环)走完第(1)步的请求,发现又有一些了,拿到后继续遍历进行(2)、(3)步,如此循环往复。
      注:有些 IO 模式是将(1)(2)步都交给操作系统处理了,线程本身只需处理第(3)步

    2. 瓶颈在带宽,而不在 cpu
      由于 数据存放在内存中+处理逻辑简单,导致即使是单线程,Redis 可支持的 qps 也相当大,而当 qps 相当大的时候,首先限制性能的是带宽,即不需要把 cpu 的性能挖掘出来,因为在这之前,带宽就不够用了。所以没有必要为了提高 cpu 利用率而使用多线程处理业务逻辑。

    参考资料

    https://segmentfault.com/a/1190000003063859

    https://www.zhihu.com/question/306267779/answer/570147888

  • 相关阅读:
    [HNOI2010]CITY 城市建设

    [HNOI2011]数学作业
    [NOI2012]美食节
    [HEOI2014]大工程
    [HEOI2013]ALO(待更)
    [HEOI2016/TJOI2016]序列
    贪食蛇(未完待续)
    [HEOI2016/TJOI2016]字符串
    bzoj 2437[Noi2011]兔兔与蛋蛋 黑白染色二分图+博弈+匈牙利新姿势
  • 原文地址:https://www.cnblogs.com/kuotian/p/13199625.html
Copyright © 2011-2022 走看看