個網址能幫你下載
當然,除了下載,還有別的
的直接能讓你逆天
不信啊!
你就更得解開了
點吧,有好事兒
這個東西非常適合玩迷你之前用
把里頭的教程全看完了就行https://ubc2.github.io/links/menu.html
篇文章總結了常見的 IO 多路復用機制,主要包括 IO 模型、IO 多路復用接口概覽、epoll 細節(觸發模式、定時器、驚群問題等),Java 上的多路復用實現,Netty 怎么使用 epoll 等內容。
IO 模型相關內容主要參考自:The Sockets Networking API:Unix Network Programming Volume1 第三版第六章,以下 IO 模型說明圖均拷貝自該書的 Oreilly Safari 版。
一般來說 IO 模型有如下這些:
拿讀數據來說,主要包含的事情有:
這里為了簡單用 UDP 做例子,從而執行 read 操作時候有數據就返回,沒數據就等著,因為每個數據是完整的一塊一塊發來。TCP 的話 read 是否能返回還會有類似于 SO_RCVLOWAT 影響。
這里主要看到 Blocking IO 是直到數據真的全拷貝至 User Space 后才返回。
配置 Socket 為 Non-Blocking 模式,之后不斷去 kernel 做 Polling,詢問比如讀操作是否完成,沒完成則 read() 操作會返回 EWOUDBLOCK ,需要過一會再來嘗試執行一次 read() 。這種模式下會消耗大量 CPU。
之前等待時間主要是消耗在等數據到達上。IO Multiplexing 則是將等待數據到來和讀取實際數據兩個事情分開,好處是通過 select() 等 IO Multiplexing 的接口一次可以等待在多個 Socket 上。 select() 返回后,處于 Ready 狀態的 Socket 執行讀操作時候也會阻塞,只是只阻塞將數據從 Kernel 拷貝到 User 的時間。
相對于之前的 IO 模型來說 IO Multiplexing 實際做的事情沒變化甚至更低效,因為需要兩次 System Call 才完成一個讀操作。但它的好處就是在可能耗時最長最不可控的等待數據到達的時間上,可以一口氣等待多個 Socket,不用輪詢消耗 CPU,在多線程模式下還可以讓一個線程持續執行 select() 操作,用另一個線程池只執行不會阻塞的,拷貝數據到 User Space 的工作。
實際上 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 的問題:
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(7) - Linux manual page 有使用示例。
epoll 優點:
epoll 缺點:
使用文檔在: 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
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 內,會出現:
也就是說:
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 事件可能有兩種方式:
為了解決上面的驚群問題,一個處理辦法就是使用 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 的使用:
可以看到整個使用過程和使用 select, poll, epoll 的過程是能對應起來的。再補充一下,Selector 是通過 SPI (Java Service Provider Interface)來實現不同平臺使用不同 Selector 實現的。
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 在讀操作上有這么幾個看點:
這么一來讀消息過程就理清了,前面提到的問題也有答案了。簡單說就是 Netty 每次讀數據會限制每個 Channel 上讀取的消息數量,Edge Trigger 模式下會連續執行 read() 直到讀取操作次數達到上限,如果還有數據剩余則通過 Schedule 一個 Task 過一會再回來讀 Socket;Level Trigger 則一般只讀一次。如果 Auto Read 關閉了則會在每次處理完 EPOLLIN 事件后會取消 Channel 的 EPOLLIN 事件監聽,等下一次用戶主動調用 Channel 的 read() 時再重新注冊 EPOLLIN 。
、HTTP的歷史
早在 HTTP 建立之初,主要就是為了將超文本標記語言(HTML)文檔從Web服務器傳送到客戶端的瀏覽器。也是說對于前端來說,我們所寫的HTML頁面將要放在我們的 web 服務器上,用戶端通過瀏覽器訪問url地址來獲取網頁的顯示內容,但是到了 WEB2.0 以來,我們的頁面變得復雜,不僅僅單純的是一些簡單的文字和圖片,同時我們的 HTML 頁面有了 CSS,Javascript,來豐富我們的頁面展示,當 ajax 的出現,我們又多了一種向服務器端獲取數據的方法,這些其實都是基于 HTTP 協議的。同樣到了移動互聯網時代,我們頁面可以跑在手機端瀏覽器里面,但是和 PC 相比,手機端的網絡情況更加復雜,這使得我們開始了不得不對 HTTP 進行深入理解并不斷優化過程中。
二、HTTP的基本優化
影響一個 HTTP 網絡請求的因素主要有兩個:帶寬和延遲。
三、HTTP1.0和HTTP1.1的一些區別
HTTP1.0最早在網頁中使用是在1996年,那個時候只是使用一些較為簡單的網頁上和網絡請求上,而HTTP1.1則在1999年才開始廣泛應用于現在的各大瀏覽器網絡請求中,同時HTTP1.1也是當前使用最為廣泛的HTTP協議。 主要區別主要體現在:
四、HTTPS與HTTP的一些區別
五、SPDY:HTTP1.x的優化
2012年google如一聲驚雷提出了SPDY的方案,優化了HTTP1.X的請求延遲,解決了HTTP1.X的安全性,具體如下:
SPDY位于HTTP之下,TCP和SSL之上,這樣可以輕松兼容老版本的HTTP協議(將HTTP1.x的內容封裝成一種新的frame格式),同時可以使用已有的SSL功能。
六、HTTP2.0性能驚人
HTTP/2: the Future of the Internet https://link.zhihu.com/?target=https://http2.akamai.com/demo 是 Akamai 公司建立的一個官方的演示,用以說明 HTTP/2 相比于之前的 HTTP/1.1 在性能上的大幅度提升。 同時請求 379 張圖片,從Load time 的對比可以看出 HTTP/2 在速度上的優勢。
七、HTTP2.0:SPDY的升級版
HTTP2.0可以說是SPDY的升級版(其實原本也是基于SPDY設計的),但是,HTTP2.0 跟 SPDY 仍有不同的地方,如下:
HTTP2.0和SPDY的區別:
八、HTTP2.0和HTTP1.X相比的新特性
九、HTTP2.0的升級改造
十、附注
HTTP2.0的多路復用和HTTP1.X中的長連接復用有什么區別?
服務器推送到底是什么?服務端推送能把客戶端所需要的資源伴隨著index.html一起發送到客戶端,省去了客戶端重復請求的步驟。正因為沒有發起請求,建立連接等操作,所以靜態資源通過服務端推送的方式可以極大地提升速度。具體如下:
為什么需要頭部壓縮?假定一個頁面有100個資源需要加載(這個數量對于今天的Web而言還是挺保守的), 而每一次請求都有1kb的消息頭(這同樣也并不少見,因為Cookie和引用等東西的存在), 則至少需要多消耗100kb來獲取這些消息頭。HTTP2.0可以維護一個字典,差量更新HTTP頭部,大大降低因頭部傳輸產生的流量。具體參考:HTTP/2 頭部壓縮技術介紹
HTTP2.0多路復用有多好?HTTP 性能優化的關鍵并不在于高帶寬,而是低延遲。TCP 連接會隨著時間進行自我「調諧」,起初會限制連接的最大速度,如果數據成功傳輸,會隨著時間的推移提高傳輸的速度。這種調諧則被稱為 TCP 慢啟動。由于這種原因,讓原本就具有突發性和短時性的 HTTP 連接變的十分低效。HTTP/2 通過讓所有數據流共用同一個連接,可以更有效地使用 TCP 連接,讓高帶寬也能真正的服務于 HTTP 的性能提升。
原文:https://mp.weixin.qq.com/s/GICbiyJpINrHZ41u_4zT-A
*請認真填寫需求信息,我們會在24小時內與您取得聯系。