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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM