Linux下IO多路複用

一、IO多路複用處理數據報文

在這裏插入圖片描述

二、select

1. 簡介

select系統調用是用來讓我們的程序監視多個文件描述符的狀態變化的。程序會阻塞到select這裏,直到被監聽的文件描述符一個或者多個發生了狀態變化。

2. 函數原型

int select(int nfds, fd_set* read_fds, fd_set* write_fds, 
           fd_set* except_fds, struct timeval* timeout);

2.1 參數說明

  • nfds:是需要監視的最大的文件描述符值+1
  • read_sets:對應於需要檢測的可讀文件描述符的集合
  • write_sets:對應於需要檢測的可寫文件描述符的集合
  • except_sets:對應於需要檢測的異常文件描述符的集合
  • timeout:用來設置select的等待時間
    • NULL:表示不設置select的等待時間,select在監聽到描述符狀態變化之前將一直阻塞
    • 0:表示僅檢測描述符集合的狀態,然後立即返回,並不等待外部事件的發生
    • 特定的時間值:如果在這個設置的時間值內沒有事件發生,select將超時返回

2.2 fd_set結構說明

首先,我們可以看一下這個結構的定義:

/* The fd_set member is required to be an array of longs. */
typedef long int __fd_mask;

typedef struct{
 	/* something */
	__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    /* something */
};

注:上面代碼節選自<sys/select.h>中,其中我只保留了便於理解的部分。

從定義中我們可以看出,其實fd_set結構就是一個long型數組,或者說,它代表一種數據結構----“位圖”。使用位圖中對應的位來表示要監視的文件描述符。

select提供了一組操縱位圖的接口:

void FD_CLR(int fd, fd_set *set);  // 用來清除描述位圖set中相關fd的位
int FD_ISSET(int fd, fd_set *set); // 用來測試描述位圖set中相關fd的位是否爲真
void FD_SET(int fd, fd_set *set);  // 用來設置描述位圖set中相關fd的位
void FD_ZERO(fd_set* set);         // 用來清空位圖set中的所有位

2.3 timeval結構說明

這個結構的定義:

/* A time value that is accurate to the nearest
   microsecond but also has a range of years.
*/
struct timeval{
    __time_t tv_sec; /* Second. */
    __suseconds_t tv_usec; /* Microseconds. */
};

注:上面代碼節選自<time.h>中,其中我只保留了便於理解的部分。

2.4 返回值說明

執行成功

  • 返回文件描述符中狀態已經改變的描述符個數

其他結果

  • 返回0,表示在參數傳入的timeout時間內沒有文件描述符狀態發生變化
  • 返回-1,表示在執行中發生錯誤,錯誤原因存儲於errno中,errno可能的結果有以下幾種:
    • EBADF:文件描述符無效或文件已關閉
    • EINTR:此次select調用被信號打斷
    • EINVAL:參數n爲負值
    • ENOMEM:內存不足

3. 就緒條件

3.1 讀就緒

  • socket內核中, 接收緩衝區中的字節數, 大於等於低水位標記SO_RCVLOWAT;
  • socket TCP通信中, 對端關閉連接, 此時對該socket讀, 則返回0;
  • 監聽的socket上有新的連接請求;
  • socket上有未處理的錯誤。

3.2 寫就緒

  • socket內核中, 發送緩衝區中的可用字節數(發送緩衝區的空閒位置大小), 大於等於低水位標記

    SO_SNDLOWAT;

  • socket的寫操作被關閉(close或者shutdown). 對一個寫操作被關閉的socket進行寫操作, 會觸發SIGPIPE

    信號;

  • socket使用非阻塞connect連接成功或失敗之後;

  • socket上有未處理的錯誤。

4. 函數使用

使用select實現一個本地回顯程序。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>

int main() {
  fd_set read_fds;
  FD_ZERO(&read_fds); //初始化fd_set結構
  FD_SET(STDIN_FILENO, &read_fds); //監聽標準輸入
  while(1){
    printf("> ");
    fflush(stdout);
    int ret = select(STDIN_FILENO+1, &read_fds, NULL, NULL, NULL);
    if(ret < 0){
      perror("select");
      continue;
    }
    if(FD_ISSET(STDIN_FILENO, &read_fds)){
      char buf[1024] = {0}; 
      read(STDIN_FILENO, buf, sizeof(buf) - 1); //讀取鍵盤輸入
      printf("Echo: %s\n", buf);
    } else {
      printf("error! invalid fd\n");
      continue;
    }
    FD_ZERO(&read_fds);
    FD_SET(STDIN_FILENO, &read_fds);
  }
  return 0;
}

5. 函數特點

  • 可監控的文件描述符個數取決與sizeof(fd_set)的值。
  • 將fd加入select監控集的同時,還要再使用一個數據結構array保存放到select監控集中的fd,
    • 一是用於再select返回後,array作爲源數據和fd_set進行FD_ISSET判斷。
    • 二是select返回後會把以前加入的但並無事件發生的fd清空,則每次開始select前都要重新從array取得fd逐一加入(FD_ZERO最先),掃描array的同時取得fd最大值maxfd,用於select的第一個參數。

6. 函數缺點

  • 每次調用select, 都需要手動設置fd集合, 從接口使用角度來說也非常不便.
  • 每次調用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大
  • 同時每次調用select都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
  • select支持的文件描述符數量太小

三、poll

1. 函數原型

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

1.1 參數說明

  • fds:fds是一個poll函數監聽的結構列表
  • nfds:表示fds數組的長度
  • timeout:表示poll函數的超時時間, 單位是毫秒(ms)

1.2 pollfd結構說明

struct pollfd{
	int fd;        	   /* File descriptor to poll. */
	short int events;  /* Types of events poller cares about. */
	short int revents; /* Types of events that actually occurred. */
};

注:上面代碼節選自<time.h>中

events和revents的取值列表:

事件 描述 是否可作爲輸入 是否可作爲輸出
POLLIN 數據可讀(包括普通數據和優先數據)
POLLRDNORM 普通數據可讀
POLLPRI 高優先級數據可讀,eg: TCP帶外數據
POLLOUT 數據可寫(包括普通數據和優先數據)
POLLWRNORM 普通數據可寫
POLLWRBAND 優先級帶數據可寫
POLLRDHUP TCP連接被對方關閉,或對方關閉了寫操作
POLLERR 錯誤
POLLHUP 掛起
POLLNVAL 文件描述符未打開

1.3 返回值說明

執行成功

  • 返回文件描述符中狀態已經改變的描述符個數

其他結果

  • 返回0,表示在參數傳入的timeout時間內沒有文件描述符狀態發生變化
  • 返回-1,表示在執行中發生錯誤

2. 就緒條件

同select

3. 函數使用

使用poll實現一個本地回顯程序。

#include <sys/poll.h>
#include <unistd.h>
#include <stdio.h>

int main(){
    struct pollfd poll_fd;
    poll_fd.fd = STDIN_FILEIN;
    poll_fd.events = POLLIN;
    while(1){
        printf("> ");
        fflush(stdout);
        int ret = poll(&poll_fd, 1, -1);
        if(0 == ret){
            printf("poll timeout\n");
            continue;
        }else if(ret < 0){
            perror("poll");
            continue;
        }
        if(POLLIN == poll_fd.revents){
            char buf[1024] = {0};
            read(STDIN_FILENO, buf, sizeof(buf) - 1);
            printf("stdin: %s", buf);
        }
    }
    return 0;
}

4. 優點

  • poll的接口使用比select更方便了一些
  • poll監聽的文件描述符的數目理論上沒有上限,但是由於poll底層是採用遍歷檢測文件描述符是否就緒的方式,所以數量過大後性能還是會下降的

5. 缺點

  • 和select函數一樣,poll返回後,需要輪詢pollfd來獲取就緒的描述符,監視的文件描述符超過一定數量,輪詢的時間開銷會線性增長

四、epoll

1. 函數原型

int epoll_create(int size);

1.1 epoll_create說明、

1.1.1 參數說明

在Linux2.6.8版本後,這個參數會被忽略。但是還是要注意,這個參數不可以傳入小於0的數。

1.1.2 返回值說明

epoll_create返回一個操作epoll的句柄。

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

1.2 epoll_ctl說明

1.2.1 參數說明
  • epfd:是epoll_create的返回值,即epoll的句柄。
  • op:表示epoll_ctl函數的動作,有三個取值:
    • EPOLL_CTL_ADD:註冊新的文件描述符fd到epfd中
    • EPOLL_CTL_MOD:修改已經註冊到epfd的監聽事件
    • EPOLL_CTL_DEL:從epfd中註銷一個文件描述符fd
  • fd:需要監聽的文件描述符
  • event:告訴內核要監聽什麼類型的事件
1.2.2 epoll_event結構說明

這個結構體的定義是這樣的:

typedef union epoll_data{
  	void* ptr;
	int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event{
	uint32_t events; /* Epoll events. */
    epoll_data_t data; /* User data variable. */
} __EPOLL_PACKED;

上面代碼節選自<sys/epoll.h>中,我只保留了便於理解的部分。

其中events可以是以下幾個宏的集合:

  • EPOLLIN:表示對應的文件描述符可以讀(包括對端socket正常關閉)
  • EPOLLOUT:表示對應的文件描述符可以寫
  • EPOLLPRI:表示對應的文件描述符有緊急數據可讀(帶外數據)
  • EPOLLERR:表示對應的文件描述符發生錯誤
  • EPOLLHUP:表示對應的文件描述符被掛斷
  • EPOLLET:將EPOLL設置爲邊緣觸發(ET)模式,EPOLL默認爲水平觸發(LT)模式
  • EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還要繼續監聽這個事件,需要重新把這個事件添加到EPOLL隊列中
int epoll_wait(int epfd, struct epoll_event* events, int max_events, int timeout);

1.3 epoll_wait說明

1.3.1 參數說明
  • epfd:是epoll_create的返回值,即epoll的句柄。
  • events:epoll會把已經發生的事件拷貝到events數組中(events不可以是空指針,內核只負責把數據拷貝到這個數組中,不會主動在用戶態分配內存存儲)
  • max_events:events的大小,這個參數不能大於調用epoll_create時的傳入的size
  • timeout:超時時間,單位是毫秒
    • 這個參數填0,epoll_wait會立即返回
    • 這個參數填-1,epoll_wait會永久阻塞,直到有事件發生
1.3.1 返回值說明

執行成功

  • 返回文件描述符中狀態已經改變的描述符個數

其他結果

  • 返回0,表示在參數傳入的timeout時間內沒有文件描述符狀態發生變化
  • 返回<0,表示函數執行過程中發生錯誤

2. 工作原理

當進程調用epoll_create時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員和epoll的使用密切相關:

struct eventpoll{
	/*something*/
    /* 紅黑樹的根節點,這棵樹中存儲着所有添加到epoll中需要監控的事件。 */
    struct rb_root rbr;
    /* 雙鏈表的頭節點,雙鏈表中存儲着要通過epoll_wait返回給用戶的滿足條件的事件。 */
    struct list_head rblist;
    /*something*/
};

每一個epoll對象都會在內核中創建一個eventpoll結構體,通過epoll_ctl方法向epoll對象添加進來的事件,這些事件都會掛載到eventpoll中的紅黑樹上,事件的結構類型是這樣的:

struct epitem{
	struct rb_node rbn; /* 紅黑樹節點 */
	struct list_head rdllink; /* 雙向鏈表節點 */
	struct epoll_filefd ffd; /* 事件句柄信息 */
	struct eventpoll* ep; /* 指向其所屬的 eventpoll 對象 */
	struct epoll_event event; /* 期待發生的事件類型 */
};

所有添加到epoll對象中的事件都會與設備驅動程序建立回調關係,當事件發生響應,就會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它主要的作用是將發生響應的事件添加到eventpoll中的雙鏈表上。

當調用epoll_wait檢查事件是否發生的時候,只需要檢查eventpoll對象中的rdlist雙鏈表中是否存在epitem(即事件)元素即可。如果存在,則把rdlist中的事件拷貝到用戶態,同時通過返回值將rdlist中的元素數目(事件發生的個數)返回給用戶。

3. 工作模式

先看一下這個例子:

已經把一個tcp socket添加到epoll描述符中,這個時候socket的客戶端寫入了2KB數據,服務器端調用epoll_wait,epoll_wait返回,然後調用read,可在服務器端分配的緩衝區只有1KB,所以一次只讀取了1KB數據,繼續調用epoll_wait…

3.1 LT模式

當epoll檢測到socket上事件就緒的時候,可以選擇只處理一部分數據,或者不立即進行處理。例如上面的例子,由於第一次調用epoll_wait,服務器端只讀取了1KB數據,在第二次調用epoll_wait時,epoll_wait仍然會立即返回並通知socket讀事件就緒,直到socket緩衝區中所有的數據都讀取,epoll_wait纔不會因爲這個socket而立即返回。

LT模式支持阻塞讀寫和非阻塞讀寫。

注:select和poll其實就相當於是epoll的LT模式。

3.2 ET模式

當epoll_wait返回通知socket讀時間就緒時,必須立即處理,而且要一次處理完。例如上面的例子,第一次調用epoll_wait時,只讀取了1KB數據,那麼第二次調用epoll_wait時,epoll_wait就不會再返回。

ET模式支持非阻塞讀寫。注意:使用ET模式的時候,需要將監聽的文件描述符設置爲非阻塞。

注:Nginx默認用的就是epoll的ET模式。

4. 函數使用

/* epoll LT模式的回顯服務器 */

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define CHECK_ERROR(str, n) do{if(n < 0){ perror(str); exit(EXIT_FAILURE); }}while(0)

void ProcessConnect(int listen_fd, int epoll_fd){
  struct sockaddr_in client_addr;
  socklen_t len = sizeof(client_addr);
  int connect_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
  if(connect_fd < 0){
    perror("accept");
    exit(EXIT_FAILURE);
  }
  printf("client %s:%d connect\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
  struct epoll_event ev;
  ev.data.fd = connect_fd;
  ev.events = EPOLLIN;
  int ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, connect_fd, &ev);
  if(ret < 0){
    perror("epoll_ctl");
    exit(EXIT_FAILURE);
  }
}

void ProcessRequest(int connect_fd, int epoll_fd){
  char buf[1024] = {0};
  ssize_t read_size = read(connect_fd, buf, sizeof(buf) - 1);
  if(read_size < 0){
    perror("read");
    return;
  }
  if(0 == read_size){
    close(connect_fd);
    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, connect_fd, NULL);
    printf("client say: goodbye\n");
    return;
  }
  printf("client say: %s\n", buf);
  write(connect_fd, buf, strlen(buf));
}

void CorrectUsage(){
  printf("Usage: ./epoll_server [ip] [port]\n");
}

int main(int argc, char* argv[]) {
  if(argc != 3){
    CorrectUsage();
    exit(EXIT_FAILURE);
  } 
  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_port = htons(atoi(argv[2]));
  addr.sin_addr.s_addr = inet_addr(argv[1]);
  int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
  CHECK_ERROR("socket", listen_fd);
    
  int ret = bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));  
  CHECK_ERROR("bind", ret);
    
  ret = listen(listen_fd, 10);
  CHECK_ERROR("listen", ret);
    
  int epoll_fd = epoll_create(5);
  CHECK_ERROR("epoll_create", epoll_fd);
    
  struct epoll_event event;
  event.events = EPOLLIN;
  event.data.fd = listen_fd;
  ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event);
  CHECK_ERROR("epoll_ctl", ret);
    
  while(1){
    struct epoll_event events[10];
    int size = epoll_wait(epoll_fd, events, sizeof(events) / sizeof(events[0]), -1);
    if(size < 0){
      perror("epoll_wait");
      continue;
    }
    if(0 == size){
      printf("epoll timeout\n");
      continue;
    }
    for(int i = 0; i < size; ++i){
      if(!(events[i].events & EPOLLIN)){
        continue;
      }
      if(events[i].data.fd == listen_fd){
        ProcessConnect(listen_fd, epoll_fd);
      }else{
        ProcessRequest(events[i].data.fd, epoll_fd);
      }
    }
  }
  return 0;
}

5. 驚羣效應

參考:Epoll的驚羣效應

6. 優點

前面介紹了一下epoll的底層原理,可以看出它相對於之前的IO多路複用接口做了很多優化:

  • 不用再通過輪詢的方式來檢測事件是否就緒,而是通過回調機制,大大縮短了輪詢的時間開銷
  • 不用每次使用檢測事件是否就緒接口前都要拷貝之前的事件集合,大大減少了拷貝開銷
  • 文件描述符的數目理論上沒有上限,不像select,“文件描述符的數目取決於 ’位圖‘ 的大小,想要更改 ‘位圖‘ 大小甚至還要重新編譯內核”
  • 接口使用也方便了很多,不需要每次循環都要重新設置關注的文件描述符(eg:select)

7. 使用場景

對於多連接,並且多連接中只有一部分連接比較活躍時,比較適合用epoll。例如:各種互聯網APP的入口服務器,就很適合用epoll。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章