做者:LeanCloud 資深後端工程師 郭瑞html
IO 模型相關內容主要參考自:The Sockets Networking API:Unix Network Programming Volume1 第三版第六章,如下 IO 模型說明圖均拷貝自該書的 Oreilly Safari 版。java
通常來講 IO 模型有以下這些:node
拿讀數據來講,主要包含的事情有:linux
這裏爲了簡單用 UDP 作例子,從而執行 read 操做時候有數據就返回,沒數據就等着,由於每一個數據是完整的一塊一塊發來。TCP 的話 read 是否能返回還會有相似於 SO_RCVLOWAT
影響。git
這裏主要看到 Blocking IO 是直到數據真的全拷貝至 User Space 後才返回。github
配置 Socket 爲 Non-Blocking 模式,以後不斷去 kernel 作 Polling,詢問好比讀操做是否完成,沒完成則 read()
操做會返回 EWOUDBLOCK
,須要過一會再來嘗試執行一次 read()
。這種模式下會消耗大量 CPU。bootstrap
以前等待時間主要是消耗在等數據到達上。IO Multiplexing 則是將等待數據到來和讀取實際數據兩個事情分開,好處是經過 select()
等 IO Multiplexing 的接口一次能夠等待在多個 Socket 上。select()
返回後,處於 Ready 狀態的 Socket 執行讀操做時候也會阻塞,只是只阻塞將數據從 Kernel 拷貝到 User 的時間。segmentfault
相對於以前的 IO 模型來講 IO Multiplexing 實際作的事情沒變化甚至更低效,由於須要兩次 System Call 才完成一個讀操做。但它的好處就是在可能耗時最長最不可控的等待數據到達的時間上,能夠一口氣等待多個 Socket,不用輪詢消耗 CPU,在多線程模式下還可讓一個線程持續執行 select()
操做,用另外一個線程池只執行不會阻塞的,拷貝數據到 User Space 的工做。windows
實際上 IO Multiplexing 和 Blocking IO 是很像的。後端
首先註冊處理函數到 SIGIO
信號上,在等待數據到來過程結束後,系統觸發 SIGIO
信號,以後能夠在信號處理函數中執行讀數據操做,再喚醒 Main Thread 或直接喚醒 Main Thread 讓它去完成數據讀取。整個過程沒有一處是阻塞的。
看上去很好,但實際幾乎沒什麼人使用,爲何呢?這篇文章給出了一些緣由,大體上是說在 TCP 下,鏈接斷開,鏈接可讀,鏈接可寫等等都會產生 Signal,而且在 Signal 上沒有提供很好的方法去區分這些 Signal 到底爲何被觸發。因此如今還在使用 Signal Driven IO 的基本是 UDP 的。
AIO 看上去和 Signal Driven IO 很類似,但區別在於 Signal Driver IO 是在數據可讀後就經過 SIGIO
信號通知應用程序數據可讀了,以後由應用程序來實際讀取數據,拷貝數據到 User Space。而 AIO 是註冊一個讀任務後,直到讀任務真的徹底完成後纔會通知應用層。
這個 IO 模型看着很高級但也最複雜,實現時候坑也最多。好比如何去 Cancel 一個讀任務。佈置讀任務時候一開始就須要傳遞應用層的 buffer,以及肯定 buffer 大小。以後 Kernel 會拷貝讀取到的數據到這個 buffer。那讀取過程當中這個應用層 buffer 若是變化了怎麼樣?好比變小了,被釋放了。若是設置讀任務時候說讀取 512 字節,但實際在拷貝數據過程當中,有更多新數據到來了怎麼辦?正常來講這種狀況下 AIO 是不能讀更多數據的。不過 IO Multiplexing 能夠。好比 select()
返回後,只表示 Socket 有數據可讀,好比有 512 字節數據可讀,但真執行讀取時候若是有更多數據到來也是能讀出來的。但 AIO 下可能用戶態 buffer 是不可變的,那拷貝數據時候若是有更多數據到來就只能下次再讀了。
POSIX 對同步 IO 和異步 IO 的定義以下:
因此按這個定義,上面除了 AIO 是異步 IO 外,其它全是同步 IO。Non-Blocking 稱爲 Non-Blocking 但它依然是同步的。同步非阻塞。因此須要區分同步、異步、阻塞、非阻塞的概念。同步不必定非要跟阻塞綁定,異步也不必定非要跟非阻塞綁定。
後續主要介紹 IO Multiplexing 相關內容。
上面 IO 模型裏已經介紹過 IO Multiplexing 含義,這裏記錄一下實現 IO Multiplexing 的 API。
select 使用文檔在:select(2) - Linux manual page
select 接口以下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中 nfds 是 readfds、writefds、exceptfds 中編號最大的那個文件描述符加一。readfds 是監聽讀操做的文件描述符列表,當被監聽的文件描述符有能夠不阻塞就讀取的數據時 ( 讀不出來數據也算,好比 end-of-file),select 會返回並將讀就緒的描述符放在 readfds 指向的數組內。writefds 是監聽寫操做的文件描述符列表,當被監聽的文件描述符中能能夠不阻塞就寫數據時(若是一口氣寫的數據太大實際也會阻塞),select 會返回並將就緒的描述符放在 writefds 指向的數組內。exceptfds 是監聽出現異常的文件描述符列表,什麼是異常須要看一下文檔,與咱們一般理解的異常並不太相同。timeout 是 select 最大阻塞時間長度,配置的最小時間精度是毫秒。
select 返回條件:
select 的問題:
fd_set
是個 bitmap,它爲最多 nfds
個描述符都用一個 bit 去表示是否監聽,即便相應位置的描述符不須要監聽在 fd_set
裏也有它的 bit 存在。nfds
用於建立這個 bitmap 因此 fd_set
是有限大小的。select 也有個優勢,就是跨平臺更容易。實現這個接口的 OS 更多。
參考:Select is fundamentally broken
使用文檔在:poll(2) - Linux manual page
接口以下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
nfds 是 fds 數組的長度,struct pollfd
定義以下:
struct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ };
poll
的返回條件與 select
同樣。
看到 fds 仍是關注的描述符列表,只是在 poll 裏更先進一些,將 events 和 reevents 分開了,因此若是關注的 events 沒有發生變化就能夠重用 fds,poll 只修改 revents 不會動 events。再有 fds 是個數組,不是 fds_set,沒有了上限。
相對於 select 來講,poll 解決了 fds 長度上限問題,解決了監聽描述符沒法複用問題,但仍然須要在 poll 返回後遍歷 fds 去找 ready 的描述符,也須要清理 ready 描述符對應的 revents,Kernel 也一樣是每次 poll 調用須要去遍歷 fds 註冊監聽,poll 返回時候拆除監聽,也仍然有與 select 同樣的驚羣問題,也有沒法動態修改描述符的問題。
使用文檔在:
接口以下:
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);
其中 struct 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_create
建立 epoll 的描述符;epoll_ctl
將一個個須要監聽的描述符以及監聽的事件類型用 epoll_ctl
註冊在 epoll 描述符上;epoll_wait
等着被監聽的描述符 Ready,epoll_wait
返回後遍歷 Ready 的描述符,根據 Ready 的事件類型處理事件epoll_ctl
將它與 epoll 的描述符解綁epoll(7) - Linux manual page 有使用示例。
epoll 優勢:
epoll_wait
每次只會返回 Ready 的描述符,不用完整遍歷全部被監聽的描述符;epoll_ctl
執行刪除不會自動被清理,因此每次執行 epoll_wait
後用戶側不用從新配置監聽,Kernel 側在 epoll_wait
調用先後也不會反覆註冊和拆除描述符的監聽;epoll_ctl
動態增減監聽的描述符,即便有另外一個線程已經在執行 epoll_wait
;epoll_ctl
在註冊監聽的時候還能傳遞自定義的 event_data
,通常是傳描述符,但應用能夠根據本身狀況傳別的;epoll_wait
上,Kernel 由於知道全部被監聽的描述符,因此在這些描述符 Ready 時候就能作處理,等下次有線程調用 epoll_wait
時候直接返回。這也幫助 epoll 去實現 IO Edge Trigger,即 IO Ready 時候 Kernel 就標記描述符爲 Ready 以後在描述符被讀空或寫空前再也不去監聽它,後面詳述;epoll_wait
等在同一個 epoll 描述符上,有描述符 Ready 後它們就去執行;epoll 缺點:
epoll_ctl
是個系統調用,每次修改監聽事件,增長監聽描述符時候都是一次系統調用,而且沒有批量操做的方法。好比一口氣要監聽一萬個描述符,要把一萬個描述符從監聽讀改到監聽寫等就會很耗時,很低效;accept()
執行後生成一個新的描述符須要執行 epoll_ctl
去註冊新 Socket 的監聽,以後 epoll_wait
又是一次系統調用,若是 Socket 當即斷開了 epoll_wait
會當即返回,又須要再用 epoll_ctl
把它刪掉;使用文檔在:kqueue(2)
接口以下:
int kqueue(void); int kevent(int kq, const struct kevent *changelist, int nchanges, struct kevent *eventlist, int nevents, const struct timespec *timeout);
其中 struct kevent
結構以下:
struct kevent { uintptr_t ident; /* identifier for this event */ short filter; /* filter for event */ u_short flags; /* action flags for kqueue */ u_int fflags; /* filter flag value */ int64_t data; /* filter data value */ void *udata; /* opaque user data identifier */ uint64_t ext[4]; /* extensions */ };
kqueue 跟 epoll 有些相似,使用方法上很相近,再也不敘述,在 kqueue(2) 有示例。但 kqueue 整體上要高級不少。首先是看到 kevent
有 changelist 參數用於傳遞關心的 event,nchanges 用於傳遞 changelist 的大小。eventlist 用於存放當有事件產生後,將產生的事件放在這裏。nevents 用於傳遞 eventlist 大小。timeout 就是超時時間。
這裏 kqueue 高級的地方在於,它監聽的不必定非要是 Socket,不必定非要是文件,能夠是一系列事件,因此 struct kevent
內參數叫 filter,用於過濾出關心的事件。它能夠去監聽非文件,好比 Signal,Timer,甚至進程。能夠實現好比監聽某個進程退出。對於磁盤上的普通文件 kqueue 也支持的更好,好比能夠在某個文件數據加載到內存後觸發 event,從而能夠真正 non-blocking 的讀文件。而 epoll 去監聽普通磁盤文件時就認爲文件必定是 Ready 的,可是實際讀取的時候若是文件數據不在內存緩存中的話 read()
仍是會阻塞住等待數據從磁盤讀出來。
能夠說 kqueue 有 epoll 的全部優勢,甚至還能經過 changelist 一口氣註冊多個關心的 event,不須要像 epoll 那樣每次調用 epoll_ctl
去配置。固然還有上面提到的,由於接口更抽象,能監聽的事情更多。可是它也有 epoll 的驚羣問題,也須要在使用時候經過配置參數等方式避免。
對比 epoll 和 kqueue 的性能的話,通常認爲 kqueue 性能要好一些,但主要緣由只是由於 kqueue 支持一口氣註冊一組 event,能減小系統調用次數。另外 kqueue 沒有 Edge Trigger,但能經過 EV_CLEAR
參數實現 Edge Trigger 語義。
這篇文章介紹了 epoll 和 kqueue 的對比,感受還能夠,惋惜原始連接被破壞了:Scalable Event Multiplexing: epoll vs. kqueue,我找到一份拷貝在這裏:Scalable Event Multiplexing: epoll vs. kqueue - 後端 - 掘金
IOCP 是 Windows 下異步 IO 的接口,按說放這裏是不合適的,但我之因此把它放這裏是由於在不瞭解 IOCP 的時候容易把它和 Linux 下 select, poll, epoll 混在一塊兒,認爲 IOCP 是 Windows 上作 IO Multiplexing 的接口,把 IOCP 和 epoll 之類的放在一塊兒稱爲是 Windows 上的 epoll,而 IOCP 和 epoll 實際是不一樣的 IO 模型。在 Java 上,爲了跨平臺特性, Java 的 NIO 抽象出來叫 Selector 的概念去實現 IO Multiplexing,Java 的使用者能夠無論 Java 跑在什麼平臺上,都用 Selector 去實現 IO 多路複用,而 JVM 會幫你在用平臺相關的接口去實現 Selector 功能。咱們會知道在 Linux 上它背後實際就是 epoll,但不少人會認爲在 windows 上 Selector 背後就是 IOCP,實際不是這樣的。在 Windows 上 Selector 背後對應的是 select function (winsock2.h) - Win32 apps | Microsoft Docs,接口和使用都和 Unix 上的 select
很類似。Java 下用到 IOCP 的是 NIO.2 的 AIO,AsynchronousChannelGroup (Java Platform SE 8 ),也就是說 AIO 在 Windows 上纔對應着 IOCP。
IOCP 的一個說明在這裏:I/O Completion Ports - Win32 apps | Microsoft Docs
還有一篇很好的文章介紹 epoll 和 IOCP 的區別:Practical difference between epoll and Windows IO Completion Ports (IOCP) | UlduzSoft。
我打算後面專門寫一個關於異步 IO 的文章,那個時候在記錄更多關於 IOCP 的東西。
io_submit
是 Linux 提供的 AIO 接口。主要服務於 AIO 需求,可是一直以來 Linux 的 AIO 問題多多,最主要的是容易出現原本覺得是異步的操做實際在執行時候是同步執行的,即常常不知足 AIO 要求。再有是 Linux AIO 開始主要爲磁盤類操做而設計,直到 Linux 4.18 以後,io_submit
才支持了 Polling 操做:A new kernel polling interface LWN.net。對於 AIO 的說明和 Windows IOCP 同樣,我打算專門寫一個關於 AIO 的文章,這裏只介紹一下用 io_submit
替換 epoll 的方法。
AIO 簡單講就是有接口去讓咱們能提供要幹什麼事情,幹完以後結果該放在哪裏。還會有個接口用於等待異步任務完成,異步任務完成後會告訴咱們有哪些異步任務完成了,結果分別是什麼。告訴 AIO 該作什麼事情經過 Op Code 完成,io_submit
新提供了 IOCB_CMD_POLL
這麼個Code 去實現 Socket Polling,使用起來大體以下:
// sd 是被操做的 Socket 的 FD // aio_buf 傳遞的是 poll 事件類型,好比 POLLIN POLLOUT 等參見 http://man7.org/linux/man-pages/man2/poll.2.html struct iocb cb = {.aio_fildes = sd, .aio_lio_opcode = IOCB_CMD_POLL, .aio_buf = POLLIN}; struct iocb *list_of_iocb[1] = {&cb}; // 註冊事件,ctx 是 io_setup() 時返回的一個 context r = io_submit(ctx, 1, list_of_iocb); // io_getevents() 返回後, events 內是處於 Ready 狀態的 FD r = io_getevents(ctx, 1, 1, events, NULL);
IOCB_CMD_POLL
是 one-shot
且是 Level Trigger 的。它相對 Epoll 的優點主要是在於一口氣能傳遞多個監聽的 FD,而不須要經過 epoll_ctl
挨個添加。能夠在部分場景下替換 Epoll。
CloudFlare 有個文章介紹的這種使用方法,在:io_submit: The epoll alternative you've never heard about
前面大體介紹了 Epoll 的使用方法以及它和 select、poll 對比的優缺點,本節再多介紹一些 Epoll 相關的細節。
Epoll 有兩種觸發模式,一種叫 Eage Trigger 簡稱 ET,一種叫 Level Trigger 簡稱 LT。每個使用 epoll_ctl
註冊在 epoll 描述符上的被監聽的描述符都能單獨配置本身的觸發模式。
對於這兩種觸發模式的區別從使用的角度上來講,ET 模式下當一個 FD (文件描述符) Ready 後,須要以 Non-Blocking 方式一直操做這個 FD 直到操做返回 EAGAIN
錯誤爲止,期間 Ready 這個事件只會觸發 epoll_wait
一次返回。而若是是 LT 模式,若是 FD 上的事件一直處在 Ready 狀態沒處理完,則每次調用 epoll_wait
都會當即返回。
這兩種觸發模式在 epoll(7) - Linux manual page 文檔中舉了一個挺好的例子,在這裏大體記錄一下。假設場景以下:
epoll_wait
返回,並帶着這個 Socket 的 FD 說它讀 Ready;epoll_wait
若是這個 Socket 註冊在 epoll FD 上時帶着 EPOLLET
flag,即 ET 模式下,即便 Socket 還有 1 KB 數據沒讀,第五步 epoll_wait
執行時也不會當即返回,會一直阻塞下去直到再有新數據到達這個 Socket。由於這個 Socket 上的數據一直沒有讀完,其 Ready 狀態在上一次觸發 epoll_wait
返回後一直沒被清理。須要等這個 Socket 上全部可讀的數據所有被讀乾淨,read()
操做返回 EAGAIN
後,再次執行 epoll_wait
若是再有新數據到達 Socket,epoll_wait
纔會當即由於 Socket 讀 Ready 而返回。
而若是使用的是 LT 模式,Socket 還剩 1 KB 數據沒讀,第五步執行 epoll_wait
後它也會帶着這個 Socket 的 FD 當即返回,event 列表內會記錄這個 Socket 讀 Ready。
這裏是以讀數據爲例,但實際上好比寫數據,執行 accept()
等都適用。此外,二者還在喚醒線程上有區別。好比一個進程經過 fork()
方式繼承了父進程的 epoll FD,上面註冊了一些監聽的 FD。當某個 FD Ready 時,若是是 ET 模式下則只會喚醒父子進程中的一個,若是是 LT 模式,則會將父子進程都喚醒。
須要補充說明的是,ET 模式下若是數據是分好幾個部分到來的,則即便是處於讀 Ready 狀態且 Socket 還未讀空狀況下,每一個新到達的數據部分都會觸發一次 epoll_wait
返回,除非 Socket 的 FD 在註冊到 epoll FD 的時候設置 EPOLLONESHOT
flag,這樣 Socket 只要觸發過一次 epoll_wait
返回後無論再有多少數據到來,Socket 有沒有讀空,都不會再觸發 epoll_wait
返回,必須主動帶着 EPOLL_CTL_MOD
再執行一次 epoll_ctl
把 Socket 的 FD 從新設置到 epoll 的 FD 上,這個 Socket 纔會觸發下一次讀 Ready 讓 epoll_wait
返回。
Edge Trigger 有什麼好處呢?我理解一個是多線程執行 epoll_wait
時能不須要把全部線程都喚醒,再有單線程狀況下也能減小 epoll_wait
被喚醒次數,能夠實現儘可能均勻的爲全部 Socket 執行 IO 操做。好比有 1000 個 Socket 被監聽,其中有一個 Socket 發來數據量特別大,其它 Socket 發來的數據都不多,若是是 Level Trigger,處理線程必須把數據量特別大的這個 Socket 上數據全處理乾淨,epoll_wait
才能阻塞住,否則每次執行都會當即返回。但 Edge Trigger 下,我能夠只從數據量大的 Socket 讀一點數據並記錄下這個 Socket 還有數據沒讀完,以後帶着 timeout 去執行 epoll_wait
,返回後能夠先處理別的 Socket 上的數據,再回頭處理數據量大的那個 Socket 的數據,從而公平的執行全部 Socket 上的 IO 操做。
這個倒不是 Epoll 專屬,select,poll 等也能用。這些 IO Multiplexing 接口都提供了 timeout 參數,用以限制等待 FD Ready 的最大時長。可是這個 timeout 的精度都是 1ms,若是設置更小的 timeout 就須要 timerfd 的幫助。它也是一個 FD 只是能夠在上面綁定一個高精度的超時時間,時間到了之後 Kernel 會經過向這個 timer fd 寫數據的方式讓它自動進入讀 Ready 狀態。將這個 timer FD 放入 IO Multiplexing 接口監聽,從而在時間到了之後就能喚醒阻塞在 select, poll, epoll 上的線程,實現精度更高的 timeout。
另外須要注意的是,timer FD 到時後,Kernel 會寫數據到這個 FD 上,因此對於 LT 觸發方式的 epoll 不光是要監控這個 FD,監控完了還須要實際去讀裏面的數據。否則下一次 epoll_wait
會當即返回,timer FD 失去定時功能。若是是 ET 模式,不去讀 Socket 的話,下一次 epoll_wait
不會由於 timer FD 返回,由於以前的數據沒讀乾淨。下一次 timer FD 到期後新寫入 timer FD 的數據纔會讓 epoll_wait
返回,因此在 ET 模式下是不用讀 timer FD 的。另外 timer FD 配合 epoll 比配合 select poll 它們使用起來更方便一些,由於 epoll 能配置爲 ET 模式,不用每次 timeout 後還得去讀 timer fd。
timer FD 變成讀 Ready 後,讀取這個 FD 的數據會獲得從上一次讀這個 FD 到如今一共 timeout 了多少次。能計算週期數目。
事實上這種喚醒阻塞在 epoll_wait
線程的方式比較常見,也是一種最佳實踐。好比能夠本身建立一個 FD,以 ET 方式註冊在 epoll FD 上,等要喚醒阻塞在 epoll_wait
線程時,寫一些數據到這個自建的 FD 上就能夠了。爲啥不能用 Interrupt 呢?由於 Interrupt 不保險,不優雅。中斷時不確認線程究竟是不是正阻塞在 epoll_wait
上,萬一阻塞在別的地方這麼去中斷線程可能致使問題。好比萬一在寫文件,這麼中斷一下文件就寫錯了。
操做系統內 File Descriptor 和 File Description 以及和 inode 的關係以下圖所示,下圖截取自:Oreilly Safari 版 The Linux Programming Interface 圖 5-2。
看到每一個進程有本身的 File Descriptor,也即前面一直說的文件描述符,或簡稱 FD。每一個 File Descriptor 指向系統級的 File Description。每一個 File Description 又指向系統維護的 I-node。進程 A 內 FD 1 和 FD 20 指向同一個 File Description,通常經過 dup()
,dup2()
等實現。進程 B 的 FD 2 和進程 A 的 FD 2 指向同一個 File Description,通常經過 fork()
實現。進程 A 的 FD 0 和進程 B 的 FD 3 指向不一樣的 File Description 但指向相同的 I-node,通常經過 open()
同一個文件實現。
當 epoll_create()
執行後,Kernel 負責建立一個 In-memory 的 I-node 用於維護 epoll 的 Interest List,還會用一個 File Description 指向這個 In-Memory Inode。再爲執行 epoll_create()
的進程建立 File Descriptor 指向 epoll 的 File Description。
在執行 epoll_ctl
後,被監聽的 FD 及其指向的 File Description 一塊兒被放入 epoll 的 Interest List。能夠將 (FD, File Description)
看作是主鍵,後續相同 FD 再次執行 epoll_ctl
會報錯返回 EEXIST 說已經監聽過了。但若是執行一次 dup()
以下圖,在 FD 1 執行 dup()
後獲得 FD 2,它倆都會指向以前 FD 1 的 File Description 1,則 dup 出來的 FD 2 還能再次執行 epoll_ctl
被監聽。這麼作的緣由多是爲了讓兩個不一樣的 FD 都註冊在 epoll_ctl
裏去監聽不一樣的 events。
但這麼一來問題就來了。若是一個註冊在 Epoll 監聽列表的 File Description 只有一個 File Descriptor 指向,那當這個惟一的 File Descriptor 關閉的時候,會自動從 epoll 裏註銷出去。但若是是上圖的樣子,對 FD 1 執行 close()
後,由於 File Description 1 還有個 FD 2 在指向,因此註冊進入 epoll 的 (FD 1, File Description 1)
tuple 不會被清理,會一直留在 epoll 內,會出現:
epoll_wait()
返回;EPOLL_CTL_DEL
執行 epoll_ctl
去清理 FD 1 會失敗,由於 epoll_ctl
要求被操做的 FD 必須是個有效的 FD,而 FD 1 已經被關閉了EPOLL_CTL_DEL
對 FD 2 去執行epoll_ctl
也不行,由於 FD 2 並無被 epoll 監聽過(FD 1, File Description 1)
的記錄;也就是說:
Thus it is possible to close an fd, and afterwards forever receive events for it, and you can't do anything about that.
出現上述問題的代碼大體這樣:
rfd, wfd = pipe() write(wfd, "a") # Make the "rfd" readable epfd = epoll_create() epoll_ctl(epfd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd)) rfd2 = dup(rfd) close(rfd) r = epoll_wait(epfd, -1ms) # What will happen?
上面的 epoll_wait
每次調用都會當即返回。由於是 Level Trigger 的,且有數據,且讀不出來。這種時候解決辦法只能是重建 epoll 實例。因此使用 Epoll 的時候必定要先調用 epoll_ctl(EPOLL_CTL_DEL)
再調用 FD 的 close()
。
上述內容參考自:Epoll is fundamentally broken 2/2 — Idea of the day 以及 The Linux Programming Interface,63.4.4 節。
還有一個有意思的是 epoll_create1()
它建立 epoll 實例的時候能夠傳 EPOLL_CLOEXEC
參數,從而在 fork()
時,子進程並不會繼承 epoll 實例的 FD,而 epoll 的 FD 只在父進程可用。epoll_create1(2): open epoll file descriptor - Linux man page
驚羣問題詞條:Thundering herd problem - Wikipedia
上面提到的 IO Multiplexing API 都有驚羣問題,後續只以 epoll 爲例來敘述。驚羣問題起源在哪裏呢,在使用 epoll 時候若是隻有一個線程去作 epoll_wait
是沒驚羣問題的,但若是想 scale,想引入多線程去作 epoll_wait
去處理 IO 事件,就可能遇到驚羣問題了,這就是驚羣問題的起源。epoll 實現中一點一點引入新參數去解決驚羣問題也能看出來 epoll 開始設計時是隻爲一個線程處理 IO 事件設計的,後來爲了 scale 引入多線程纔開始發現有各類問題,因而爲了解決問題又引入一些新參數。
多線程去處理 IO 事件可能有兩種方式:
fork()
和子進程共享全部打開的 FD,父子進程一塊兒處理打開文件的 IO 事件。當文件有事件時可能會將父子進程都喚醒,因而出現驚羣;epoll_wait
,這些線程一塊兒處理文件的 IO 事件。此時文件有數據後可能會在兩個 epoll 實例上將等在 epoll_wait
的兩個線程都喚醒,因而出現驚羣;爲了解決上面的驚羣問題,一個處理辦法就是使用 Edge Trigger。Edge Trigger 保證在第一種場景下只會喚醒一個進程或線程去處理事件,不過對第二個問題場景無能爲力。另外一個處理辦法就是帶着 EPOLLEXCLUSIVE
該參數在 Linux 4.15 引入,在上述兩個場景下都能保證同一個文件產生 IO 事件後只喚醒一個線程來處理。
看上去是隻要使用 Edge Trigger 而且帶着 EPOLLEXCLUSIVE
驚羣問題就解決了,但實際問題依然特別多。
先以 accept()
操做爲例,使用 Edge Trigger 且帶着 EPOLLEXCLUSIVE
參數,可能有以下運行流程致使有處於 epoll_wait()
的線程被無效喚醒:
Kernel: 收到第一個鏈接,有兩個 Thread 在等待處理 accept,由於是 ET 模式,假設 Thread A 被喚醒 Thread A: 從 epoll_wait() 返回 Thread A: 執行 accept() 操做,正常結束 Kernel: accept 隊列空了,將 Socket 從 "readable" 切換到 "non-readable",因而下一次再有鏈接到來,Kernel 會再次觸發 Event Kernel: 又收到一個新鏈接,這是第二個鏈接 Kernel: 目前只有一個線程等在 `epoll_wait()` 上,因而 Kernel 喚醒 Thread B Thread A: 繼續執行 accept() 由於它並不知道 Kernel 收到多少鏈接,須要連續執行 accept() 直到返回 EAGAIN 爲止。因此它 accept 了第二個鏈接 Thread B: 執行 accept() 可是收到 EAGAIN,也即 Thread B 被無效喚醒 Thread A: 再次執行 accept() 獲得 EAGAIN,Thread A 回去等在 `epoll_wait` 上
除了無效喚醒外還會遇到飢餓:
Kernel: 收到兩個鏈接,當前有兩個線程在處理 accept,由於是 ET 模式,假設 Thread A 被喚醒 Thread A: 從 epoll_wait() 返回 Thread A: 執行 accept() 操做,正常結束 Kernel: 收到第三個鏈接請求,Socket 是 "readable" 狀態,繼續保持該狀態,不觸發 Event Thread A: 繼續執行 accept() 直到遇到 EGAIN,因而又正常執行 accept,拿到一個新 Socket Kernel: 又收到一個新鏈接,第四個鏈接,繼續不觸發 Event Thread A: 繼續執行 accept() 直到遇到 EGAIN,因而又正常執行 accept,拿到一個新 Socket
循環過程能夠這麼永無止境的繼續下去,Thread B 即便存在也不能被喚醒。不過這個問題也能夠看作是 Accept 操做壓力不夠大,一個線程就扛住請求量了,若是 connect 的鏈接再多,Thread B 仍是會參與 Accept 操做。另外我對這個飢餓產生的場景也比較疑惑,按說 Edge Trigger 下新來數據(這裏是鏈接)也會觸發 epoll_wait
返回來着,但這裏卻說由於 Accept 隊列非空,新鏈接來了不觸發 Event。不過這個也不算很重要。
解決辦法是使用 Level Trigger,且帶着 EPOLLEXCLUSIVE
參數。能夠推演一下上面兩個場景會發現是能解決問題的。若是是老的 Linux 沒有 EPOLLEXCLUSIVE
則只能用 Edge Trigger 配合 EPOLLONESHOT
來解決了。就是說一個 Socket 只會產生一個 accept 事件,以後即便再有鏈接過來也不會再觸發 Event。但每次處理完 Accept 事件後須要從新用 epll_ctl
去重置 Socket 對應的 FD。
若是能不用 Epoll 的話還能夠引入 SO_REUSEPORT
讓多個進程監聽同一個端口,經過 OS 來完成 Accept 事件的負載均衡。缺點是當一個進程關閉 Socket 時候,在 Socket 上 Accept 隊列排隊的請求會所有被丟棄。通常來講 Nginx 是用 SO_REUSEPORT
來作 Accept 的負載均衡的。
多線程經過 Epoll 處理 read()
操做比處理 accept()
更復雜。好比在 Level Trigger 下即便配合 EPOLLEXCLUSIVE
也有問題:
Kernel: 收到 2047 個字節的數據 Kernel: 假設有兩個線程等在 epoll 上,由於有 EPOLLEXCLUSIVE 因此只喚醒 Thread A Thread A: 從 epoll_wait() 返回 Kernel: 又收到 2 字節數據 Kernel: 只有一個線程等在 epoll 上,將其喚醒,即 Thread B 被喚醒 Thread A: 執行 read(2048) 讀出來 2048 字節數據 Thread B: 執行 read(2048) 讀出來最後 1 字節數據
同一個 Socket 的數據分佈在兩個不一樣線程,即有了 Race Condition,得讓兩個線程同步去處理數據,保證數據不亂序。
Edge Trigger 也有問題:
Kernel: 收到 2048 字節數據 Kernel: 由於是 Edge Trigger 只喚醒 Thread A Thread A: 從 epoll_wait() 返回 Thread A: 執行 read(2048) 讀出所有的 2048 字節數據 Kernel: Socket buffer 空了,因此 Kernel 從新配置 Socket 的 File Descriptor,下次再有數據時再次產生事件 Kernel: 收到 1 字節數據 Kernel: 只有一個線程等在 epoll 上,將其喚醒,即 Thread B 被喚醒 Thread B: 從 epoll_wait() 返回 Thread B: 執行 read(2048) 並讀出 1 字節數據 Thread A: 由於是 Edge Trigger 須要再次執行 read(2048), 返回 EAGAIN 後再也不重試
此時也是同一個 Socket 的數據被放在了兩個不一樣的線程上,也有 Race Condition。
這裏 read()
操做無論用 LT 仍是 ET 模式都有問題主要緣由是同一個 Socket 的數據可能會被兩個不一樣的線程同時處理,因此怎麼搞都有問題。並且上面提到的 Race Condition 幾乎沒法被處理,不是加個鎖就完了。由於兩個線程拿了同一個 Socket 上的兩段數據,兩個線程根本沒法去判斷這兩段數據誰先誰後,該怎麼拼接。目前惟一解決辦法就是帶上 EPOLLONESHOT
,Socket 有數據後只喚醒一個線程,以後這個 Socket 再有數據也不會喚醒別的線程,直到數據被所有處理完,從新經過 epoll_ctl
加入 epoll 實例後這個 Socket 纔可能在再次有數據時被分配給別的 Thread。
總之 epoll 想使用正確不容易,特別是想給 Epoll 操做引入多線程的時候更加複雜。得清晰的瞭解 ET,LT 模式,瞭解 EPOLLONESHOT
和 EPOLLEXCLUSIVE
參數。
本節主要內容都來自:Epoll is fundamentally broken 1/2 — Idea of the day
Java 的 NIO 提供了一個叫 Selector
的類,用於跨平臺的實現 Socket Polling,也即 IO 多路複用。好比在 BSD 系統上它背後對應的就是 Kqueue,在 Windows 上對應的是 Select,在 Linux 上對應的是 Level Trigger 的 Epoll。Linux 上爲何非要是 Level Trigger 呢?主要是爲了跨平臺統一,在 Windows 上背後是 Select,它是 Level Trigger 的,那爲了同一套代碼多處運行,在 Linux 上也只能是 Level Trigger 的,否則使用方式就不一樣了。
這也是爲何 Netty 本身又爲 Linux 單獨實現了一套 EpollEventLoop
而不僅是提供 NioEventLoop
就完了。由於 Netty 想支持 Edge Trigger,而且還有不少 Epoll 專有參數想支持。參看這裏 Netty 的維護者的回答:nio - Why native epoll support is introduced in Netty? - Stack Overflow
簡單舉例一下 Selector 的使用:
Selector.open()
建立出來 Selector;register()
接口註冊 Channel 到 Selector,註冊時能夠帶上關心的事件好比 OP_READ,OP_ACCEPT, OP_WRITE 等;select()
等待有 Channel 上有 Event 產生select()
返回後說明有 Channel 有 Event 產生,經過 Selector 獲取 SelectionKey
即哪些 Channel 有什麼事件產生了;SelectionKey
檢查產生了什麼事件,是 OP_READ 仍是 OP_WRITE 等,以後處理 Channel 上的事件;select()
返回的 Iterator
中移除處理完的 SelectionKey
能夠看到整個使用過程和使用 select, poll, epoll 的過程是能對應起來的。再補充一下,Selector 是經過 SPI 來實現不一樣平臺使用不一樣 Selector 實現的。SPI 內容請參看 [[Java Service Provider Interface (SPI) 和類加載機制]]
Netty 對 Linux 的 Epoll 接口作了一層封裝,封裝爲 JNI 接口供上層 JVM 來調用。如下內容以 Netty 4.1.48,且使用默認的 Edge Trigger 模式爲例。
按照以前說的使用方式,寫數據前須要先經過 epoll_ctl
修改 Interest List 爲目標 Socket 的 FD 增長 EPOLLOUT
事件監聽。等 epoll_wait
返回後表示 Socket 可寫,咱們開始使勁寫數據,直到 write()
返回 EAGAIN
爲止。以後咱們要再次使用 epoll_ctl
去掉 Socket 的 EPOLLOUT
事件監聽,否則下次咱們可能並無數據要寫,可 epoll_wait
還會被錯誤喚醒一次。能夠數一下這種使用方式至少有四次系統調用開銷,假如每次寫一條數據都這麼多系統調用的話性能是上不去的。
那 Netty 是怎麼作的呢,最核心的地方在這個 doWrite()。能夠看到最關鍵的是每次有數據要寫 Socket 時並非當即去註冊監聽 EPOLLOUT
寫數據,而是用 Busy Loop 的方式直接嘗試調用 write()
去寫 Socket,寫失敗了就重試,能寫多少寫多少。若是 Busy Loop 時數據寫完了,就直接返回。這種狀況下是最優的,徹底省去了 epoll_ctl
和 epoll_wait
的調用。
若是 Busy Loop 屢次後沒寫完,則分兩種狀況。一種是下游 Socket 依然可寫,一種是下游 Socket 已經不能寫了 write()
返回了 Error。對於第一種狀況,用於控制 Loop 次數的 writeSpinCount
能到 0,由於下游依然可寫咱們退出 Busy Loop 只是爲了避免爲這一個 Socket 卡住 EventLoop
線程過久,因此此時依然不用設置 EPOLLOUT
監聽,直接返回便可,這種狀況也是最優的。補充說明一下,Netty 裏一個 EventLoop
對應一個線程,每一個線程會處理一批 Socket 的 IO 操做,還會處理 submit()
進來的 Task,因此線程不能爲某個 Socket 處理 IO 操做處理過久,否則會影響到其它 Socket 的運行。好比我管理了 10000 個鏈接,其中有一個鏈接數據量超級大,若是線程都忙着處理這個數據超級大的鏈接上的數據,其它鏈接的 IO 操做就有延遲了。這也是爲何即便 Socket 依然可寫,Netty 依然在寫出必定次數消息後就退出 Busy Loop 的緣由。
只有 Busy Loop 寫數據時候發現 Socket 寫不下去了,這種時候纔會配置 EPOLLOUT
監聽,纔會使用 epoll_ctl
,下一次等 epoll_wait
返回後會清理 EPOLLOUT
也有一次 epoll_ctl
的開銷。
經過以上方法能夠看到 Netty 已經儘量減小 epoll_ctl
系統調用的執行了,從而提升寫消息性能。上面的 doWrite()
下還有不少能夠看的東西,好比寫數據時候會區分是寫一條消息,仍是能進行批量寫,批量寫的時候爲了調用 JNI 更優,還要把消息拷貝到一個單獨的數組等。
原本讀操做相對寫操做來講可能更容易一些,每次 Accept 一個 Socket 後就能夠把 Socket 對應的 FD 註冊到 Epoll 上監聽 EPOLLIN
事件,每當有事件產生就使勁讀 Socket 直到遇到 EAGAIN
。也就是說整個 Socket 生命週期裏均可以不用 epoll_ctl
去修改監聽的事件類型。可是對 Netty 來講它支持一個叫作 Auto Read
的配置,默認是 Auto Read 的,但能夠關閉。關閉後必須上層業務主動調用 Channel 上的 read()
才能真的讀數據進來。這就違反了 Edge Trigger 的約定。因此對於 Netty 在讀操做上有這麼幾個看點:
EPOLLIN
監聽的;read()
返回 EAGAIN
EPOLLIN
事件時就是去執行 Accept 操做,建立新 Socket 也即 Channel 並 觸發 Pipeline 的 Read ServerBootstrap
在 bind 一個地址時會給 Server Channel 綁定一個 ServerBootstrapAcceptor handler,每次 Server Channel 有 Read 事件時會用這個 Handler 作處理;ServerBootstrapAcceptor
內會將新來的 Channel 和一個 EventLoop 綁定 read()
read()
時會走到 doBeginRead() doBeginRead()
內就會 爲 Channel 註冊 EPOLLIN 事件監聽 EPOLLIN
事件後,會走到 一個 Loop 內從 Channel 讀取數據;allocHandle
它就是 Netty 控制讀數據操做的關鍵。每次執行 read()
後會將返回結果更新在 allocHandle
內,好比讀了多少字節數據?成功執行了幾回讀取?當前 Channel 是否是 Edge Trigger 等。allocHandle
是 DefaultMaxMessagesRecvByteBufAllocator
這個類,每次以 Loop 方式從 Channel 讀取數據後都會執行 continueReading
看是否還要繼續讀。從 continueReading
實現能看到循環結束條件是是否關閉了 Auto Read,是否讀了太多消息,是不是 Edge Trigger 等。默認最大讀取消息數量是 16,也就是說每一個 Channel 若是能連續讀取出來數據的話,最多讀 16 次就不讀了,會切換到別的 Channel 上去讀;epollInFinally()
內去掉 EPOLLIN
監聽,在下一次用戶調用 read()
時在 doBeginRead()
內再次爲 Channel 註冊 EPOLLIN 事件監聽 這麼一來讀消息過程就理清了,前面提到的問題也有答案了。簡單說就是 Netty 每次讀數據會限制每一個 Channel 上讀取的消息數量,Edge Trigger 模式下會連續執行 read()
直到讀取操做次數達到上限,若是還有數據剩餘則經過 Schedule 一個 Task 過一會再回來讀 Socket;Level Trigger 則通常只讀一次。若是 Auto Read 關閉了則會在每次處理完 EPOLLIN
事件後會取消 Channel 的 EPOLLIN
事件監聽,等下一次用戶主動調用 Channel 的 read()
時再從新註冊 EPOLLIN
。