可擴展的事件復用技術:epoll和kqueue


 

通常來說我喜歡Linux更甚於BSD系統,但是我真的想在Linux上擁有BSD的kqueue功能。

什么是事件復用技術

假設你有一個簡單的web服務器,並且那里已經打開了兩個socket連接。當服務器從兩個連接那里都收到Http請求的時候,它應該返回一個Http響應給客戶端。但是你沒法知道那個客戶端先發送的消息和什么時候發送的。BSD套接字接口的阻塞行為意味着,如果你在一個連接上調用recv()函數,你就沒辦法去響應另外一個連接上的請求。這時你就需要I/O復用技術。 I/O復用技術的一個直接方式是讓每個連接都擁有一個進程/線程,這樣連接上的阻塞行為就不會相互影響。這樣,你就把所有繁瑣的調度/復用問題交給了操作系統內核。這樣的多線程架構伴隨着的是高昂資源消耗。維護大量的線程對內核來說沒有什么必要。每個連接上的獨立棧不僅要增加內存痕跡,同時也降低了CPU本地緩存能力。 那么我們如何不使用線程-連接模式來實現I/O復用技術呢?你可以通過一個簡單的忙等輪詢來實現,即在每個連接上進行非阻塞的套接字操作,但這種行為過於的浪費。我們所要知道的只不過是哪個套接字已經就緒。因此系統內核為應用與內核之間提供了一個單獨的通道,這個通道在你的套接字變為就緒時會發出通知。這就是基於准備就緒模式下的select()/poll()工作模式。

概況: select()

select()和poll()的工作方式非常類似。讓我們先快速看一下select()函數

select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

調用select()函數,你的應用程序需要提供三個興趣集:r,w和e。每一個集合都是一個文件描述符的位圖。例如,如果你關注從文件描述符6里面讀取數據,那么r集合里面的第6個字節位就設成1。這個調用會被阻塞直到興趣集中有更多的文件描述符就緒,因此你可以操縱這些文件描述符而不會被阻塞。在返回后,系統內核會覆寫整個位圖來指明哪些文件描述符已經就緒。 從擴展性角度,我們可以找到4個問題:

  1. 這些位圖的大小是固定的(FD_SETSIZE, 通常是1024),盡管也有一些方法可以繞過這個限制。
  2. 由於位圖是由內核來覆寫的,用戶應用程序在每一次調用時需要重填興趣集。
  3. 每一次調用時,用戶應用程序和內核都需要掃描整個位圖,用於指出哪些文件描述符屬於興趣集,哪些屬於結果集。這對於結果集來說特別的低效,因為他們看起來非常的稀疏(如在一個給定的時間內,只有很少的文件描述符會發生變化)。
  4. 內核必須為每一次調用去迭代整個興趣集,以便找到哪些文件描述符已經就緒。假如沒有一個就緒,內核就會迭代的為每個套接字鏈接設置一個內部事件。

概況: poll()

poll()的設計意圖就是解決這些問題。

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

struct pollfd {
    int fd;
    short events;
    short revents;
}

poll()的實現不依賴於位圖,而是用文件描述符數組(這樣第一個問題就解決了)。通過對興趣事件與結果事件采取分離字段,第二個問題也得以解決,因為用戶程序可以維護並重用這個數組。如果poll函數能夠拆分該數組而不是字段,那么第三個問題也就引刃而解。第四個問題是繼承而來的而且是不可避免,因為poll()和select()都是無狀態的,內核不會在內部維護興趣集狀態。

為什么與擴展性有關?

如果你的網絡服務器需要維護一個相對較小的連接數(如100個),並且連接率也比較低(如每秒100個), 那么poll()和select()就足夠了。也許你根本不需要為事件驅動編程而苦惱,只要多進程/多線程架構就可以了。如果性能不是你關注的重點,那么靈活性與容易開發才是關鍵。Apache web服務器就是一個典型的例子。

但是,如果你的服務器程序是網絡資源敏感的(如1000個並發連接數或者一個較高的連接率),那么你就要真的在意性能問題了。這種情況通常被稱為c10k問題。你的網絡服務器將很難執行任何有用的東西,除了在這樣的高負荷下浪費寶貴的CPU周期。

假設這里有10000並發連接。一般來說,只有少量的文件描述符被使用,如10個已經讀就緒。那么每次poll()/select()被調用,就有9990個文件描述符被毫無意義的拷貝和掃描。

正如更早時候提到過的,這個問題是由於select()/poll()接口的無狀態產生的。Banga et al的論文(發布於USENIX ATC 1999)提供了一個新的建議:狀態相關興趣集。通過在內核內部維護興趣集的狀態,來取代每次調用都要提供整個興趣集這樣的方式。在decalre_interest()調用之上,內核持續的更新興趣集。用戶程序通過調用get_next_event()函數來分發事件。

靈感通常來自於研究成果,Linux和Free BSD都有它們自己的實現, 分別是epoll和kqueue。但這又意味着缺少了可移植性,一個基於epoll的程序是無法跑在Free BSD系統上的。有一種說法是kqueue技術上比epoll更優,所以看起來epoll也沒有存在的理由了。

Linux中的epoll

epoll接口由3個調用組成:

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_ctl()和epoll_ctl()本質上是分別對應到declare_interest()和get_next_event() 函數的。epoll_create()創建一個類似於文件描述符的上下文,這個上下文其實暗指進程的上下文。 從內部機制來說,epoll在Linux內核中的實現並非非常不同於select()/poll()的實現。唯一不同的地方就是是否狀態相關。因為本質上來說它們的設計目標是一樣的(基於套接字/管道的事件復用技術)。查看Linux分支樹種的源代碼文件fs/select.c(對應select和poll)和fs/eventpoll.c(對應epoll)可以得到更多的信息。 你也可以從這里找到Linus Torvalds對於epoll的早期一些想法。

Free BSD中的Kqueue

如epoll那樣,kqueue同樣支持每個進程中有多個上下文(興趣集)。kqueue()函數行為有點類似於epoll_create()。但是,kevent()卻集成了epoll_ctl()(用於調整興趣集)和epoll_wait()(獲取事件) 的角色。

int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges, 
           struct kevent *eventlist, int nevents, const struct timespec *timeout);

事實上,kqueue從易於編程角度來看相比epoll要更復雜一些。這是因為kqueue設計更抽象一些,目的更寬泛。讓我們來看一下kevent結構體:

struct kevent {
     uintptr_t       ident;          /* 事件標識 */
     int16_t         filter;         /* 事件過濾器 */
     uint16_t        flags;          /* 通用標記 */
     uint32_t        fflags;         /* 特定過濾器標記 */
     intptr_t        data;           /* 特定過濾器數據 */
     void            *udata;         /* 不透明的用戶數據標識 */
 };

這些字段的細節已經超出了本文的范圍,但你可能已經注意到了這里沒有顯式的文件描述符字段。這是因為kqueue設計的目的並非是為了替代基於套接字事件復用技術的select()/poll(),而是提供一般化的機制來處理多種操作系統事件。

過濾器字段指明了內核事件類型。如果它是EVFILT_READ或EVFILT_WRITE,kqueue就與epoll是一樣的。這種情況下,ident字段表現為一個文件描述符。ident字段也可能表現為其他類型事件的標識,如進程號和信號數目,這取決於過濾器類型。更多的細節可以從man手冊這篇文檔里找到。

epoll和kqueue的比較

性能

從性能角度講,epoll存在一個設計上的缺陷;它不能在單次系統調用中多次更新興趣集。當你的興趣集中有100個文件描述符需要更新狀態時,你不得不調用100次epoll_ctl()函數。性能降級在過渡的系統調用時表現的非常明顯,這篇文章有做解釋。我猜這是Banga et al原來工作的遺留,正如declare_interest()只支持一次調用一次更新那樣。相對的,你可以在一次的kevent調用中指定進行多次興趣集更新。

非文件類型支持

另一個問題,在我看了更重要一些,同樣也是epoll的一個限制。它的設計目的是為了提高select()/poll()的性能,epoll只能基於文件描述符工作。這有什么問題嗎? 一個常見的說法是“在unix中,所有東西都是文件”。大部分情況都是對的,但並不總是這樣。例如時鍾就不是,信號也不是,信號量也不是,包括進程也不是。(在Linux中)網絡設備也不是文件。在類Unix系統中有好多事物都不是文件。你無法對這些事物采用select()/poll()/epoll()的事件復用技術。典型的網絡服務器管理很多類型的資源,除了套接字外。你可能想通過一個單一的接口來管理它們,但是你做不到。為了避免這個問題,Linux提供了很多補充性質的系統調用,如signalfd(),eventfd()和timerfd_create()來轉換非文件類型到文件描述符,這樣你就可以使用epoll了。但是看起來不那么的優雅...你真的想讓用一個單獨的系統調用來處理每一種資源類型嗎? 在kqueue中,多才多藝的kevent結構體支持多種非文件事件。例如,你的程序可以獲得一個子進程退出事件通知(通過設置filter = EVFILT_PROC, ident = pid, 和fflags = NOTE_EXIT)。即便有些資源或事件不被當前版本的內核支持,它們也會在將來的內核中被支持,同時還不用修改任何API接口。

磁盤文件支持

最后一個問題是epoll並不支持所有的文件描述符;select()/poll()/epoll()不能工作在常規的磁盤文件上。這是因為epoll有一個強烈基於准備就緒模型的假設前提。你監視的是准備就緒的套接字,因此套接字上的順序IO調用不會發生阻塞。但是磁盤文件並不符合這種模型,因為它們總是處於就緒狀態。 磁盤I/O只有在數據沒有被緩存到內存時會發生阻塞,而不是因為客戶端沒發送消息。磁盤文件的模型是完成通知模型。在這樣的模型里,你只是產生I/O操縱,然后等待完成通知。kqueue支持這種方式,通過設置EVFILT_AIO 過濾器類型來關聯到 POSIX AIO功能上,諸如aio_read()。在Linux中,你只能祈禱因為緩存命中率高而磁盤發生不阻塞(這種情況在通常的網絡服務器上是個彩蛋),或者通過分離線程來使得磁盤I/O阻塞不會影響網絡套接字的處理(如FLASH架構)。

在我們之前的文章中,我們建議了一種新的編程接口:MegaPipe。它是完全基於完成通知模型的,可用於磁盤文件和非磁盤文件。 最后原文在這里


免責聲明!

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



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