作者:京東零售 石朝陽
在説IO多路複用模型之前,我們先來大致瞭解下Linux文件系統。在Linux系統中,不論是你的鼠標,鍵盤,還是打印機,甚至於連接到本機的socket client端,都是以文件描述符的形式存在於系統中,諸如此類,等等等等,所以可以這麼説,一切皆文件。來看一下系統定義的文件描述符説明:
從上面的列表可以看到,文件描述符0,1,2都已經被系統佔用了,當系統啓動的時候,這三個描述符就存在了。其中0代表標準輸入,1代表標準輸出,2代表錯誤輸出。當我們創建新的文件描述符的時候,就會在2的基礎上進行遞增。可以這麼説,文件描述符是為了管理被打開的文件而創建的系統索引,他代表了文件的身份ID。對標windows的話,你可以認為和句柄類似,這樣就更容易理解一些。
由於網上對linux文件這塊的原理描述的文章已經非常多了,所以這裏我不再做過多的贅述,感興趣的同學可以從Wikipedia翻閲一下。由於這塊內容比較複雜,不屬於本文普及的內容,建議讀者另行自研,這裏我非常推薦馬士兵老師將linux文件系統這塊,講解的真的非常好。
select模型
此模型是IO多路複用的最早期使用的模型之一,距今已經幾十年了,但是現在依舊有不少應用還在採用此種方式,可見其長生不老。首先來看下其具體的定義(來源於man二類文檔):
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
這裏解釋下其具體參數:
參數一:nfds,也即maxfd,最大的文件描述符遞增一。這裏之所以傳最大描述符,為的就是在遍歷fd_set的時候,限定遍歷範圍。
參數二:readfds,可讀文件描述符集合。
參數三:writefds,可寫文件描述符集合。
參數四:errorfds,異常文件描述符集合。
參數五:timeout,超時時間。在這段時間內沒有檢測到描述符被觸發,則返回。
下面的宏處理,可以對fd_set集合(準確的説是bitmap,一個描述符有變更,則會在描述符對應的索引處置1)進行操作:
FD_CLR(inr fd,fd_set* set) 用來清除描述詞組set中相關fd 的位,即bitmap結構中索引值為fd的值置為0。
FD_ISSET(int fd,fd_set *set) 用來測試描述詞組set中相關fd 的位是否為真,即bitmap結構中某一位是否為1。
FD_SET(int fd,fd_set*set) 用來設置描述詞組set中相關fd的位,即將bitmap結構中某一位設置為1,索引值為fd。
FD_ZERO(fd_set *set) 用來清除描述詞組set的全部位,即將bitmap結構全部清零。
首先來看一段服務端採用了select模型的示例代碼:
//創建server端套接字,獲取文件描述符
int listenfd = socket(PF_INET,SOCK_STREAM,0);
if(listenfd < 0) return -1;
//綁定服務器
bind(listenfd,(struct sockaddr*)&address,sizeof(address));
//監聽服務器
listen(listenfd,5);
struct sockaddr_in client;
socklen_t addr_len = sizeof(client);
//接收客户端連接
int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
//讀緩衝區
char buff[1024];
//讀文件操作符
fd_set read_fds;
while(1)
{
memset(buff,0,sizeof(buff));
//注意:每次調用select之前都要重新設置文件描述符connfd,因為文件描述符表會在內核中被修改
FD_ZERO(&read_fds);
FD_SET(connfd,&read_fds);
//注意:select會將用户態中的文件描述符表放到內核中進行修改,內核修改完畢後再返回給用户態,開銷較大
ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
if(ret < 0)
{
printf("Fail to select!\n");
return -1;
}
//檢測文件描述符表中相關請求是否可讀
if(FD_ISSET(connfd, &read_fds))
{
ret = recv(connfd,buff,sizeof(buff)-1,0);
printf("receive %d bytes from client: %s \n",ret,buff);
}
}
上面的代碼我加了比較詳細的註釋了,大家應該很容易看明白,説白了大概流程其實如下:
首先,創建socket套接字,創建完畢後,會獲取到此套接字的文件描述符。
然後,bind到指定的地址進行監聽listen。這樣,服務端就在特定的端口啓動起來並進行監聽了。
之後,利用開啓accept方法來監聽客户端的連接請求。一旦有客户端連接,則將獲取到當前客户端連接的connection文件描述符。
雙方建立連接之後,就可以進行數據互傳了。需要注意的是,在循環開始的時候,務必每次都要重新設置當前connection的文件描述符,是因為文件描描述符表在內核中被修改過,如果不重置,將會導致異常的情況。
重新設置文件描述符後,就可以利用select函數從文件描述符表中,來輪詢哪些文件描述符就緒了。此時系統會將用户態的文件描述符表發送到內核態進行調整,即將準備就緒的文件描述符進行置位,然後再發送給用户態的應用中來。
用户通過FD_ISSET方法來輪詢文件描述符,如果數據可讀,則讀取數據即可。
舉個例子,假設此時連接上來了3個客户端,connection的文件描述符分別為 4,8,12,那麼其read_fds文件描述符表(bitmap結構)的大致結構為 00010001000100000....0,由於read_fds文件描述符的長度為1024位,所以最多允許1024個連接。
而在select的時候,涉及到用户態和內核態的轉換,所以整體轉換方式如下:
所以,綜合起來,select整體還是比較高效和穩定的,但是呈現出來的問題也不少,這些問題進一步限制了其性能發揮:
- 文件描述符表為bitmap結構,且有長度為1024的限制。
- fdset無法做到重用,每次循環必須重新創建。
- 頻繁的用户態和內核態拷貝,性能開銷較大。
- 需要對文件描述符表進行遍歷,O(n)的輪詢時間複雜度。
poll模型
考慮到select模型的幾個限制,後來進行了改進,這也就是poll模型,既然是select模型的改進版,那麼肯定有其亮眼的地方,一起來看看吧。當然,這次我們依舊是先翻閲linux man二類文檔,因為這是官方的文檔,對其有着最為精準的定義。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其實,從運行機制上説來,poll所做的功能和select是基本上一樣的,都是等待並檢測一組文件描述符就緒,然後在進行後續的IO處理工作。只不過不同的是,select中,採用的是bitmap結構,長度限定在1024位的文件描述符表,而poll模型則採用的是pollfd結構的數組fds,也正是由於poll模型採用了數組結構,則不會有1024長度限制,使其能夠承受更高的併發。
pollfd結構內容如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 關心的事件 */
short revents; /* 實際返回的事件 */
};
從上面的結構可以看出,fd很明顯就是指文件描述符,也就是當客户端連接上來後,fd會將生成的文件描述符保存到這裏;而events則是指用户想關注的事件;revents則是指實際返回的事件,是由系統內核填充並返回,如果當前的fd文件描述符有狀態變化,則revents的值就會有相應的變化。
events事件列表如下:
revents事件列表如下:
從列表中可以看出,revents是包含events的。接下來結合示例來看一下:
//創建server端套接字,獲取文件描述符
int listenfd = socket(PF_INET,SOCK_STREAM,0);
if(listenfd < 0) return -1;
//綁定服務器
bind(listenfd,(struct sockaddr*)&address,sizeof(address));
//監聽服務器
listen(listenfd,5);
struct pollfd pollfds[1];
socklen_t addr_len = sizeof(client);
//接收客户端連接
int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
//放入fd數組
pollfds[0].fd = connfd;
pollfds[0].events = POLLIN;
//讀緩衝區
char buff[1024];
//讀文件操作符
fd_set read_fds;
while(1)
{
memset(buff,0,sizeof(buff));
/**
** SELECT模型專用
** 注意:每次調用select之前都要重新設置文件描述符connfd,因為文件描述符表會在內核中被修改
** FD_ZERO(&read_fds);
** FD_SET(connfd,&read_fds);
** 注意:select會將用户態中的文件描述符表放到內核中進行修改,內核修改完畢後再返回給用户態,開銷較大
** ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
**/
ret = poll(pollfds, 1, 1000);
if(ret < 0)
{
printf("Fail to poll!\n");
return -1;
}
/**
** SELECT模型專用
** 檢測文件描述符表中相關請求是否可讀
** if(FD_ISSET(connfd, &read_fds))
** {
** ret = recv(connfd,buff,sizeof(buff)-1,0);
** printf("receive %d bytes from client: %s \n",ret,buff);
** }
**/
//檢測文件描述符數組中相關請求
if(pollfds[0].revents & POLLIN){
pollfds[0].revents = 0;
ret = recv(connfd,buff,sizeof(buff)-1,0);
printf("receive %d bytes from client: %s \n",ret,buff);
}
}
由於源碼中,我做了比較詳細的註釋,同時將和select模型不一樣的地方都列了出來,這裏就不再詳細解釋了。總體説來,poll模型比select模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:
- 用户態和內核態仍需要頻繁切換,因為revents的賦值是在內核態進行的,然後再推送到用户態,和select類似,整體開銷較大。
- 仍需要遍歷數組,時間複雜度為O(N)。
epoll模型
如果説select模型和poll模型是早期的產物,在性能上有諸多不盡人意之處,那麼自linux 2.6之後新增的epoll模型,則徹底解決了性能問題,一舉使得單機承受百萬併發的課題變得極為容易。現在可以這麼説,只需要一些簡單的設置更改,然後配合上epoll的性能,實現單機百萬併發輕而易舉。同時,由於epoll整體的優化,使得之前的幾個比較耗費性能的問題不再成為羈絆,所以也成為了linux平台上進行網絡通訊的首選模型。
講解之前,還是linux man文檔鎮樓:linux man epoll 4類文檔 linux man epoll 7類文檔,倆文檔結合着讀,會對epoll有個大概的瞭解。和之前提到的select和poll不同的是,此二者皆屬於系統調用函數,但是epoll則不然,他是存在於內核中的數據結構,可以通過epoll_create,epoll_ctl及epoll_wait三個函數結合來對此數據結構進行操控。
説道epoll_create函數,其作用是在內核中創建一個epoll數據結構實例,然後將返回此實例在系統中的文件描述符。此epoll數據結構的組成其實是一個鏈表結構,我們稱之為interest list,裏面會註冊連接上來的client的文件描述符。
其簡化工作機制如下:
説道epoll_ctl函數,其作用則是對epoll實例進行增刪改查操作。有些類似我們常用的CRUD操作。這個函數操作的對象其實就是epoll數據結構,當有新的client連接上來的時候,他會將此client註冊到epoll中的interest list中,此操作通過附加EPOLL_CTL_ADD標記來實現;當已有的client掉線或者主動下線的時候,他會將下線的client從epoll的interest list中移除,此操作通過附加EPOLL_CTL_DEL標記來實現;當有client的文件描述符有變更的時候,他會將events中的對應的文件描述符進行更新,此操作通過附加EPOLL_CTL_MOD來實現;當interest list中有client已經準備好了,可以進行IO操作的時候,他會將這些clients拿出來,然後放到一個新的ready list裏面。
其簡化工作機制如下:
説道epoll_wait函數,其作用就是掃描ready list,處理準備就緒的client IO,其返回結果即為準備好進行IO的client的個數。通過遍歷這些準備好的client,就可以輕鬆進行IO處理了。
上面這三個函數是epoll操作的基本函數,但是,想要徹底理解epoll,則需要先了解這三塊內容,即:inode,鏈表,紅黑樹。
在linux內核中,針對當前打開的文件,有一個open file table,裏面記錄的是所有打開的文件描述符信息;同時也有一個inode table,裏面則記錄的是底層的文件描述符信息。這裏假如文件描述符B fork了文件描述符A,雖然在open file table中,我們看新增了一個文件描述符B,但是實際上,在inode table中,A和B的底層是一模一樣的。這裏,將inode table中的內容理解為windows中的文件屬性,會更加貼切和易懂。這樣存儲的好處就是,無論上層文件描述符怎麼變化,由於epoll監控的數據永遠是inode table的底層數據,那麼我就可以一直能夠監控到文件的各種變化信息,這也是epoll高效的基礎。更多詳細信息,請參閲這兩篇文章:Nonblocking IO & The method to epoll's madness.
簡化流程如下:
數據存儲這塊解決了,那麼針對連接上來的客户端socket,該用什麼數據結構保存進來呢?這裏用到了紅黑樹,由於客户端socket會有頻繁的新增和刪除操作,而紅黑樹這塊時間複雜度僅僅為O(logN),還是挺高效的。有人會問為啥不用哈希表呢?當大量的連接頻繁的進行接入或者斷開的時候,擴容或者其他行為將會產生不少的rehash操作,而且還要考慮哈希衝突的情況。雖然查詢速度的確可以達到o(1),但是rehash或者哈希衝突是不可控的,所以基於這些考量,我認為紅黑樹佔優一些。
客户端socket怎麼管理這塊解決了,接下來,當有socket有數據需要進行讀寫事件處理的時候,系統會將已經就緒的socket添加到雙向鏈表中,然後通過epoll_wait方法檢測的時候,其實檢查的就是這個雙向鏈表,由於鏈表中都是就緒的數據,所以避免了針對整個客户端socket列表進行遍歷的情況,使得整體效率大大提升。 整體的操作流程為:
首先,利用epoll_create在內核中創建一個epoll對象。其實這個epoll對象,就是一個可以存儲客户端連接的數據結構。
然後,客户端socket連接上來,會通過epoll_ctl操作將結果添加到epoll對象的紅黑樹數據結構中。
然後,一旦有socket有事件發生,則會通過回調函數將其添加到ready list雙向鏈表中。
最後,epoll_wait會遍歷鏈表來處理已經準備好的socket,然後通過預先設置的水平觸發或者邊緣觸發來進行數據的感知操作。
從上面的細節可以看出,由於epoll內部監控的是底層的文件描述符信息,可以將變更的描述符直接加入到ready list,無需用户將所有的描述符再進行傳入。同時由於epoll_wait掃描的是已經就緒的文件描述符,避免了很多無效的遍歷查詢,使得epoll的整體性能大大提升,可以説現在只要談論linux平台的IO多路複用,epoll已經成為了不二之選。
水平觸發和邊緣觸發
上面説到了epoll,主要講解了client端怎麼連進來,但是並未詳細的講解epoll_wait怎麼被喚醒的,這裏我將來詳細的講解一下。
水平觸發,意即Level Trigger,邊緣觸發,意即Edge Trigger,如果單從字面意思上理解,則不太容易,但是如果將硬件設計中的水平沿,上升沿,下降沿的概念引進來,則理解起來就容易多了。比如我們可以這樣認為:
如果將上圖中的方塊看做是buffer的話,那麼理解起來則就更加容易了,比如針對水平觸發,buffer只要是一直有數據,則一直通知;而邊緣觸發,則buffer容量發生變化的時候,才會通知。雖然可以這樣簡單的理解,但是實際上,其細節處理部分,比圖示中展現的更加精細,這裏來詳細的説一下。
邊緣觸發
針對讀操作,也就是當前fd處於EPOLLIN模式下,即可讀。此時意味着有新的數據到來,接收緩衝區可讀,以下buffer都指接收緩衝區:
- buffer由空變為非空,意即有數據進來的時候,此過程會觸發通知。
- buffer原本有些數據,這時候又有新數據進來的時候,數據變多,此過程會觸發通知。
- buffer中有數據,此時用户對操作的fd註冊EPOLL_CTL_MOD事件的時候,會觸發通知。
針對寫操作,也就是當前fd處於EPOLLOUT模式下,即可寫。此時意味着緩衝區可以寫了,以下buffer都指發送緩衝區:
- buffer滿了,這時候發送出去一些數據,數據變少,此過程會觸發通知。
- buffer原本有些數據,這時候又發送出去一些數據,數據變少,此過程會觸發通知。
這裏就是ET這種模式觸發的幾種情形,可以看出,基本上都是圍繞着接收緩衝區或者發送緩衝區的狀態變化來進行的。
晦澀難懂?不存在的,舉個栗子:
在服務端,我們開啓邊緣觸發模式,然後將buffer size設為10個字節,來看看具體的表現形式。
服務端開啓,客户端連接,發送單字符A到服務端,輸出結果如下:
-->ET Mode: it was triggered once
get 1 bytes of content: A
-->wait to read!
可以看到,由於buffer從空到非空,邊緣觸發通知產生,之後在epoll_wait處阻塞,繼續等待後續事件。
這裏我們變一下,輸入ABCDEFGHIJKLMNOPQ,可以看到,客户端發送的字符長度超過了服務端buffer size,那麼輸出結果將是怎麼樣的呢?
-->ET Mode: it was triggered once
get 9 bytes of content: ABCDEFGHI
get 8 bytes of content: JKLMNOPQ
-->wait to read!
可以看到,這次發送,由於發送的長度大於buffer size,所以內容被折成兩段進行接收,由於用了邊緣觸發方式,buffer的情況是從空到非空,所以只會產生一次通知。
水平觸發
水平觸發則簡單多了,他包含了邊緣觸發的所有場景,簡而言之如下:
當接收緩衝區不為空的時候,有數據可讀,則讀事件會一直觸發。
當發送緩衝區未滿的時候,可以繼續寫入數據,則寫事件一直會觸發。
同樣的,為了使表達更清晰,我們也來舉個栗子,按照上述入輸入方式來進行。
服務端開啓,客户端連接併發送單字符A,可以看到服務端輸出情況如下:
-->LT Mode: it was triggered once!
get 1 bytes of content: A
這個輸出結果,毋庸置疑,由於buffer中有數據,所以水平模式觸發,輸出了結果。
服務端開啓,客户端連接併發送ABCDEFGHIJKLMNOPQ,可以看到服務端輸出情況如下:
-->LT Mode: it was triggered once!
get 9 bytes of content: ABCDEFGHI
-->LT Mode: it was triggered once!
get 8 bytes of content: JKLMNOPQ
從結果中,可以看出,由於buffer中數據讀取完畢後,還有未讀完的數據,所以水平模式會一直觸發,這也是為啥這裏水平模式被觸發了兩次的原因。
有了這兩個栗子的比對,不知道聰明的你,get到二者的區別了嗎?
在實際開發過程中,實際上LT更易用一些,畢竟系統幫助我們做了大部分校驗通知工作,之前提到的SELECT和POLL,默認採用的也都是這個。但是需要注意的是,當有成千上萬個客户端連接上來開始進行數據發送,由於LT的特性,內核會頻繁的處理通知操作,導致其相對於ET來説,比較的耗費系統資源,所以,隨着客户端的增多,其性能也就越差。
而邊緣觸發,由於監控的是FD的狀態變化,所以整體的系統通知並沒有那麼頻繁,高併發下整體的性能表現也要好很多。但是由於此模式下,用户需要積極的處理好每一筆數據,帶來的維護代價也是相當大的,稍微不注意就有可能出錯。所以使用起來須要非常小心才行。
至於二者如何抉擇,諸位就仁者見仁智者見智吧。
行文到這裏,關於epoll的講解基本上完畢了,大家從中是不是學到了很多幹貨呢? 由於從netty研究到linux epoll底層,其難度非常大,可以用曲高和寡來形容,所以在這塊探索的文章是比較少的,很多東西需要自己照着man文檔和源碼一點一點的琢磨(linux源碼詳見eventpoll.c等)。這裏我來糾正一下搜索引擎上,説epoll高性能是因為利用mmap技術實現了用户態和內核態的內存共享,所以性能好,我前期被這個觀點誤導了好久,後來下來了linux源碼,翻了一下,並沒有在epoll中翻到mmap的技術點,所以這個觀點是錯誤的。這些錯誤觀點的文章,國內不少,國外也不少,希望大家能審慎抉擇,避免被錯誤帶偏。
所以,epoll高性能的根本就是,其高效的文件描述符處理方式加上頗具特性邊的緣觸發處理模式,以極少的內核態和用户態的切換,實現了真正意義上的高併發。