IO多路複用-- Select、Poll、Epoll

IO多路複用的概念

多路複用是一種機制,可以用來監聽多種描述符,如果其中任意一個描述符處於就緒的狀態,就會返回消息給對應的進程通知其採取下一步的操作。

IO多路複用的優勢

當進程需要等待多個描述符的時候,通常情況下進程會開啓多個線程,每個線程等待一個描述符就緒,但是多路複用可以同時監聽多個描述符,進程中無需開啓線程,減少系統開銷,在這種情況下多路複用的性能要比使用多線程的性能要好很多。

相關API介紹

在linux中,關於多路複用的使用,有三種不同的API,select、poll和epoll

Select介紹

select的使用需要引入sys/select.h頭文件,API函數比較簡單,函數原型如下:

int select (int __nfds, fd_set *__restrict __readfds,
           fd_set *__restrict __writefds,
           fd_set *__restrict __exceptfds,
           struct timeval *__restrict __timeout);

fd_set

其中有一個很重要的結構體fd_set,該結構體可以看作是一個描述符的集合,可以將fa_set看作是一個位圖,類似於操作系統中的位圖,其中每個整數的每一bit代表一個描述符,。

舉個簡單的例子,fd_set中元素的個數爲2,初始化都爲0,則fd_set中含有兩個整數0,假設一個整數的長度8位(爲了好舉例子),則展開fd_set的結構就是 00000000 0000000,如果這個時候添加一個描述符爲3,則對應fd_set編程 00000000 00001000,可以看到在這種情況下,第一個整數標記描述符0~7,第二個整數標記8~15,依次類推。

fd_set有四個關聯的api

void FD_ZERO(fd_set *fdset) //清空fdset,將所有bit置爲0
void FD_SET(int fd, fd_set *fdset) //將fd對應的bit置爲1
void FD_CLR(int fd, fd_set *fdset) //將fd對應的bit置爲0
void FD_ISSET(int fd, fd_set *fdset) //判斷fd對應的bit是否爲1,也就是fd是否就緒

select函數中存在三個fd_set集合,分別代表三種事件,__readfds表示讀描述符集合,__writefds表示讀描述符集合,__exceptfds表示讀描述符集合,當對應的fd_set = NULL時,表示不監聽該類描述符。

__nfds

__nfds是fd_set中最大的描述符+1,當調用select的時候,內核態會判斷fd_set中描述符是否就緒,__nfds告訴內核最多判斷到哪一個描述符。

timeval

struct timeval {
    long tv_sec;    //秒
    long tv_usec;    //微秒
}

參數__timeout指定select的工作方式:

  • __timeout= NULL,表示select永遠等待下去,直到其中至少存在一個描述符就緒
  • __timeout結構體中秒或者微妙是一個大於0的整數,表示select等待一段固定的事件,若該短時間內未有描述符就緒則返回
  • __timeout= 0,表示不等待,直接返回

函數返回

select函數返回產生事件的描述符的數量,如果爲-1表示產生錯誤

值得注意的是,比如用戶態要監聽描述符1和3的讀事件,則將readset對應bit置爲1,當調用select函數之後,若只有1描述符就緒,則readset對應bit爲1,但是描述符3對應的位置爲0,這就需要注意,每次調用select的時候,都需要重新初始化並賦值readset結構體,將需要監聽的描述符對應的bit置爲1,而不能直接使用readset,因爲這個時候readset已經被內核改變了。

Poll介紹

select中,每個fd_set結構體最多隻能標識1024個描述符,在poll中去掉了這種限制,使用poll需要引入頭文件sys/poll.h,poll調用的API如下:

int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);

pollfd

struct pollfd {
    int fd;                    // poll的文件描述符
    short int events;        // poll關心的事件類型
    short int revents;        // 發生的事件類型
  };

Poll使用結構體pollfd來指定一個需要監聽的描述符,結構體中fd爲需要監聽的文件描述符,events爲需要監聽的事件類型,而revents爲經過poll調用之後返回的事件類型,在調用poll的時候,一般會傳入一個pollfd的結構體數組,數組的元素個數表示監控的描述符個數,所以pollfd相對於select,沒有最大1024個描述符的限制。

事件類型有多種,在bits/poll.h中定義了多種事件類型,主要如下:

#define POLLIN        0x001        // 有數據可讀
#define POLLPRI        0x002        // 有緊迫數據可讀
#define POLLOUT        0x004        // 現在寫數據不會導致阻塞

# define POLLRDNORM    0x040        // 有普通數據可讀
# define POLLRDBAND    0x080        // 有優先數據可讀
# define POLLWRNORM    0x100        // 寫普通數據不會導致阻塞
# define POLLWRBAND    0x200        // 寫優先數據不會導致阻塞

#define POLLERR        0x008        // 發生錯誤
#define POLLHUP        0x010        // 掛起
#define POLLNVAL    0x020        // 無效文件描述符

當一個文件描述符要同時監聽讀寫事件時,可以寫成 events = POLLIN | POLLOUT

可以看到,poll中使用結構體保存一個文件描述符關心的事件,而在select中,統一使用fd_set,一個fd_set中可以是所有需要監聽讀事件的文件描述符,也可以是所有需要寫事件的文件描述符。

相比來說,poll比select更加的靈活,在調用poll之後,無需像select一樣需要重新對文件描述符初始化,因爲poll返回的事件寫在了pollfd->revents成員中。

__fds

__fds的作用同select中的__nfds,表示pollfd數組中最大的下標索引

__timeout

  • __timeout = -1:poll阻塞直到有事件產生
  • __timeout = -0:poll立刻返回
  • __timeout != -1 && __timeout != 0:poll阻塞__timeout對應的時候,如果超過該時間沒有事件產生則返回

函數返回

poll函數返回產生事件的描述符的數量,如果返回0表示超時,如果爲-1表示產生錯誤

Epoll介紹

epoll中,使用一個描述符來管理多個文件描述符,使用epoll需要引入頭文件sys/epoll.h,epoll相關的api函數如下:

int epoll_create (int __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);

epoll_event

typedef union epoll_data {
  void *ptr;     // 可以用改指針指向自定義的參數
  int fd;         // 可以用改成員指向epoll所監控的文件描述符
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event {
  uint32_t events;        // epoll事件
  epoll_data_t data;    // 用戶數據
} __EPOLL_PACKED;

epoll_event結構體中,首先是一個events的整型變量,類似於pollfd->events,表示要監控的事件,events支持的事件類型在sys/epoll.h的頭文件中,跟pollfd中的事件類型基本移植,如下,這裏只寫出一部分:

enum EPOLL_EVENTS {
    EPOLLIN = 0x001,
#define EPOLLIN EPOLLIN     // 有數據可讀
    EPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRI     // 有緊迫數據可讀
    EPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUT     // 現在寫數據不會導致阻塞
    EPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORM        // 有普通數據可讀
    EPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBAND        // 有優先數據可讀
    EPOLLWRNORM = 0x100,
#define EPOLLWRNORM EPOLLWRNORM        // 寫普通數據不會導致阻塞
    EPOLLWRBAND = 0x200,
#define EPOLLWRBAND EPOLLWRBAND        // 寫優先數據不會導致阻塞
    ...
    EPOLLERR = 0x008,
#define EPOLLERR EPOLLERR    // 發生錯誤
    EPOLLHUP = 0x010,
#define EPOLLHUP EPOLLHUP    // 掛起
    EPOLLRDHUP = 0x2000,
       ...
  };

epoll_event中的data指向一個共用體結構,可以用該共用體保存自定義的參數,或者指向被監控的文件描述符。

epoll_create

int epoll_create (int __size);

epoll_create函數創建一個epoll實例並返回,該實例可以用於監控__size個文件描述符

epoll_ctl

int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);

該函數用來向epoll中註冊事件函數,其中__epfd爲epoll_create返回的epoll實例,__op表示要進行的操作,__fd爲要進行監控的文件描述符,__event要監控的事件。

__op可用的類型定義在sys/epoll.h頭文件中,如下:

#define EPOLL_CTL_ADD 1        // 添加文件描述符
#define EPOLL_CTL_DEL 2        // 刪除文件描述符
#define EPOLL_CTL_MOD 3        //    修改文件描述符(指的是epoll_ctl中傳入的__event)

該函數如果調用成功返回0,否則返回-1。

epoll_wait

int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);

epoll_wait類似與select中的select函數、poll中的poll函數,等待內核返回監聽描述符的事件產生,其中__epfd是epoll_create創建的epoll實例,__events數組爲epoll_wait要返回的已經產生的事件集合,其中第i個元素成員的__events[i]->data->fd表示產生該事件的描述符,__maxevents爲希望返回的最大的事件數量(通常爲__events的大小),__timeout和poll中的__timeout相同。該函數返回已經就緒的事件的數量,如果爲-1表示出錯。

select、poll、epoll比較

select和poll的機制基本相同,只不過poll沒有select最大文件描述符的限制,在具體使用的時候,有如下缺點:

  • 每次調用select或者poll,都需要將監聽的fd_set或者pollfd發送給內核態,如果需要監聽大量的文件描述符,這樣的效率是很低下的
  • 在內核態中,每次需要對傳入的文件描述符進行輪詢,查詢是否有對應的事件產生。

epoll的高效在於將這些分開,首先epoll不是在每次調用epoll_wait的時候,將描述符傳送給內核,而是在epoll_ctl的時候傳送描述符給內核,當調用epoll_wait的收,不用每次都接收

不像select和poll使用一個單獨的API函數,在epoll中,使用epoll_create創建一個epoll實例,然後當調用epoll_ctl新增監聽描述符的時候,這個時候纔將用戶態的描述符發送到內核態,因爲epoll_wait調用的頻率肯定要比epoll_create的頻率要高,所以當epoll_wait的時候無需傳送任何描述符到用戶態;

關於第二點,在內核態中,使用一個描述符就緒的鏈表,當描述符就緒的時候,在內核態中會使用回調函數,該函數會將對應的描述符添加入就緒鏈表中,那麼當epoll_wait調用的時候,就不需要遍歷所有的描述符查看是否有就緒的事件,而是直接查看鏈表是否爲空。

總結

可以使用一個生活中的場景來對三者的區別做個總結,仍然接着筆者的上一篇博文IO模型淺析-阻塞、非阻塞、IO複用、信號驅動、異步IO、同步IO中喫飯的例子:

在這個例子中,服務員和餐廳代表內核,客戶“你”就是用戶態進程,可能覺得這個例子寫的不好,在這裏寫下加深記憶。

select和poll:你去餐廳請客喫飯,你是個豪爽的人,點了很多菜,你告訴服務員對應種類的菜有多少上多少,服務員將菜名一一寫在紙上。然後你開始問服務員飯菜有好了麼,服務員看着你的菜單一大串,頭皮發麻,於是按着菜單的順序去廚房查看飯菜有沒有好,如果菜沒有好就劃掉菜單中對應的菜,終於找出了所有已經燒好的飯菜,服務員把飯菜端給了你。可是這個時候菜單上只能看到已經準備好的菜了,沒準備好的菜看不清了,你覺得這個服務員做事很傻逼,沒辦法將就點,誰讓你性格好呢,於是你重新寫了一份菜單(可能這個過程中你又想點一些新的菜或者刪除一些菜)。接下來你又去問飯菜好沒好,服務員又開始按照菜單的順序去廚房查看飯菜有沒有好。。。(select和poll的主要區別就在於,select中的菜單是有限的,而poll中的菜單是無限的,你可以點任意種類的菜)

epoll:你去餐廳請客喫飯,你是個豪爽的人,點了很多菜,你告訴服務員對應種類的菜有多少上多少,服務員將菜名一一錄入到餐廳後臺的菜單管理軟件中,廚房的師傅燒好一道菜在管理軟件中標記完成一下,然後在燒好的菜上掛上對應的桌號放在取菜區,這個時候你來問服務員飯菜有準備好的麼,服務員於是查一下管理軟件,有標記欸,於是從取菜區取出對應桌號的飯菜送給你,清空標記。過了段時間,你又想點一道新的菜,於是叫來服務員,服務員在菜單軟件中添加一欄。接下來你又去問飯菜好沒好,服務員又開始看菜單軟件中是否有標記完成的信息。。。

另外關於epoll的高效還有很多細節,例如使用mmap將用戶空間和內核空間的地址映射到同一塊物理內存地址,使用紅黑樹存儲要監聽的事件等等,具體的細節可以參考博文select、poll、epoll之間的區別總結整理高併發網絡編程之epoll詳解Linux下的I/O複用與epoll詳解徹底學會使用epoll(一)——ET模式實現分析等幾篇文章。

---------------------------------------------------------------------------------------

補充說明:

epoll有EPOLLLT和EPOLLET兩種觸發模式,LT是默認的模式,ET是“高速”模式。LT模式下,只要這個fd還有數據可讀,每次 epoll_wait都會返回它的事件,提醒用戶程序去操作,而在ET(邊緣觸發)模式中,它只會提示一次,直到下次再有數據流入之前都不會再提示了,無論fd中是否還有數據可讀。所以在ET模式下,read一個fd的時候一定要把它的buffer讀光,也就是說一直讀到read的返回值小於請求值,或者遇到EAGAIN錯誤。還有一個特點是,epoll使用“事件”的就緒通知方式,通過epoll_ctl註冊fd,一旦該fd就緒,內核就會採用類似callback的回調機制來激活該fd,epoll_wait便可以收到通知。

epoll爲什麼要有EPOLLET觸發模式?

如果採用EPOLLLT模式的話,系統中一旦有大量你不需要讀寫的就緒文件描述符,它們每次調用epoll_wait都會返回,這樣會大大降低處理程序檢索自己關心的就緒文件描述符的效率.。而採用EPOLLET這種邊沿觸發模式的話,當被監控的文件描述符上有可讀寫事件發生時,epoll_wait()會通知處理程序去讀寫。如果這次沒有把數據全部讀寫完(如讀寫緩衝區太小),那麼下次調用epoll_wait()時,它不會通知你,也就是它只會通知你一次,直到該文件描述符上出現第二次可讀寫事件纔會通知你!!!這種模式比水平觸發效率高,系統不會充斥大量你不關心的就緒文件描述符

epoll的優點:

1、沒有最大併發連接的限制,能打開的FD的上限遠大於1024(1G的內存上能監聽約10萬個端口);

2、效率提升,不是輪詢的方式,不會隨着FD數目的增加效率下降。只有活躍可用的FD纔會調用callback函數;

即Epoll最大的優點就在於它只管你“活躍”的連接,而跟連接總數無關,因此在實際的網絡環境中,Epoll的效率就會遠遠高於select和poll。

3、 內存拷貝,利用mmap()文件映射內存加速與內核空間的消息傳遞;即epoll使用mmap減少複製開銷。

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