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\n",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.\n");
        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

猜你喜欢

转载自www.cnblogs.com/kuotian/p/13199625.html