瞭解IO多路復用,瞭解poll和select存在的問題,深入瞭解epoll如何高效,最後使用epoll實現一個聊天室鞏固學習 ...
我們知道,電腦的硬體資源由操作系統管理、調度,我們的應用程式運行在操作系統之上,我們的程式運行需要訪問電腦上的資源(如讀取文件,接收網路請求),操作系統有內核空間和用戶空間之分,所以數據讀取,先由內核讀取數據到內核緩衝區,然後才會從操作系統的內核空間拷貝到用戶空間,這個就是緩存I/O,又被稱作標準I/O。
幾種常見的IO模式:阻塞I/O、非阻塞I/O、I/O多路復用
1、阻塞I/O
用戶進程向內核發起I/O系統調用,內核去準備所需的數據,直到數據都準備好了(需要一段時間)返回給用戶進程,在這期間,用戶進程一直處於阻塞狀態,拿到所需數據,才會繼續向下執行。
2、非阻塞I/O
用戶進程向內核發起I/O系統調用,內核發現數據還沒準備好,立即返回error,用戶進程拿到error,可以再次向內核發起請求,直到獲取所需數據
3、I/O多路復用
下麵詳細介紹
一、I/O多路復用
定義:I/O multiplexing allows us to simultaneously monitor multiple file descriptors to see if I/O is possible on any of them.
1、為什麼要同時監視多個文件描述符?
傳統的阻塞I/O模型可以滿足大部分的應用程式使用場景,但有的時候,一些應用程式會同時需要如下特性:
- 檢查文件I/O是否已經ready,如果沒有,不阻塞,直接返回
- 同時監視多個文件描述符,判斷他們中的任意一個的I/O是否已經ready
比如一個web伺服器,可能同時打開了幾千個連接,每accept一個連接,操作系統產生一個fd(文件描述符),我們的伺服器需要監聽這些文件描述符,當客戶端發來新的數據的時候,我們需要處理請求數據,並給出響應,正常的實現方式可能如下:
connections = [fd1, fd2, fdn];
for (c in connections) {
if (hasNewInput(x)) {
processInput(x);
}
}
這樣實現的問題是,我們自己維護著所有的連接,需要主動的去輪尋判斷I/O是否已經ready以便進行讀寫,但事實上並不是所有連接都是活躍狀態(建立的連接並沒有數據交互),如果我們輪尋的頻率很低,那用戶獲取響應的時間可能長的無法忍受,如果輪尋的頻率非常的高,則會浪費CPU的時間。
所以如果可以將主動遍歷所有連接,判斷每一個的IO狀態,改為將這些文件描述符(fd)交給內核去監視,然後當一個或多個文件描述符IO ready的時候,內核來告訴我們,這樣效率就大大提高了,因此我們需要IO多路復用。
2、I/O多路復用的幾種實現
IO多路復用的實現比較普遍的有兩個:select和poll,相比較poll,select更為普遍,最早與BSD socket api一起出現,已被納入SUSV3標準規範,epoll是只屬於Linux的特性,最早的API出現在Linux2.6。
名字叫I/O多路復用,所謂的復用,復用的是同一個進程(線程),也就是在同一個進程中“併發“的完成多個文件描述符的I/O。
2.1、select
select系統調用會阻塞,直到一個或多個文件描述符I/O ready
1>定義:
#include <sys/time.h> /* For portability */
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
2>參數:
- nfds:應設置為大於等於下麵三個fds監聽的文件描述符的總和
- readfds:需要監聽輸入事件的文件描述符集合
- writefds:需要監聽輸出事件的文件描述符集合
- exceptfds:需要被監聽是否有異常情況發生的文件描述符集合
- timeout:,阻塞;設置為0,檢查一遍文件描述符狀態立即返回;設置為NULL或者非0值,會一直阻塞直到:
- 三種fds中有至少一個文件描述符進入ready狀態
- 被信號處理中斷
- 到達指定的超時時間
3>返回值:
- 返回-1:產生錯誤
- 返回0:超時返回了,這期間沒有文件描述符ready
- 返回正數:返回的數值就是已經ready的文件描述符的數量
4>小示例
#include <stdio.h>
#include <string.h>
#include <sys/time.h>
#include <sys/select.h>
int main(int argc, char *argv[])
{
fd_set readfds, writefds;
int ready, nfds, fd, numRead, j;
struct timeval timeout;
struct timeval *pto;
char buf[10];
//判斷參數是否包含短斜線,包含timeout設置NULL
if (strcmp(argv[1], "-") == 0) {
pto = NULL;
} else {
pto = &timeout;
pto.tv_sec = atoi(argv[1]);
pto.tv_usec = 0;
}
//準備監聽文件描述符集合
nfds = 0;
//用select 提供的巨集初始化fd_set
FD_ZERO(&readfds);
FD_ZERO(&writefds);
for (j = 2; j < argc; j++) {
numRead = sscanf(argv[j], "%d%2[rw]", &fd, buf);
if (numRead != 2) {
printf("error");
return 0;
}
if (fd >= nfds) {
nfds += 1;
}
//判斷是監聽讀還是寫
if (strchr(buf, 'r') != NULL) {
FD_SET(fd, &readfds);
}
if (strchr(buf, 'w') != NULL) {
FD_SET(fd, &writefds);
}
}
//調用select函數
ready = select(nfds, &readfds, &writefds, NULL, pto);
if (ready == -1) {
printf("select error\n");
return 0;
}
for (fd = 0; fd < nfds; fd++) {
//列印ready 狀態
printf("%d: %s%s\n", fd, FD_ISSET(fd, &readfds) ? "r" : "", FD_ISSET(fd, &writefds) ? "w" : "");
}
return 0;
}
使用文件描述符0 也就是標準輸入測試,首先編譯一下這段代碼gcc -o select select.c
運行./select 10 0r 代表select阻塞最多10秒超時返回,監聽標準輸入的 讀ready狀態,會發現程式處於阻塞狀態,直到10秒後select返回,列印 0:,也就是沒有ready
運行./select - 0r 代表select不會超時直到 標準輸入 讀ready,程式會一直掛起,直到我們敲回車,返回0:r
運行./select 5 0r 1w 代表最多5秒超時,同時監聽標準輸入的讀狀態,和標準輸出的寫狀態,返回0: 1:w
2.2、poll
poll跟select機制基本一樣,不同點在於select將監聽的文件描述符分開到3個集合中,poll只需一個文件描述符列表
1>定義
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
2>參數
- struct pollfd fds:文件描述符數組
struct pollfd {
int fd; /* File descriptor */
short events; /* Requested events bit mask */
short revents; /* Returned events bit mask */
};
- nfds:fds數組中文件描述符的總數
- timeout:超時時間,傳-1阻塞直到有文件描述符ready,傳0不阻塞,傳>0的值,不管有沒有ready的文件描述符,到指定時間超時返回
3>返回值
- 返回-1:有錯誤產生
- 返回0:沒有任何文件描述符ready
- 返回正數:fds元素revents不為0的文件描述的個數
3、poll和select存在哪些問題?
1、每次調用poll()或select(),內核必須檢查傳過來的所有文件描述符,當文件描述符超過一定數量後,光是檢查這一步就已經非常耗時了
2、每次調用poll()或select(),需要從用戶態初始化文件描述符的數據結構,然後傳遞到內核態,在內核態檢查IO狀態,然以將狀態更新到文件描述符數據結構,再返回這個數據結構,數據需不斷的在用戶態和內核態間拷貝,同樣當文件描述符數量很大時,非常耗費CPU時間
3、每次調用完poll()或select(),還要遍歷返回的所有文件描述符,判斷狀態,浪費時間
解決以上問題的關鍵是:1)不要每次都在內核態和用戶態複製這些監聽的文件描述符數據 2)只返回IO ready 的文件描述符,不要只標識狀態,自己還需要再遍歷一遍,因此,Linux給了我們epoll。
二、Epoll
Linux的epoll,也是I/O多路復用的一種實現方式,同樣是同時監聽多個文件描述符的I/O狀態,他有如下幾個優勢:
- 同時監聽的大量文件描述符,效率高於select和epoll
- epoll api同時提供了水平觸發通知和邊緣觸發通知可選,select和epoll只有水平觸發通知的方式(兩種通知方式後面會講)
- 對於監聽文件描述的具體事件(讀、寫等,通過bit mask的方式),更為靈活
不像select和poll直接就是系統調用的函數,epoll由3個系統調用函數組成
1、epoll的3個函數
1.1、epoll_create
通過調用epoll_create,創建一個新的epoll內核數據結構實例,返回該實例的文件描述符,然後,調用進程可以使用這個文件描述符向epoll實例添加、刪除或修改它想要監視的其他文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
1.2、epoll_ctl
進程可以通過調用epoll_ctl向epoll實例添加它希望監視的文件描述符,所有這些文件描述符由epoll實例維護在interest list中。當被監視的文件描述符為I/O做好準備時,他們就進入到ready list,ready list是interest list的一個子集
定義:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
參數:
- epfd:epoll_create返回的文件描述符
- fd:準備加入監視列表(interest list)的文件描述符
- op:要對這個fd做什麼操作,有3種
- 添加:EPOLL_CTL_ADD
- 刪除:EPOLL_CTL_DEL
- 修改:EPOLL_CTL_MOD
- event:指向epoll_event結構體的指針,結構體中存儲著我們實際想要監視fd的哪些事件
epoll_event結構:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events:
事件的成員是位掩碼,比如fd是一個socket,我們可能希望監視他,以獲取套接字緩衝區上的新數據(使用EPOLLIN),如果希望事件的通知機制使用邊緣觸發的方式,可以使用EPOLLET。也就是讀操作就緒,使用邊緣觸發的通知方式,需要這樣指定events:EPOLLIN | EPOLLET
所有可以指定的events見http://man7.org/linux/man-pages/man2/epoll_ctl.2.html
data:
epoll_data_t 是一個union,可以存儲一些數據,當該文件描述符ready的時候,返回給調用監聽的進程
typedef union epoll_data {
void *ptr; /* Pointer to user-defined data */
int fd; /* File descriptor */
uint32_t u32; /* 32-bit integer */
uint64_t u64; /* 64-bit integer */
} epoll_data_t;
返回值:返回0執行成功,-1發生錯誤
1.3、epoll_wait
通過調用epoll_wait,可以獲取之前監聽的所有事件(interest list)中I/O準備好的事件(ready list)
定義:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
參數:
- epfd:epoll_create創建epoll實例,返回的文件描述符
- evlist:epoll_event數組,epoll_wait調用會阻塞,直到有文件描述符ready,ready的文件描述符及發生的事件會保存到evlist
- maxevents:evlist數組的長度
- timeout:指定epoll_wait將阻塞多長時間
- 設置為0,非阻塞模式,檢查interest list是否有ready的文件描述符後立即返回
- 設置為-1,永遠的阻塞直到1>interest list中的一個或多個文件描述符就緒 2>被信號處理程式終斷
- 設置為正數:永遠的阻塞直到1>interest list中的一個或多個文件描述符就緒 2>被信號處理程式終斷 3> 指定的timeout毫秒到了,過期返回
2、深入epoll實例
2.1、先弄清文件描述符和打開文件的關係
為了弄清楚打開文件、文件描述符和epoll之間的關係,我們先看一下Linux操作系統中文件描述符(file descriptors)、打開文件描述(open file description)、和系統級文件inode表之間的關係,如圖所示:
內核維護了3個數據結構:
- 每個進程打開的文件描述符(file descriptor table)
- fd flags:文件的flag,就是只讀、只寫等這些flag
- file ptr:指向文件描述的指針
- 系統中打開文件的描述(open file table)
- file offset:當前文件的offset
- status flags:對應open的flags參數
- inode ptr:指向i-node對象的指針
- 操作系統維護文件系統 (i-node table)
- file type:文件類型
- file locks:一個指向該文件鎖相關的列表的指針
- 以及其它各種標識這個文件相關信息
圖中可以看出:
- A進程的fd1和fd20這兩個文件描述符的file ptr指向同一個同一個open file description(可能執行了dup系統調用複製了文件描述符)
- A進程fd2和B進程的fd2的file ptr也指向同一個open file description(B可能是A調用系統調用fork出來的子進程)
- A進程的fd0和B進程的fd3指向不同的file description,但是指向的是同一個i-node,也就說打開的是同一個文件
2.2、再看epoll_create
當調用了epoll_create,事實上內核在inode-table創建了一個新的inode(也就是epoll實例),以及在file description table中添加一條打開文件描述記錄,並且返回給調用者一個文件描述符。
接下來我們就可以使用這個文件描述符,向epoll實例中添加需要監聽的文件描述符,當調用epoll_ctl添加一個文件描述符到epoll實例的監聽列表(interest list)的時候,事實上真正添加的不是文件描述符,而是其對應的文件描述(file description table)。基於這一點,結合上面文件描述符和打開文件關係的圖:
假如進程A調用epoll_create,返迴文件描述符fd8。
- 如果B是A fork出來的子進程,那麼會繼承A所有打開的文件描述符,包括fd8,所以進程A和進程B共用interest list,
- A fork完 B 之後,又打開了一個文件,返迴文件描述符fd3,然後調用epoll_ctl添加到interest list中,這時B中並不包括A新打開的文件描述符fd3,但是當fd3有I/O 事件的時候,A或B進程調用epoll_wait,都會收到fd3 IO ready的通知
2.3、epoll為什麼性能高於poll和select?
回顧上述poll()和select()存在的問題,可以看到epoll剛好解決了這些問題:
- 相比較poll和select,每次調用迴圈檢查每一個文件描述符I/O狀態,時間複雜度O(N),不管N大小,調用一次迴圈檢查一次;epoll得益於監聽的是文件描述符下一級的打開文件描述,並且每次調用epoll_wait的時候直接返回ready list就可以了,不需要迴圈
- 相比較poll和select,每次調用都需要傳遞監聽文件描述符數據到內核,epoll調用epoll_ctl一次性添加到epoll實例,接下來調用epoll_wait不需要再傳遞這些文件描述符信息過去了
- 相比較poll()和select()返回所有的文件描述,epoll只返回ready的文件描述符
監聽不同數量文件描述符,執行100000次狀態檢查select、poll、epoll性能對比:
3、關於level-trigger 和 edge-trigger
3.1、文件描述符準備就緒通知的兩種模型:
- 水平觸發:如果可以在不阻塞的情況下執行I/O系統調用,則認為文件描述符已經準備好了,觸發通知
- 邊緣觸發:文件描述符被監控以來,有新的I/O活動(例如 新的輸入),觸發通知
3.2、不同的通知模型如何影響我們的程式設計?
水平觸發
- 確定文件描述符的I/O狀態已經ready
- 已經ready,對這個文件描述符執行一些I/O操作(比如讀取幾個位元組數據),然後繼續監視它的IO狀態
- 仍然有未讀取的數據,還會繼續觸發通知,也就是說不用一次執行完所有的IO操作(比如一次讀取緩衝區的全部內容)
邊緣觸發:
- 只有新的IO事件發生時,才會觸發通知
- 直到下次IO事件發生,不會再觸發通知
註:對於邊緣觸發,當觸發通知時,我們並不知道有多少IO可用(例如有多少位元組可以讀),所以一般使用邊緣觸發通知方式要遵循如下規則:
- 接收到事件通知後,程式應該儘可能多的執行IO(讀寫),因為僅僅通知這一次,不讀取完畢,數據可能就丟失了
- 為了避免IO阻塞,每個被監視的文件描述符應該是非阻塞模式打開的,然後收到事件通知後,重覆的執行IO直到返回錯誤信息
接下來的聊天室的實例,我們使用邊緣觸發的方式
三、Epoll實戰:聊天室
瞭解完epoll的基礎和原理,使用一個精簡版的聊天室的小實例來鞏固學習。
主要思路:一個服務端,可以接收多個客戶端的連接,客戶端發送的消息會同步到所有聊天室內的客戶端
1、服務端設計與實現
第一步:服務端創建socket服務
第二步:創建epoll實例,將socket服務fd加入interest list
第三步:迴圈調用epoll_wait看是否有ready的文件描述符,如果有並且是我們的socket服務fd,說明是有新的客戶端連接加入,保存客戶端fd,並將fd加入interest list;如果非socket 服務fd,說明是客戶端有新消息發送至服務端,接收消息然後廣播給保存的所有客戶端fd
2、客戶端設計與實現
第一步:創建socket連接服務端
第二步:創建管道,以便獲取客戶端輸入
第三步:創建epoll實例,並把socket fd和管道fd加入interest list
第四步:fork子進程
第五步:父進程調用epoll_wait獲取ready list,如果fd是服務端sockt,則直接列印廣播消息;如果fd是管道則為用戶輸入,讀取管道中的消息,發到服務端
3、涉及其他知識點
- socket
- fork
- pipe
- linked list(保存連接的客戶端fd)
運行如圖:
源碼放到了Githubhttps://github.com/bigbignerd/epollchat
四、參考文獻
1、《The linux programming interface》
2、Medium: The method to epoll's madness
如有表述錯誤之處,歡迎指正!
原文地址https://www.cnblogs.com/skyfynn/p/10644243.html