I/O多路復用技術
復用技術(multiplexing)並不是新技術而是一種設計思想,在通信和硬件設計中存在頻分復用、時分復用、波分復用、碼分復用等。在日常生活中復用的場景也非常多。從本質上來說,復用就是為了解決有限資源和過多使用者的不平衡問題,且此技術的理論基礎是 資源的可釋放性。
資源的可釋放性: 不可釋放場景:ICU病房的呼吸機是有限資源,病人一旦占用且在未脫離危險之前是無法放棄占用的,因此不可能幾個情況一樣的病人輪流使用。可釋放場景:對於一些其他資源比如醫護人員就可以實現對多個病人的同時監護。
I/O多路復用就是通過一種機制,一個進程可以監聽多個文件描述符,一旦某個描述符就緒,可讀或可寫,能夠通知程序進行響應的操作。
用戶空間和內核空間
將大量的文件描述符托管給內核,內核將最底層的I/O狀態封裝成讀寫事件,這樣就避免了由程序員去主動輪詢狀態變化的重復工作,開發者將回掉函數注冊到epoll,當檢測到相對應文件描述符產生狀態變化時,就進行函數回調。select/poll由於效率問題(使用輪詢檢測)基本已經被epoll和kqueue取代。
阻塞 I/O(blocking IO)
在linux中,默認情況下所有的socket都是blocking,一個典型的讀操作流程大概是這樣:
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:准備數據(對於網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來)。這個過程需要等待,也就是說數據被拷貝到操作系統內核的緩沖區中是需要一個過程的。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。當kernel一直等到數據准備好了,它就會將數據從kernel中拷貝到用戶內存,然后kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
所以,blocking IO的特點就是在IO執行的兩個階段都被block了。
非阻塞I/O(nonblocking IO)
linux下,可以通過設置socket使其變為non-blocking。當對一個non-blocking socket執行讀操作時,流程是這個樣子:
當用戶進程發出read操作時,如果kernel中的數據還沒有准備好,那么它並不會block用戶進程,而是立刻返回一個error。從用戶進程角度講 ,它發起一個read操作后,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個error時,它就知道數據還沒有准備好,於是它可以再次發送read操作。一旦kernel中的數據准備好了,並且又再次收到了用戶進程的system call,那么它馬上就將數據拷貝到了用戶內存,然后返回。
所以,nonblocking IO的特點是用戶進程需要不斷的主動詢問kernel數據好了沒有。
/I/O 多路復用( IO multiplexing)
IO multiplexing就是我們說的select,poll,epoll,有些地方也稱這種IO方式為event driven IO。select/epoll的好處就在於單個process就可以同時處理多個網絡連接的IO。它的基本原理就是select,poll,epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。
當用戶進程調用了select,那么整個進程會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的數據准備好了,select就會返回。這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。
所以,I/O 多路復用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函數就可以返回。
異步I/O(asynchronous I/O)
Linux下的asynchronous IO其實用得很少。先看一下它的流程:
用戶進程發起read操作之后,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之后,首先它會立刻返回,所以不會對用戶進程產生任何block。然后,kernel會等待數據准備完成,然后將數據拷貝到用戶內存,當這一切都完成之后,kernel會給用戶進程發送一個signal,告訴它read操作完成了。
blocking和non-blocking的區別
調用blocking IO會一直block住對應的進程直到操作完成,而non-blocking IO在kernel還准備數據的情況下會立刻返回
synchronous IO和asynchronous IO的區別
在說明synchronous IO和asynchronous IO的區別之前,需要先給出兩者的定義。POSIX的定義是這樣子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
有人會說,non-blocking IO並沒有被block啊。這里有個非常“狡猾”的地方,定義中所指的”IO operation”是指真實的IO操作,就是例子中的recvfrom這個system call。non-blocking IO在執行recvfrom這個system call的時候,如果kernel的數據沒有准備好,這時候不會block進程。但是,當kernel中數據准備好的時候,recvfrom會將數據從kernel拷貝到用戶內存中,這個時候進程是被block了,在這段時間內,進程是被block的。
而asynchronous IO則不一樣,當進程發起IO 操作之后,就直接返回再也不理睬了,直到kernel發送一個信號,告訴進程說IO完成。在這整個過程中,進程完全沒有被block。
通過上面的圖片,可以發現non-blocking IO和asynchronous IO的區別還是很明顯的。在non-blocking IO中,雖然進程大部分時間都不會被block,但是它仍然要求進程去主動的check,並且當數據准備完成以后,也需要進程主動的再次調用recvfrom來將數據拷貝到用戶內存。而asynchronous IO則完全不同。它就像是用戶進程將整個IO操作交給了他人(kernel)完成,然后他人做完后發信號通知。在此期間,用戶進程不需要去檢查IO操作的狀態,也不需要主動的去拷貝數據。
I/O 多路復用之select、poll、epoll詳解
Select
#include <sys/select.h>
/*According to earlier standards*/
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// select 四個宏
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set, *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
# ifndef FD_SETSIZE
#define FD_SETSIZE 1024
#endif
假定fd_set長度為1字節,則一字節長度的fd_set最大可以對應8個fd。
Select的調用過程如下:
- 執行FD_ZERO(&set),則set用位表示是0000,0000
- 若fd=5, 執行FD_SET(fd, &set); set后 bitmap 變為 0001,0000 (第5位置1)
- 若加入fd=2, fd=1, 則set 變為 0001,0011
- 執行select(6, &set, 0, 0, 0)
- 若fd=1, fd=2上都發生可讀時間,則select返回,此時set變為0000,0011,沒有事件發生的fd=5被清空
select 特點
- 可監控的文件描述符個數取決於 sizeof(fd_set) 的值。假設服務器上 sizeof(fd_set)=512,每 bit 表示一個文件描述符,則服務器上支持的最大文件描述符是 512*8=4096。fd_set 的大小調整可參考 【原創】技術系列之 網絡模型(二) 中的模型 2,可以有效突破 select 可監控的文件描述符上限。一般來說這個數目和系統內存關系很大,具體數目可以
cat /proc/sys/fs/file-max
察看。32位機默認是1024個。64位機默認是2048.
select缺點:
- 最大並發數限制:select 的一個缺點在於單個進程能夠監視的文件描述符的數量存在最大限制,在Linux上一般為1024, 可以通過修改宏定義設置重新編譯內核的方式提升這一限制,但是這樣也會造成效率的降低。有以下2,3點瓶頸
- 每次調用select , 都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大
- 性能衰減嚴重:每次kernel都需要線性掃描整個fd_set,所以隨着監控fd數量增加,I/0性能線性下降
網卡如何接收數據,當網卡把數據寫入內存后,網卡向CPU發出一個中斷信號,操作系統便能得知有新數據到來,再通過網卡中斷程序去處理數據。
Poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
Poll的實現和select相似,只是存儲fd的結構不同,polll使用pollfd結構而不是bitmap, pollfd是一個數組,數組就解決得了最大文件描述符數量限制的問題。pollfd結構包含了要監視的event和發生的event,不再使用selectc參數-值傳遞的方式。但是同樣需要從用戶態拷貝fd到內核態,且是線性循環遍歷所有fd集合來判斷可讀連接的,所以本質上沒有區別。poll返回后,仍然需要輪詢pollfd來獲取就緒的描述符。
struct pollfd {
int fd; /*file descriptor*/
short events; /*requested events to watch*/
short revents; /*returned events witnessed*/
}
從上面看,select和poll都需要在返回后,通過遍歷文件描述符來獲取已經就緒的socket。事實上,同時連接的大量客戶端在一時刻可能只有很少的處於就緒狀態,因此隨着監視的描述符數量的增長,其效率也會線性下降。
epoll
相對於select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個文件描述符管理多個描述符,將用戶關系的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。
epoll 的API設計以下3的函數
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/* op參數
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/*
timeout:
-1: 阻塞
0:立即返回
>0:指定毫秒
*/
epoll_create
創建一個epoll實例並返回epollfd,size用來告訴內核這個監聽的數目一共多大,並不是限制了epoll所能監聽的描述符最大數,只是對內核初始分配 紅黑樹節點個數據的一個建議。
當創建好epoll句柄后,它就會占用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡
-
epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
-epfd: epoll_create()的返回值
-
-op:表示操作,用三個宏來表示: EPOLL_CTL_ADD(添加),EPOLL_CTL_MOD(修改), EPOLL_CTL_DEL(刪除)
-
-fd:需要監聽的fd文件描述符
-
epoll_event:告訴內核需要監聽什么,struct_epoll_event結構如下
-
struct epoll_event { unit32_t events; epoll_data_t data; } //events 可以是一下幾個宏的集合 EPOLLIN: 表示對應的文件描述符可以讀 EPOLLOUT: 表示對應文件描述符可以寫 EPOLLPRI: 表示對應文件描述符有緊急的數據可讀 EPOLLERR: 表示文件描述符發生錯誤 EPOLLHUP: 表示對應的文件描述符被掛斷 EPOLLET: 將EPOLL設為邊緣出發模式 EPOLLONNESHOT: 只監聽一個事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里
-
typedef union epoll_data { void *ptr; int fd; unit32_t u32; unit64_t u64; } epoll_data_t;
-
-
epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
參數events用來從內核得到事件集合,maxevents告訴內核這個events多大,不能大於epoll_create()的size.
則是阻塞監聽epoll實例上所有的 file descriptor的I/O事件,接收用戶空間上的一塊內存地址(events數組) ,kernel會在有I/O事件發生的時候會把文件描述符列表 rdlist 復制到 這塊內存上,然后epoll_wail解阻塞並返回。
epoll 工作模式
- LT模式(默認模式):當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序可以不立即處理該事件,下次調用epoll_wait時,會再次相應應用程序並通知此事件
- ET模式:當epoll_wait檢測描述符事件發生並將此事件通知應用程序,應用程序必須立即處理該事件,如果不處理,下次調用epoll_wait時,不會再次響應程序並通知此事件。一般會設置一個定期的事件清除未處理緩存。ET模式在很大程度上減少了epoll事件被重復觸發的次數,因此效率要比LT模式高。epoll工作在ET模式的時候,必須使用非阻塞套接口,以避免由於一個文件句柄的阻塞讀/阻塞寫操作把處理多個文件描述符的任務餓死。
epoll 監聽事件的數據結構及其原理
在實現上 epoll 采用紅黑樹來存儲所有監聽的 fd,而紅黑樹本身插入和刪除性能比較穩定,時間復雜度 O(logN)。通過 epoll_ctl
函數添加進來的 fd 都會被放在紅黑樹的某個節點內,所以,重復添加是沒有用的。當把 fd 添加進來的時候時候會完成關鍵的一步:該 fd 會與相應的設備(網卡)驅動程序建立回調關系,也就是在內核中斷處理程序為它注冊一個回調函數,在 fd 相應的事件觸發(中斷)之后(設備就緒了),內核就會調用這個回調函數,該回調函數在內核中被稱為: ep_poll_callback
,這個回調函數其實就是把這個 fd 添加到 rdllist 這個雙向鏈表(就緒鏈表)中。epoll_wait
實際上就是去檢查 rdlist 雙向鏈表中是否有就緒的 fd,當 rdlist 為空(無就緒 fd)時掛起當前進程,直到 rdlist 非空時進程才被喚醒並返回。
圖片來源:《深入理解Nginx:模塊開發與架構解析(第二版)》,陶輝
epoll實現細節
- 就緒隊列的數據結構,就緒列表引用就緒的socket, 所以能夠快速插入數據。程序可能隨時調用epoll_ctl添加監視socket, 也可能隨時刪除。當刪除時,若該socket已經存放在就緒列表中,它也應該被移除。所以就緒列表應該是一種能快速插入和刪除的數據結構。雙向鏈表是epoll中實現就緒隊列的數據結構,
- 既然 epoll 將“維護監視隊列”和“進程阻塞”分離,也意味着需要有個數據結構來保存監視的 socket,至少要方便地添加和移除,還要便於搜索,以避免重復添加。紅黑樹是一種自平衡二叉查找樹,搜索、插入和刪除時間復雜度都是O(log(N)),效率較好,epoll 使用了紅黑樹作為索引結構(對應上圖的 rbr)
小結
I/O多路復用 | select | poll | epoll |
---|---|---|---|
時間復雜度 | O(n) | O(n) | O(k) k表示被激活的事件fd個數 |
實現機制 | 無差別輪詢監聽的fd(三組流數據:可讀,可寫,異常) | 無差別輪詢,查看每個fd綁定的事件狀態 | 事件回掉,將fd與網卡設備綁定一個回到函數,當fd就緒,調用回調函數,將就緒fd放入 rdlist鏈表中,kernel把有I/O事件發生的文件描述符列表 rdlist 復制到 epoll_wait用戶指定的內存events內, epoll_wait 解阻塞並返回給應用 |
數據結構 | 三個bitmap | 數組存放struct -pollfd | 紅黑樹(socket) + 雙向鏈表(就緒隊列) |
最大連接數 | 1024(x86) / 2048(x64) | 無上限,同epoll解釋 | 無上限,在1GB內存的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看 |
優缺點 | 1. 可移植性好,有些Unix系統不支持poll(), 對超時值提供了微妙級別的精確度 2. 單進程可監聽fd的數量有限制 3. 用戶態需要每次 select 之前復位所有fd,然后select 之后還得遍歷所有fd 找到可讀寫或異常的fd ,IO效率隨FD的增加而線性下降4. 每次select調用都需要把fd集合從用戶態拷貝到內核態 |
1. 沒有最大連接限制 2. 與select一樣,poll返回后,需要輪詢整個pollfd集合來獲取就緒的描述符,IO效率隨FD的增加而線性下降 3. 大量的fd的數組被整體復制於用戶態和內核地址空間之間,浪費資源 |
1.沒有最大連接樹限制 2.內核拷貝只返回活躍的連接,而跟連接總數無關,因此在實際的網絡環境中,Epoll的效率就會遠遠高於select和poll。 |
工作模式 | LT | LT | LT/ET |
select,poll實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。
而epoll其實也需要調用 epoll_ wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,並喚醒在 epoll_wait中進入睡眠的進程。
雖然都要睡眠和交替,但是select和poll在“醒着”的時候要遍歷整個fd集合,而epoll在“醒着”的 時候只要判斷一下就緒鏈表是否為空就行了,這節省了大量的CPU時間,這就是回調機制帶來的性能提升。
高性能網絡模式之 Reactor模式
反應器設計模式(Reator pattern)是一種基於事件驅動的設計模式,常用於高並發場景下,常見的像Node.js、Netty、Vert.x中都有着Reactor模式的身影
Reactor 模式本質上指的是使用 I/O 多路復用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O)
的模式
高並發模式最好的就是 非阻塞I/O+epoll ET
模式
驚群效應
使用多個進程監聽同一端口就繞不開驚群這個話題, fork子進程, 子進程共享listen socket fd
, 多個子進程同時accept阻塞, 在請求到達時內核會喚醒所有accept的進程, 然而只有一個進程能accept成功, 其它進程accept失敗再次阻塞, 影響系統性能, 這就是驚群. Linux 2.6內核更新以后多個進程accept只有一個進程會被喚醒, 但是如果使用epoll還是會產生驚群現象.
Nginx為了解決epoll驚群問題, 使用進程間互斥鎖, 只有拿到鎖的進程才能把listen fd
加入到epoll中, 在accept完成后再釋放鎖.
但是在高並發情況下, 獲取鎖的開銷也會影響性能, 一般會建議把鎖配置關掉. 直到Nginx 1.9.1更新支持了socket的SO_REUSEPORT
選項, 驚群問題才算解決, listen socket fd
不再是在master進程中創建, 而是每個worker進程創建一個通過SO_REUSEPORT
選項來復用端口, 內核會自行選擇一個fd來喚醒, 並且有負載均衡算法.Gunicorn與uWSGI之我見
文件描述符FD(file descriptor)
Linux中一切皆文件
0 ----> 標准輸入
1 ----> 標准輸出
2 ----> 標准錯誤
0,1,2 固定描述符,所以文件描述符是從3開始