IO模型學習
1. 阻塞和非阻塞IO
低速系統呼叫是可能會使程序永遠阻塞的一類系統呼叫:
- 如果某些檔案型別的資料並不存在,讀操作可能會使呼叫者永遠阻塞;
- 如果資料不能被相同的檔案型別立即接受,寫操作可能會使呼叫者永遠阻塞;
- 在某種條件發生之前開啟某些檔案型別可能會發生阻塞;
- 對已經加上強制性記錄鎖的檔案進行讀寫;
- 某些ioctl操作;
- 某些程序間通訊函式。
非阻塞IO使得我們可以發出open、read和write這樣的IO操作,並使得這些操作不會永遠阻塞; 如果這種操作不能完成,則呼叫立即出錯返回,表示該操作如繼續執行將阻塞;
即阻塞需要等待IO結束再返回,而非阻塞可以直接返回,返回是否出錯,或者可以通過輪詢的方式來判斷是否IO完成;
2. IO多路轉接
i. select函式(非同步通知)
在所有POSIX相容的平臺上,select函式使我們可以執行IO多路轉接。
#include <sys/select.h>
int select(int maxfdpl, fd_set *restrict readfds,
fd_set *restrict writefds, fd_set *restrict exceptfds,
struct timeval *restrict tvptr);
返回值:準備就緒的描述符數目;若超時則返回0,出錯返回-1;
引數:
1.readfds、writefds、exceptfds: 指向描述符集的指標,說明了我們關心的可讀、可寫或處於異常條件的描述符集,儲存於fs_set的資料型別中;
2.tvptr:願意等待的時間長度,單位為秒或微秒;
3.maxfdp1:最大檔案描述符加一;
對於一個正返回值的select呼叫,表示已經準備好的描述符數目,該值是3個描述符集中已準備好的描述符數之和,如果同一個描述符已經準備好了讀和寫,那麼在返回值中會對其計兩次數;對於準備好的具體說明:
- readfds:某描述符進行read操作不會阻塞,則該描述符是準備好的;
- writedfs: 某描述符進行write操作不會阻塞,則該描述符是準備好的;
- exceptfds:某描述符中有一個未決異常條件,則該描述符是準備好的;
ii. poll(非同步通知)
poll函式類似於select,但是程式設計師介面有所不同。
#include <poll.h> int poll(strcut pollfd fdarray[], nfds_t nfds, int timeout);
與select不同,poll不是為每個條件構造一個描述符集,而是構造一個pollfd結構的陣列,每個陣列元素指定一個描述符編號以及我們對該描述符感興趣的條件。
引數:
struct pollfd {
int fd;
short events;
short revents;
};
events成員指定了我們對該描述符感興趣的條件,由使用者設定。可以為下列常量的一個或者幾個:
- POLLIN:可以不阻塞地讀取高優先順序資料以外的資料(等價於POLLRDNORM|POLLRDBAND)
- POLLRDNORM:可以不阻塞地讀取普通資料
- POLLRDBAND:可以不阻塞地讀取優先順序資料
- POLLPRI:可以不阻塞地讀取高優先順序資料
- POLLOUT:可以不阻塞地寫普通資料
- POLLWRNORM:與POLLOUT相同
- POLLWRBAND:可以不阻塞地寫優先順序資料
返回時,revents成員由核心設定,用於說明每個描述符發生了哪些時間;
返回值:準備就緒的描述符數目;若超時則返回0,出錯返回-1;
iii. select和poll的缺點
select缺點:
1.單個程序能夠監視的檔案描述符的數量存在最大限制,通常是1024,當然可以更改數量,但由於select採用輪詢的方式掃描檔案描述符,檔案描述符數量越多,效能越差;(在linux核心標頭檔案中,有這樣的定義:#define __FD_SETSIZE 1024)
2.核心 / 使用者空間記憶體拷貝問題,select需要複製大量的控制代碼資料結構,產生巨大的開銷;
3.select返回的是含有整個控制代碼的陣列,應用程式需要遍歷整個陣列才能發現哪些控制代碼發生了事件;
4.select的觸發方式是水平觸發,應用程式如果沒有完成對一個已經就緒的檔案描述符進行IO操作,那麼之後每次select呼叫還是會將這些檔案描述符通知程序。
相比select模型,poll使用連結串列儲存檔案描述符,因此沒有了監視檔案數量的限制,但其他三個缺點依然存在。
拿select模型為例,假設我們的伺服器需要支援100萬的併發連線,則在__FD_SETSIZE 為1024的情況下,則我們至少需要開闢1k個程序才能實現100萬的併發連線。除了程序間上下文切換的時間消耗外,從核心/使用者空間大量的無腦記憶體拷貝、陣列輪詢等,是系統難以承受的。因此,基於select模型的伺服器程式,要達到10萬級別的併發訪問,是一個很難完成的任務。
iv. epoll
設想一下如下場景:有100萬個客戶端同時與一個伺服器程序保持著TCP連線。而每一時刻,通常只有幾百上千個TCP連線是活躍的(事實上大部分場景都是這種情況)。如何實現這樣的高併發?
在select/poll時代,伺服器程序每次都把這100萬個連線告訴作業系統(從使用者態複製控制代碼資料結構到核心態),讓作業系統核心去查詢這些套接字上是否有事件發生,輪詢完後,再將控制代碼資料複製到使用者態,讓伺服器應用程式輪詢處理已發生的網路事件,這一過程資源消耗較大,因此,select/poll一般只能處理幾千的併發連線。
呼叫過程
epoll的設計和實現與select完全不同。epoll通過在Linux核心中申請一個簡易的檔案系統(檔案系統一般用什麼資料結構實現?紅黑樹)。把原先的select/poll呼叫分成了3個部分:
1)呼叫epoll_create()建立一個epoll物件(在epoll檔案系統中為這個控制代碼物件分配資源)
2)呼叫epoll_ctl向epoll物件中新增這100萬個連線的套接字
3)呼叫epoll_wait收集發生的事件的連線
如此一來,要實現上面說是的場景,只需要在程序啟動時建立一個epoll物件,然後在需要的時候向這個epoll物件中新增或者刪除連線。同時,epoll_wait的效率也非常高,因為呼叫epoll_wait時,並沒有一股腦的向作業系統複製這100萬個連線的控制代碼資料,核心也不需要去遍歷全部的連線。
實現思路
當某一程序呼叫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雙鏈表中。
在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示:
struct epitem{
struct rb_node rbn;//紅黑樹節點
struct list_head rdlink;//雙向連結串列節點
struct epoll_filefd ffd; //事件控制代碼資訊
struct eventpoll *ep; //指向其所屬的eventpoll物件
struct epoll_event event; //期待發生的事件型別
}
當呼叫epoll_wait檢查是否有事件發生時,只需要檢查eventpoll物件中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件複製到使用者態,同時將事件數量返回給使用者。