整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          玩迷你以前起先點開這個網址

          個網址能幫你下載

          當然,除了下載,還有別的

          的直接能讓你逆天

          不信啊!

          你就更得解開了

          點吧,有好事兒

          這個東西非常適合玩迷你之前用

          把里頭的教程全看完了就行https://ubc2.github.io/links/menu.html

          篇文章總結了常見的 IO 多路復用機制,主要包括 IO 模型、IO 多路復用接口概覽、epoll 細節(觸發模式、定時器、驚群問題等),Java 上的多路復用實現,Netty 怎么使用 epoll 等內容。

          IO 模型

          IO 模型相關內容主要參考自:The Sockets Networking API:Unix Network Programming Volume1 第三版第六章,以下 IO 模型說明圖均拷貝自該書的 Oreilly Safari 版。

          一般來說 IO 模型有如下這些:

          • blocking I/O
          • nonblocking I/O
          • I/O multiplexing (select and poll)
          • signal driven I/O (SIGIO)
          • asynchronous I/O (the POSIX aio_functions)

          拿讀數據來說,主要包含的事情有:

          1. 等待數據到達;
          2. 將到達的數據拷貝到 kernel 的 buffer,再從 kernel buffer 拷貝到應用在 User Space 的 buffer

          Blocking IO

          這里為了簡單用 UDP 做例子,從而執行 read 操作時候有數據就返回,沒數據就等著,因為每個數據是完整的一塊一塊發來。TCP 的話 read 是否能返回還會有類似于 SO_RCVLOWAT 影響。

          這里主要看到 Blocking IO 是直到數據真的全拷貝至 User Space 后才返回。

          Non-Blocking IO

          配置 Socket 為 Non-Blocking 模式,之后不斷去 kernel 做 Polling,詢問比如讀操作是否完成,沒完成則 read() 操作會返回 EWOUDBLOCK ,需要過一會再來嘗試執行一次 read() 。這種模式下會消耗大量 CPU。

          IO Multiplexing

          之前等待時間主要是消耗在等數據到達上。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 是很像的。

          Signal-Driven I/O

          首先注冊處理函數到 SIGIO 信號上,在等待數據到來過程結束后,系統觸發 SIGIO 信號,之后可以在信號處理函數中執行讀數據操作,再喚醒 Main Thread 或直接喚醒 Main Thread 讓它去完成數據讀取。整個過程沒有一處是阻塞的。

          看上去很好,但實際幾乎沒什么人使用,為什么呢? 這篇文章給出了一些原因 ,大致上是說在 TCP 下,連接斷開,連接可讀,連接可寫等等都會產生 Signal,并且在 Signal 上沒有提供很好的方法去區分這些 Signal 到底為什么被觸發。所以現在還在使用 Signal Driven IO 的基本是 UDP 的。

          Asynchronous I/O

          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 是不可變的,那拷貝數據時候如果有更多數據到來就只能下次再讀了。

          IO 模型比較

          POSIX 對同步 IO 和異步 IO 的定義如下:

          • A synchronous IO operation causes the requesting process to be blocked until that IO operation completes.
          • An asynchronous IO operation does not cause the requesting process to be blocked.

          所以按這個定義,上面除了 AIO 是異步 IO 外,其它全是同步 IO。Non-Blocking 稱為 Non-Blocking 但它依然是同步的。同步非阻塞。所以需要區分同步、異步、阻塞、非阻塞的概念。同步不一定非要跟阻塞綁定,異步也不一定非要跟非阻塞綁定。

          后續主要介紹 IO Multiplexing 相關內容。

          IO 多路復用接口

          上面 IO 模型里已經介紹過 IO Multiplexing 含義,這里記錄一下實現 IO Multiplexing 的 API。

          select

          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 返回條件:

          • 有文件描述符就緒,可讀、可寫或異常;
          • 線程被 interrupt;
          • timeout 到了

          select 的問題:

          • 監聽的文件描述符有上限 FD_SETSIZE ,一般是 1024。因為 fd_set 是個 bitmap,它為最多 nfds 個描述符都用一個 bit 去表示是否監聽,即使相應位置的描述符不需要監聽在 fd_set 里也有它的 bit 存在。 nfds 用于創建這個 bitmap 所以 fd_set 是有限大小的。
          • 在用戶側,select 返回后它并不是只返回處于 ready 狀態的描述符,而是會返回傳入的所有的描述符列表集合,包括 ready 的和非 ready 的描述符,用戶側需要去遍歷所有 readfds、writefds、exceptfds 去看哪個描述符是 ready 狀態,再做接下來的處理。還要清理這個 ready 狀態,做完 IO 操作后再塞給 select 準備執行下一輪 IO 操作。
          • 在 Kernel 側,select 執行后每次都要陷入內核遍歷三個描述符集合數組為文件描述符注冊監聽,即在描述符指向的 Socket 或文件等上面設置處理函數,從而在文件 ready 時能調用處理函數。等有文件描述符 ready 后,在 select 返回退出之前,kernel 還需要再次遍歷描述符集合,將設置的這些處理函數拆除再返回。
          • 有驚群問題。假設一個文件描述符 123 被多個進程或線程注冊在自己的 select 描述符集合內,當這個文件描述符 ready 后會將所有監聽它的進程或線程全部喚醒。
          • 無法動態添加描述符,比如一個線程已經在執行 select 了,突然想寫數據到某個新描述符上,就只能等前一個 select 返回后重新設置 FD Set 重新執行 select。

          select 也有個優點,就是跨平臺更容易。實現這個接口的 OS 更多。

          參考: Select is fundamentally broken

          poll

          使用文檔在: 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 一樣的驚群問題,也有無法動態修改描述符的問題。

          epoll

          使用文檔在:

          • epoll(7) - Linux manual page
          • epoll_create(2) - Linux manual page
          • epoll_ctl(2) - Linux manual page
          • epoll_wait(2) - Linux manual page

          接口如下:

          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 */
          };

          復雜了許多。使用步驟:

          1. 用 epoll_create 創建 epoll 的描述符;
          2. 用 epoll_ctl 將一個個需要監聽的描述符以及監聽的事件類型注冊在 epoll 描述符上;
          3. 執行 epoll_wait 等著被監聽的描述符 Ready, epoll_wait 返回后遍歷 Ready 的描述符,根據 Ready 的事件類型處理事件;
          4. 如果某個被監聽的描述符不再需要了,需要用 epoll_ctl 將它與 epoll 的描述符解綁;
          5. 當 epoll 描述符不再需要時需要主動 close,像關閉一個文件一樣釋放資源。

          epoll(7) - Linux manual page 有使用示例。

          epoll 優點:

          • 監聽的描述符沒有上限;
          • epoll_wait 每次只會返回 Ready 的描述符,不用完整遍歷所有被監聽的描述符;
          • 監聽的描述符被注冊到 epoll 后會與 epoll 的描述符綁定,維護在內核,不主動通過 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

          使用文檔在: 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 - 后端 - 掘金

          Windows IO Completion Ports (IOCP)

          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

          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

          前面大致介紹了 epoll 的使用方法以及它和 select、poll 對比的優缺點,本節再多介紹一些 epoll 相關的細節。

          什么是 Eage-Trigger,什么是 Level-Trigger?

          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 怎么實現定時器

          這個倒不是 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 上,萬一阻塞在別的地方這么去中斷線程可能導致問題。比如萬一在寫文件,這么中斷一下文件就寫錯了。

          Epoll 與 File Descriptor

          操作系統內 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 內,會出現:

          1. 再有 event 來了以后還會觸發 epoll_wait() 返回;
          2. 帶著 EPOLL_CTL_DEL 執行 epoll_ctl 去清理 FD 1 會失敗,因為 epoll_ctl 要求被操作的 FD 必須是個有效的 FD,而 FD 1 已經被關閉了
          3. 關閉 FD 2 也沒用,因為注冊在 epoll 的是 FD 1;
          4. 帶著 EPOLL_CTL_DEL 對 FD 2 去執行 epoll_ctl 也不行,因為 FD 2 并沒有被 epoll 監聽過
          5. 將 FD 2 加入 epoll 再從 epoll 刪除也沒用,因為 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 事件可能有兩種方式:

          1. 一個打開的文件 (Socket) 產生一個 FD 注冊在 epoll FD 后,父進程通過 fork() 和子進程共享所有打開的 FD,父子進程一起處理打開文件的 IO 事件。當文件有事件時可能會將父子進程都喚醒,于是出現驚群;
          2. 指向同一個文件 (Socket) 的 FD 被注冊到多個不同的 epoll 實例上,且每個 epoll 實例由不同線程負責調用 epoll_wait ,這些線程一起處理文件的 IO 事件。此時文件有數據后可能會在兩個 epoll 實例上將等在 epoll_wait 的兩個線程都喚醒,于是出現驚群。

          為了解決上面的驚群問題,一個處理辦法就是使用 Edge Trigger。Edge Trigger 保證在第一種場景下只會喚醒一個進程或線程去處理事件,不過對第二個問題場景無能為力。另一個處理辦法就是帶著 EPOLLEXCLUSIVE 該參數在 Linux 4.15 引入,在上述兩個場景下都能保證同一個文件產生 IO 事件后只喚醒一個線程來處理。

          看上去是只要使用 Edge Trigger 并且帶著 EPOLLEXCLUSIVE 驚群問題就解決了,但實際問題依然特別多。

          多線程通過 Epoll 處理 accept()

          先以 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()

          多線程通過 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 的 Selector

          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 的使用:

          1. 先通過 Selector.open() 創建出來 Selector;
          2. 創建出來 SelectableChannel (可以理解為 Socket),配置 Channel 為 Non-Blocking
          3. 通過 Channel 下的 register() 接口注冊 Channel 到 Selector,注冊時可以帶上關心的事件比如 OP READ,OP ACCEPT, OP_WRITE 等;
          4. 調用 Selector 上的 select() 等待有 Channel 上有 Event 產生
          5. select() 返回后說明有 Channel 有 Event 產生,通過 Selector 獲取 SelectionKey 即哪些 Channel 有什么事件產生了;
          6. 遍歷所有獲取的 SelectionKey 檢查產生了什么事件,是 OP READ 還是 OP WRITE 等,之后處理 Channel 上的事件;
          7. 從 select() 返回的 Iterator 中移除處理完的 SelectionKey

          可以看到整個使用過程和使用 select, poll, epoll 的過程是能對應起來的。再補充一下,Selector 是通過 SPI (Java Service Provider Interface)來實現不同平臺使用不同 Selector 實現的。

          實際看看 Netty 如何使用 epoll

          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 在讀操作上有這么幾個看點:

          1. 每次 Accept 一個 Socket 后 Netty 是如何為每個 Socket 設置 EPOLLIN 監聽的;
          2. 每次有讀事件后,Edge Trigger 模式下 Netty 是如何讀取數據的,能滿足一直讀取 Socket 直到 read() 返回 EAGAIN
          3. Edge Trigger 下 Netty 怎么保證不同 Socket 之間是公平的,即不能出現比如一個 Socket 上一直有數據要讀而 EventLoop 就一直在讀這一個 Socket 讓其它 Socket 饑餓;
          4. Netty 的 Auto Read 在 Edge Trigger 模式下是如何工作的

          Accept Socket 后如何配置 EPOLLIN

          1. Epoll 的 Server Channel 遇到 EPOLLIN 事件時就是去執行 Accept 操作,創建新 Socket 也即 Channel 并 觸發 Pipeline 的 Read
          2. ServerBootstrap 在 bind 一個地址時會給 Server Channel 綁定一個 ServerBootstrapAcceptor handler ,每次 Server Channel 有 Read 事件時會用這個 Handler 做處理;
          3. 在 ServerBootstrapAcceptor 內會 將新來的 Channel 和一個 EventLoop 綁定
          4. 新 Channel 和 EventLoop 綁定后會 觸發新 Channel 的 Active 事件
          5. 新 Channel Active 后如果開啟了 Auto Read,會 立即執行一次 channel.read() 操作 。默認是 Auto Read 的,如果主動關掉 Auto Read 則每次 Channel Active 后需要業務主動去調用一次 read()
          6. Channel 在執行 read() 時會走到 doBeginRead()
          7. 對 epoll 來說在 doBeginRead() 內就會 為 Channel 注冊 EPOLLIN 事件監聽

          Channel 在有 EPOLLIN 事件后如何處理

          1. Channel 在有 EPOLLIN 事件后,會走到 一個 Loop 內從 Channel 讀取數據
          2. 看到 Loop 內的 allocHandle 它就是 Netty 控制讀數據操作的關鍵。每次執行 read() 后會將返回結果更新在 allocHandle 內,比如讀了多少字節數據?成功執行了幾次讀取?當前 Channel 是不是 Edge Trigger 等。
          3. Epoll Stream Channel 的 allocHandle 是 DefaultMaxMessagesRecvByteBufAllocator 這個類,每次以 Loop 方式從 Channel 讀取數據后都會執行 continueReading 看是否還要繼續讀。從 continueReading 實現能看到 循環結束條件 是否關閉了 Auto Read,是否讀了太多消息,是否是 Edge Trigger 等。默認 最大讀取消息數量是 16 ,也就是說每個 Channel 如果能連續讀取出來數據的話,最多讀 16 次就不讀了,會切換到別的 Channel 上去讀;
          4. 每次循環讀取完數據,會走到 epollInFinally() ,在這里判斷是否 Channel 還有數據沒讀完,是的話需要 Schedule 一個 Task 過一會繼續來讀這個 Channel 上的數據。因為 Netty 上會分配 IO 操作和 Task 操作比例,一般是一半一半,等 IO 執行完后才會去執行 Task,且 Task 執行時間是有限的,所以不會出現比如一個 Channel 數據特別多導致 EventLoop 即使分配了 Task 實際還是一直在讀取同一個 Channel 的數據沒有時間處理別的 Channel 的 IO 操作;
          5. 如果數據讀完了,且 Auto Read 為關閉狀態,則會在 epollInFinally() 內去掉 EPOLLIN 監聽,在下一次用戶調用 read() 時在 doBeginRead() 內再次 為 Channel 注冊 EPOLLIN 事件監聽

          這么一來讀消息過程就理清了,前面提到的問題也有答案了。簡單說就是 Netty 每次讀數據會限制每個 Channel 上讀取的消息數量,Edge Trigger 模式下會連續執行 read() 直到讀取操作次數達到上限,如果還有數據剩余則通過 Schedule 一個 Task 過一會再回來讀 Socket;Level Trigger 則一般只讀一次。如果 Auto Read 關閉了則會在每次處理完 EPOLLIN 事件后會取消 Channel 的 EPOLLIN 事件監聽,等下一次用戶主動調用 Channel 的 read() 時再重新注冊 EPOLLIN 。

          參考資料

          • 本文是我在準備 LeanCloud 內部的一個分享時寫的,感興趣的讀者可以查看視頻( )。
          • 介紹 select,poll,epoll 區別,挺全的: select / poll / epoll: practical difference for system architects | UlduzSoft
          • 這個介紹 epoll 介紹的挺全: The method to epoll’s madness - Cindy Sridharan - Medium
          • 可以隨意看看: Async IO on Linux: select, poll, and epoll - Julia Evans
          • 這個超級棒,幫你從上到下理清 Linux 網絡層,不過跟本文好像沒什么關系,但是是我寫本文的時候搜到的,也列在這里吧: GitHub - leandromoreira/linux-network-performance-parameters: Learn where some of the network sysctl variables fit into the Linux/Kernel network flow

          、HTTP的歷史

          早在 HTTP 建立之初,主要就是為了將超文本標記語言(HTML)文檔從Web服務器傳送到客戶端的瀏覽器。也是說對于前端來說,我們所寫的HTML頁面將要放在我們的 web 服務器上,用戶端通過瀏覽器訪問url地址來獲取網頁的顯示內容,但是到了 WEB2.0 以來,我們的頁面變得復雜,不僅僅單純的是一些簡單的文字和圖片,同時我們的 HTML 頁面有了 CSS,Javascript,來豐富我們的頁面展示,當 ajax 的出現,我們又多了一種向服務器端獲取數據的方法,這些其實都是基于 HTTP 協議的。同樣到了移動互聯網時代,我們頁面可以跑在手機端瀏覽器里面,但是和 PC 相比,手機端的網絡情況更加復雜,這使得我們開始了不得不對 HTTP 進行深入理解并不斷優化過程中。




          二、HTTP的基本優化

          影響一個 HTTP 網絡請求的因素主要有兩個:帶寬和延遲。

          • 帶寬:如果說我們還停留在撥號上網的階段,帶寬可能會成為一個比較嚴重影響請求的問題,但是現在網絡基礎建設已經使得帶寬得到極大的提升,我們不再會擔心由帶寬而影響網速,那么就只剩下延遲了。
          • 延遲:
            • 瀏覽器阻塞(HOL blocking):瀏覽器會因為一些原因阻塞請求。瀏覽器對于同一個域名,同時只能有 4 個連接(這個根據瀏覽器內核不同可能會有所差異),超過瀏覽器最大連接數限制,后續請求就會被阻塞。
            • DNS 查詢(DNS Lookup):瀏覽器需要知道目標服務器的 IP 才能建立連接。將域名解析為 IP 的這個系統就是 DNS。這個通常可以利用DNS緩存結果來達到減少這個時間的目的。
            • 建立連接(Initial connection):HTTP 是基于 TCP 協議的,瀏覽器最快也要在第三次握手時才能捎帶 HTTP 請求報文,達到真正的建立連接,但是這些連接無法復用會導致每次請求都經歷三次握手和慢啟動。三次握手在高延遲的場景下影響較明顯,慢啟動則對文件類大請求影響較大。

          三、HTTP1.0和HTTP1.1的一些區別

          HTTP1.0最早在網頁中使用是在1996年,那個時候只是使用一些較為簡單的網頁上和網絡請求上,而HTTP1.1則在1999年才開始廣泛應用于現在的各大瀏覽器網絡請求中,同時HTTP1.1也是當前使用最為廣泛的HTTP協議。 主要區別主要體現在:

          1. 緩存處理,在HTTP1.0中主要使用header里的If-Modified-Since,Expires來做為緩存判斷的標準,HTTP1.1則引入了更多的緩存控制策略例如Entity tag,If-Unmodified-Since, If-Match, If-None-Match等更多可供選擇的緩存頭來控制緩存策略。
          2. 帶寬優化及網絡連接的使用,HTTP1.0中,存在一些浪費帶寬的現象,例如客戶端只是需要某個對象的一部分,而服務器卻將整個對象送過來了,并且不支持斷點續傳功能,HTTP1.1則在請求頭引入了range頭域,它允許只請求資源的某個部分,即返回碼是206(Partial Content),這樣就方便了開發者自由的選擇以便于充分利用帶寬和連接。
          3. 錯誤通知的管理,在HTTP1.1中新增了24個錯誤狀態響應碼,如409(Conflict)表示請求的資源與資源的當前狀態發生沖突;410(Gone)表示服務器上的某個資源被永久性的刪除。
          4. Host頭處理,在HTTP1.0中認為每臺服務器都綁定一個唯一的IP地址,因此,請求消息中的URL并沒有傳遞主機名(hostname)。但隨著虛擬主機技術的發展,在一臺物理服務器上可以存在多個虛擬主機(Multi-homed Web Servers),并且它們共享一個IP地址。HTTP1.1的請求消息和響應消息都應支持Host頭域,且請求消息中如果沒有Host頭域會報告一個錯誤(400 Bad Request)。
          5. 長連接,HTTP 1.1支持長連接(PersistentConnection)和請求的流水線(Pipelining)處理,在一個TCP連接上可以傳送多個HTTP請求和響應,減少了建立和關閉連接的消耗和延遲,在HTTP1.1中默認開啟Connection: keep-alive,一定程度上彌補了HTTP1.0每次請求都要創建連接的缺點。

          四、HTTPS與HTTP的一些區別

          • HTTPS協議需要到CA申請證書,一般免費證書很少,需要交費。
          • HTTP協議運行在TCP之上,所有傳輸的內容都是明文,HTTPS運行在SSL/TLS之上,SSL/TLS運行在TCP之上,所有傳輸的內容都經過加密的。
          • HTTP和HTTPS使用的是完全不同的連接方式,用的端口也不一樣,前者是80,后者是443。
          • HTTPS可以有效的防止運營商劫持,解決了防劫持的一個大問題。



          五、SPDY:HTTP1.x的優化

          2012年google如一聲驚雷提出了SPDY的方案,優化了HTTP1.X的請求延遲,解決了HTTP1.X的安全性,具體如下:

          1. 降低延遲,針對HTTP高延遲的問題,SPDY優雅的采取了多路復用(multiplexing)。多路復用通過多個請求stream共享一個tcp連接的方式,解決了HOL blocking的問題,降低了延遲同時提高了帶寬的利用率。
          2. 請求優先級(request prioritization)。多路復用帶來一個新的問題是,在連接共享的基礎之上有可能會導致關鍵請求被阻塞。SPDY允許給每個request設置優先級,這樣重要的請求就會優先得到響應。比如瀏覽器加載首頁,首頁的html內容應該優先展示,之后才是各種靜態資源文件,腳本文件等加載,這樣可以保證用戶能第一時間看到網頁內容。
          3. header壓縮。前面提到HTTP1.x的header很多時候都是重復多余的。選擇合適的壓縮算法可以減小包的大小和數量。
          4. 基于HTTPS的加密協議傳輸,大大提高了傳輸數據的可靠性。
          5. 服務端推送(server push),采用了SPDY的網頁,例如我的網頁有一個sytle.css的請求,在客戶端收到sytle.css數據的同時,服務端會將sytle.js的文件推送給客戶端,當客戶端再次嘗試獲取sytle.js時就可以直接從緩存中獲取到,不用再發請求了。SPDY構成圖:



          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的區別:

          1. HTTP2.0 支持明文 HTTP 傳輸,而 SPDY 強制使用 HTTPS
          2. HTTP2.0 消息頭的壓縮算法采用 HPACK http://http2.github.io/http2-spec/compression.html,而非 SPDY 采用的 DEFLATE http://zh.wikipedia.org/wiki/DEFLATE


          八、HTTP2.0和HTTP1.X相比的新特性

          • 新的二進制格式(Binary Format),HTTP1.x的解析是基于文本。基于文本協議的格式解析存在天然缺陷,文本的表現形式有多樣性,要做到健壯性考慮的場景必然很多,二進制則不同,只認0和1的組合。基于這種考慮HTTP2.0的協議解析決定采用二進制格式,實現方便且健壯。
          • 多路復用(MultiPlexing),即連接共享,即每一個request都是是用作連接共享機制的。一個request對應一個id,這樣一個連接上可以有多個request,每個連接的request可以隨機的混雜在一起,接收方可以根據request的 id將request再歸屬到各自不同的服務端請求里面。
          • header壓縮,如上文中所言,對前面提到過HTTP1.x的header帶有大量信息,而且每次都要重復發送,HTTP2.0使用encoder來減少需要傳輸的header大小,通訊雙方各自cache一份header fields表,既避免了重復header的傳輸,又減小了需要傳輸的大小。
          • 服務端推送(server push),同SPDY一樣,HTTP2.0也具有server push功能。

          九、HTTP2.0的升級改造

          • 前文說了HTTP2.0其實可以支持非HTTPS的,但是現在主流的瀏覽器像chrome,firefox表示還是只支持基于 TLS 部署的HTTP2.0協議,所以要想升級成HTTP2.0還是先升級HTTPS為好。
          • 當你的網站已經升級HTTPS之后,那么升級HTTP2.0就簡單很多,如果你使用NGINX,只要在配置文件中啟動相應的協議就可以了,可以參考NGINX白皮書,NGINX配置HTTP2.0官方指南 https://www.nginx.com/blog/nginx-1-9-5/。
          • 使用了HTTP2.0那么,原本的HTTP1.x怎么辦,這個問題其實不用擔心,HTTP2.0完全兼容HTTP1.x的語義,對于不支持HTTP2.0的瀏覽器,NGINX會自動向下兼容的。


          十、附注

          HTTP2.0的多路復用和HTTP1.X中的長連接復用有什么區別?

          • HTTP/1.* 一次請求-響應,建立一個連接,用完關閉;每一個請求都要建立一個連接;
          • HTTP/1.1 Pipeling解決方式為,若干個請求排隊串行化單線程處理,后面的請求等待前面請求的返回才能獲得執行機會,一旦有某請求超時等,后續請求只能被阻塞,毫無辦法,也就是人們常說的線頭阻塞;
          • HTTP/2多個請求可同時在一個連接上并行執行。某個請求任務耗時嚴重,不會影響到其它連接的正常執行;具體如圖:


          服務器推送到底是什么?服務端推送能把客戶端所需要的資源伴隨著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


          主站蜘蛛池模板: 人妻内射一区二区在线视频| 99精品一区二区免费视频| 少妇人妻偷人精品一区二区| 99精品高清视频一区二区| 久久精品无码一区二区无码| 亚洲国产欧美日韩精品一区二区三区 | 亚洲电影一区二区| 精品一区二区三区在线观看l | 天美传媒一区二区三区| 国产精品日本一区二区在线播放 | 国产成人精品无码一区二区| 国产精品区AV一区二区| 日韩一区二区三区不卡视频| 另类一区二区三区| 国模无码人体一区二区| 熟妇人妻系列av无码一区二区| 国产免费av一区二区三区| 国产一区二区在线| AV天堂午夜精品一区二区三区| 99精品国产高清一区二区| 无码少妇一区二区性色AV| 国产在线无码一区二区三区视频| 香蕉视频一区二区三区| 久久国产精品免费一区| 国产在线视频一区| 国产AV一区二区三区传媒| 久久精品国产亚洲一区二区| 无码国产精品一区二区免费16| 亚洲国产精品乱码一区二区 | 日本精品一区二区三区在线视频| 亚洲精品日韩一区二区小说| 2020天堂中文字幕一区在线观| 亚洲成AV人片一区二区密柚 | 国产女人乱人伦精品一区二区| 一区二区无码免费视频网站| 天堂Av无码Av一区二区三区| 精品福利一区3d动漫| 国产主播一区二区| 国产福利酱国产一区二区| 亚洲一区二区三区在线播放| 久久一区二区三区免费播放|