這次答應(yīng)我,一舉拿下 I/O 多路復(fù)用!
本文轉(zhuǎn)載自微信公眾號「小林coding」,作者小林coding。轉(zhuǎn)載本文請聯(lián)系小林coding公眾號。
這次,我們以最簡單 socket 網(wǎng)絡(luò)模型,一步一步的過度到 I/O 多路復(fù)用。
但我不會具體細(xì)節(jié)說到每個系統(tǒng)調(diào)用的參數(shù),這方面書上肯定比我說的詳細(xì)。
好了,發(fā)車!
最基本的 Socket 模型
要想客戶端和服務(wù)器能在網(wǎng)絡(luò)中通信,那必須得使用 Socket 編程,它是進(jìn)程間通信里比較特別的方式,特別之處在于它是可以跨主機(jī)間通信。
Socket 的中文名叫作插口,咋一看還挺迷惑的。事實上,雙方要進(jìn)行網(wǎng)絡(luò)通信前,各自得創(chuàng)建一個 Socket,這相當(dāng)于客戶端和服務(wù)器都開了一個“口子”,雙方讀取和發(fā)送數(shù)據(jù)的時候,都通過這個“口子”。這樣一看,是不是覺得很像弄了一根網(wǎng)線,一頭插在客戶端,一頭插在服務(wù)端,然后進(jìn)行通信。
創(chuàng)建 Socket 的時候,可以指定網(wǎng)絡(luò)層使用的是 IPv4 還是 IPv6,傳輸層使用的是 TCP 還是 UDP。
UDP 的 Socket 編程相對簡單些,這里我們只介紹基于 TCP 的 Socket 編程。
服務(wù)器的程序要先跑起來,然后等待客戶端的連接和數(shù)據(jù),我們先來看看服務(wù)端的 Socket 編程過程是怎樣的。
服務(wù)端首先調(diào)用 socket() 函數(shù),創(chuàng)建網(wǎng)絡(luò)協(xié)議為 IPv4,以及傳輸協(xié)議為 TCP 的 Socket ,接著調(diào)用 bind() 函數(shù),給這個 Socket 綁定一個 IP 地址和端口,綁定這兩個的目的是什么?
- 綁定端口的目的:當(dāng)內(nèi)核收到 TCP 報文,通過 TCP 頭里面的端口號,來找到我們的應(yīng)用程序,然后把數(shù)據(jù)傳遞給我們。
- 綁定 IP 地址的目的:一臺機(jī)器是可以有多個網(wǎng)卡的,每個網(wǎng)卡都有對應(yīng)的 IP 地址,當(dāng)綁定一個網(wǎng)卡時,內(nèi)核在收到該網(wǎng)卡上的包,才會發(fā)給我們;
綁定完 IP 地址和端口后,就可以調(diào)用 listen() 函數(shù)進(jìn)行監(jiān)聽,此時對應(yīng) TCP 狀態(tài)圖中的 listen,如果我們要判定服務(wù)器中一個網(wǎng)絡(luò)程序有沒有啟動,可以通過netstate 命令查看對應(yīng)的端口號是否有被監(jiān)聽。
服務(wù)端進(jìn)入了監(jiān)聽狀態(tài)后,通過調(diào)用 accept() 函數(shù),來從內(nèi)核獲取客戶端的連接,如果沒有客戶端連接,則會阻塞等待客戶端連接的到來。
那客戶端是怎么發(fā)起連接的呢?客戶端在創(chuàng)建好 Socket 后,調(diào)用 connect() 函數(shù)發(fā)起連接,該函數(shù)的參數(shù)要指明服務(wù)端的 IP 地址和端口號,然后萬眾期待的 TCP 三次握手就開始了。
在 TCP 連接的過程中,服務(wù)器的內(nèi)核實際上為每個 Socket 維護(hù)了兩個隊列:
一個是還沒完全建立連接的隊列,稱為 TCP 半連接隊列,這個隊列都是沒有完成三次握手的連接,此時服務(wù)端處于 syn_rcvd 的狀態(tài);
一個是一件建立連接的隊列,稱為 TCP 全連接隊列,這個隊列都是完成了三次握手的連接,此時服務(wù)端處于 established 狀態(tài);
當(dāng) TCP 全連接隊列不為空后,服務(wù)端的 accept() 函數(shù),就會從內(nèi)核中的 TCP 全連接隊列里拿出一個已經(jīng)完成連接的 Socket 返回應(yīng)用程序,后續(xù)數(shù)據(jù)傳輸都用這個 Socket。
注意,監(jiān)聽的 Socket 和真正用來傳數(shù)據(jù)的 Socket 是兩個:
- 一個叫作監(jiān)聽 Socket;
- 一個叫作已連接 Socket;
連接建立后,客戶端和服務(wù)端就開始相互傳輸數(shù)據(jù)了,雙方都可以通過 read() 和write() 函數(shù)來讀寫數(shù)據(jù)。
至此, TCP 協(xié)議的 Socket 程序的調(diào)用過程就結(jié)束了,整個過程如下圖:
看到這,不知道你有沒有覺得讀寫 Socket 的方式,好像讀寫文件一樣。
是的,基于 Linux 一切皆文件的理念,在內(nèi)核中 Socket 也是以「文件」的形式存在的,也是有對應(yīng)的文件描述符。
PS : 下面會說到內(nèi)核里的數(shù)據(jù)結(jié)構(gòu),不感興趣的可以跳過這一部分,不會對后續(xù)的內(nèi)容有影響。
文件描述符的作用是什么?每一個進(jìn)程都有一個數(shù)據(jù)結(jié)構(gòu) task_struct,該結(jié)構(gòu)體里有一個指向「文件描述符數(shù)組」的成員指針。該數(shù)組里列出這個進(jìn)程打開的所有文件的文件描述符。數(shù)組的下標(biāo)是文件描述符,是一個整數(shù),而數(shù)組的內(nèi)容是一個指針,指向內(nèi)核中所有打開的文件的列表,也就是說內(nèi)核可以通過文件描述符找到對應(yīng)打開的文件。
然后每個文件都有一個 inode,Socket 文件的 inode 指向了內(nèi)核中的 Socket 結(jié)構(gòu),在這個結(jié)構(gòu)體里有兩個隊列,分別是發(fā)送隊列和接收隊列,這個兩個隊列里面保存的是一個個 struct sk_buff,用鏈表的組織形式串起來。
sk_buff 可以表示各個層的數(shù)據(jù)包,在應(yīng)用層數(shù)據(jù)包叫 data,在 TCP 層我們稱為 segment,在 IP 層我們叫 packet,在數(shù)據(jù)鏈路層稱為 frame。
你可能會好奇,為什么全部數(shù)據(jù)包只用一個結(jié)構(gòu)體來描述呢?協(xié)議棧采用的是分層結(jié)構(gòu),上層向下層傳遞數(shù)據(jù)時需要增加包頭,下層向上層數(shù)據(jù)時又需要去掉包頭,如果每一層都用一個結(jié)構(gòu)體,那在層之間傳遞數(shù)據(jù)的時候,就要發(fā)生多次拷貝,這將大大降低 CPU 效率。
于是,為了在層級之間傳遞數(shù)據(jù)時,不發(fā)生拷貝,只用 sk_buff 一個結(jié)構(gòu)體來描述所有的網(wǎng)絡(luò)包,那它是如何做到的呢?是通過調(diào)整 sk_buff 中 data 的指針,比如:
- 當(dāng)接收報文時,從網(wǎng)卡驅(qū)動開始,通過協(xié)議棧層層往上傳送數(shù)據(jù)報,通過增加 skb->data 的值,來逐步剝離協(xié)議首部。
- 當(dāng)要發(fā)送報文時,創(chuàng)建 sk_buff 結(jié)構(gòu)體,數(shù)據(jù)緩存區(qū)的頭部預(yù)留足夠的空間,用來填充各層首部,在經(jīng)過各下層協(xié)議時,通過減少 skb->data 的值來增加協(xié)議首部。
你可以從下面這張圖看到,當(dāng)發(fā)送報文時,data 指針的移動過程。
如何服務(wù)更多的用戶?
前面提到的 TCP Socket 調(diào)用流程是最簡單、最基本的,它基本只能一對一通信,因為使用的是同步阻塞的方式,當(dāng)服務(wù)端在還沒處理完一個客戶端的網(wǎng)絡(luò) I/O 時,或者 讀寫操作發(fā)生阻塞時,其他客戶端是無法與服務(wù)端連接的。
可如果我們服務(wù)器只能服務(wù)一個客戶,那這樣就太浪費資源了,于是我們要改進(jìn)這個網(wǎng)絡(luò) I/O 模型,以支持更多的客戶端。
在改進(jìn)網(wǎng)絡(luò) I/O 模型前,我先來提一個問題,你知道服務(wù)器單機(jī)理論最大能連接多少個客戶端?
相信你知道 TCP 連接是由四元組唯一確認(rèn)的,這個四元組就是:本機(jī)IP, 本機(jī)端口, 對端IP, 對端端口。
服務(wù)器作為服務(wù)方,通常會在本地固定監(jiān)聽一個端口,等待客戶端的連接。因此服務(wù)器的本地 IP 和端口是固定的,于是對于服務(wù)端 TCP 連接的四元組只有對端 IP 和端口是會變化的,所以最大 TCP 連接數(shù) = 客戶端 IP 數(shù)×客戶端端口數(shù)。
對于 IPv4,客戶端的 IP 數(shù)最多為 2 的 32 次方,客戶端的端口數(shù)最多為 2 的 16 次方,也就是服務(wù)端單機(jī)最大 TCP 連接數(shù)約為 2 的 48 次方。
這個理論值相當(dāng)“豐滿”,但是服務(wù)器肯定承載不了那么大的連接數(shù),主要會受兩個方面的限制:
- 文件描述符,Socket 實際上是一個文件,也就會對應(yīng)一個文件描述符。在 Linux 下,單個進(jìn)程打開的文件描述符數(shù)是有限制的,沒有經(jīng)過修改的值一般都是 1024,不過我們可以通過 ulimit 增大文件描述符的數(shù)目;
- 系統(tǒng)內(nèi)存,每個 TCP 連接在內(nèi)核中都有對應(yīng)的數(shù)據(jù)結(jié)構(gòu),意味著每個連接都是會占用一定內(nèi)存的;
那如果服務(wù)器的內(nèi)存只有 2 GB,網(wǎng)卡是千兆的,能支持并發(fā) 1 萬請求嗎?
并發(fā) 1 萬請求,也就是經(jīng)典的 C10K 問題 ,C 是 Client 單詞首字母縮寫,C10K 就是單機(jī)同時處理 1 萬個請求的問題。
從硬件資源角度看,對于 2GB 內(nèi)存千兆網(wǎng)卡的服務(wù)器,如果每個請求處理占用不到 200KB 的內(nèi)存和 100Kbit 的網(wǎng)絡(luò)帶寬就可以滿足并發(fā) 1 萬個請求。
不過,要想真正實現(xiàn) C10K 的服務(wù)器,要考慮的地方在于服務(wù)器的網(wǎng)絡(luò) I/O 模型,效率低的模型,會加重系統(tǒng)開銷,從而會離 C10K 的目標(biāo)越來越遠(yuǎn)。
多進(jìn)程模型
基于最原始的阻塞網(wǎng)絡(luò) I/O, 如果服務(wù)器要支持多個客戶端,其中比較傳統(tǒng)的方式,就是使用多進(jìn)程模型,也就是為每個客戶端分配一個進(jìn)程來處理請求。
服務(wù)器的主進(jìn)程負(fù)責(zé)監(jiān)聽客戶的連接,一旦與客戶端連接完成,accept() 函數(shù)就會返回一個「已連接 Socket」,這時就通過 fork() 函數(shù)創(chuàng)建一個子進(jìn)程,實際上就把父進(jìn)程所有相關(guān)的東西都復(fù)制一份,包括文件描述符、內(nèi)存地址空間、程序計數(shù)器、執(zhí)行的代碼等。
這兩個進(jìn)程剛復(fù)制完的時候,幾乎一摸一樣。不過,會根據(jù)返回值來區(qū)分是父進(jìn)程還是子進(jìn)程,如果返回值是 0,則是子進(jìn)程;如果返回值是其他的整數(shù),就是父進(jìn)程。
正因為子進(jìn)程會復(fù)制父進(jìn)程的文件描述符,于是就可以直接使用「已連接 Socket 」和客戶端通信了,
可以發(fā)現(xiàn),子進(jìn)程不需要關(guān)心「監(jiān)聽 Socket」,只需要關(guān)心「已連接 Socket」;父進(jìn)程則相反,將客戶服務(wù)交給子進(jìn)程來處理,因此父進(jìn)程不需要關(guān)心「已連接 Socket」,只需要關(guān)心「監(jiān)聽 Socket」。
下面這張圖描述了從連接請求到連接建立,父進(jìn)程創(chuàng)建生子進(jìn)程為客戶服務(wù)。
另外,當(dāng)「子進(jìn)程」退出時,實際上內(nèi)核里還會保留該進(jìn)程的一些信息,也是會占用內(nèi)存的,如果不做好“回收”工作,就會變成僵尸進(jìn)程,隨著僵尸進(jìn)程越多,會慢慢耗盡我們的系統(tǒng)資源。
因此,父進(jìn)程要“善后”好自己的孩子,怎么善后呢?那么有兩種方式可以在子進(jìn)程退出后回收資源,分別是調(diào)用 wait() 和 waitpid() 函數(shù)。
這種用多個進(jìn)程來應(yīng)付多個客戶端的方式,在應(yīng)對 100 個客戶端還是可行的,但是當(dāng)客戶端數(shù)量高達(dá)一萬時,肯定扛不住的,因為每產(chǎn)生一個進(jìn)程,必會占據(jù)一定的系統(tǒng)資源,而且進(jìn)程間上下文切換的“包袱”是很重的,性能會大打折扣。
進(jìn)程的上下文切換不僅包含了虛擬內(nèi)存、棧、全局變量等用戶空間的資源,還包括了內(nèi)核堆棧、寄存器等內(nèi)核空間的資源。
多線程模型
既然進(jìn)程間上下文切換的“包袱”很重,那我們就搞個比較輕量級的模型來應(yīng)對多用戶的請求 —— 多線程模型。
線程是運行在進(jìn)程中的一個“邏輯流”,單進(jìn)程中可以運行多個線程,同進(jìn)程里的線程可以共享進(jìn)程的部分資源的,比如文件描述符列表、進(jìn)程空間、代碼、全局?jǐn)?shù)據(jù)、堆、共享庫等,這些共享些資源在上下文切換時是不需要切換,而只需要切換線程的私有數(shù)據(jù)、寄存器等不共享的數(shù)據(jù),因此同一個進(jìn)程下的線程上下文切換的開銷要比進(jìn)程小得多。
當(dāng)服務(wù)器與客戶端 TCP 完成連接后,通過 pthread_create() 函數(shù)創(chuàng)建線程,然后將「已連接 Socket」的文件描述符傳遞給線程函數(shù),接著在線程里和客戶端進(jìn)行通信,從而達(dá)到并發(fā)處理的目的。
如果每來一個連接就創(chuàng)建一個線程,線程運行完后,還得操作系統(tǒng)還得銷毀線程,雖說線程切換的上寫文開銷不大,但是如果頻繁創(chuàng)建和銷毀線程,系統(tǒng)開銷也是不小的。
那么,我們可以使用線程池的方式來避免線程的頻繁創(chuàng)建和銷毀,所謂的線程池,就是提前創(chuàng)建若干個線程,這樣當(dāng)由新連接建立時,將這個已連接的 Socket 放入到一個隊列里,然后線程池里的線程負(fù)責(zé)從隊列中取出已連接 Socket 進(jìn)程處理。
需要注意的是,這個隊列是全局的,每個線程都會操作,為了避免多線程競爭,線程在操作這個隊列前要加鎖。
上面基于進(jìn)程或者線程模型的,其實還是有問題的。新到來一個 TCP 連接,就需要分配一個進(jìn)程或者線程,那么如果要達(dá)到 C10K,意味著要一臺機(jī)器維護(hù) 1 萬個連接,相當(dāng)于要維護(hù) 1 萬個進(jìn)程/線程,操作系統(tǒng)就算死扛也是扛不住的。
I/O 多路復(fù)用
既然為每個請求分配一個進(jìn)程/線程的方式不合適,那有沒有可能只使用一個進(jìn)程來維護(hù)多個 Socket 呢?答案是有的,那就是 I/O 多路復(fù)用技術(shù)。
一個進(jìn)程雖然任一時刻只能處理一個請求,但是處理每個請求的事件時,耗時控制在 1 毫秒以內(nèi),這樣 1 秒內(nèi)就可以處理上千個請求,把時間拉長來看,多個請求復(fù)用了一個進(jìn)程,這就是多路復(fù)用,這種思想很類似一個 CPU 并發(fā)多個進(jìn)程,所以也叫做時分多路復(fù)用。
我們熟悉的 select/poll/epoll 內(nèi)核提供給用戶態(tài)的多路復(fù)用系統(tǒng)調(diào)用,進(jìn)程可以通過一個系統(tǒng)調(diào)用函數(shù)從內(nèi)核中獲取多個事件。
select/poll/epoll 是如何獲取網(wǎng)絡(luò)事件的呢?在獲取事件時,先把所有連接(文件描述符)傳給內(nèi)核,再由內(nèi)核返回產(chǎn)生了事件的連接,然后在用戶態(tài)中再處理這些連接對應(yīng)的請求即可。
select/poll/epoll 這是三個多路復(fù)用接口,都能實現(xiàn) C10K 嗎?接下來,我們分別說說它們。
select/poll
select 實現(xiàn)多路復(fù)用的方式是,將已連接的 Socket 都放到一個文件描述符集合,然后調(diào)用 select 函數(shù)將文件描述符集合拷貝到內(nèi)核里,讓內(nèi)核來檢查是否有網(wǎng)絡(luò)事件產(chǎn)生,檢查的方式很粗暴,就是通過遍歷文件描述符集合的方式,當(dāng)檢查到有事件產(chǎn)生后,將此 Socket 標(biāo)記為可讀或可寫, 接著再把整個文件描述符集合拷貝回用戶態(tài)里,然后用戶態(tài)還需要再通過遍歷的方法找到可讀或可寫的 Socket,然后再對其處理。
所以,對于 select 這種方式,需要進(jìn)行 2 次「遍歷」文件描述符集合,一次是在內(nèi)核態(tài)里,一個次是在用戶態(tài)里 ,而且還會發(fā)生 2 次「拷貝」文件描述符集合,先從用戶空間傳入內(nèi)核空間,由內(nèi)核修改后,再傳出到用戶空間中。
select 使用固定長度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的個數(shù)是有限制的,在 Linux 系統(tǒng)中,由內(nèi)核中的 FD_SETSIZE 限制, 默認(rèn)最大值為 1024,只能監(jiān)聽 0~1023 的文件描述符。
poll 不再用 BitsMap 來存儲所關(guān)注的文件描述符,取而代之用動態(tài)數(shù)組,以鏈表形式來組織,突破了 select 的文件描述符個數(shù)限制,當(dāng)然還會受到系統(tǒng)文件描述符限制。
但是 poll 和 select 并沒有太大的本質(zhì)區(qū)別,都是使用「線性結(jié)構(gòu)」存儲進(jìn)程關(guān)注的 Socket 集合,因此都需要遍歷文件描述符集合來找到可讀或可寫的 Socket,時間復(fù)雜度為 O(n),而且也需要在用戶態(tài)與內(nèi)核態(tài)之間拷貝文件描述符集合,這種方式隨著并發(fā)數(shù)上來,性能的損耗會呈指數(shù)級增長。
epoll
epoll 通過兩個方面,很好解決了 select/poll 的問題。
第一點,epoll 在內(nèi)核里使用紅黑樹來跟蹤進(jìn)程所有待檢測的文件描述字,把需要監(jiān)控的 socket 通過 epoll_ctl() 函數(shù)加入內(nèi)核中的紅黑樹里,紅黑樹是個高效的數(shù)據(jù)結(jié)構(gòu),增刪查一般時間復(fù)雜度是 O(logn),通過對這棵黑紅樹進(jìn)行操作,這樣就不需要像 select/poll 每次操作時都傳入整個 socket 集合,只需要傳入一個待檢測的 socket,減少了內(nèi)核和用戶空間大量的數(shù)據(jù)拷貝和內(nèi)存分配。
第二點, epoll 使用事件驅(qū)動的機(jī)制,內(nèi)核里維護(hù)了一個鏈表來記錄就緒事件,當(dāng)某個 socket 有事件發(fā)生時,通過回調(diào)函數(shù)內(nèi)核會將其加入到這個就緒事件列表中,當(dāng)用戶調(diào)用 epoll_wait() 函數(shù)時,只會返回有事件發(fā)生的文件描述符的個數(shù),不需要像 select/poll 那樣輪詢掃描整個 socket 集合,大大提高了檢測的效率。
從下圖你可以看到 epoll 相關(guān)的接口作用:
epoll 的方式即使監(jiān)聽的 Socket 數(shù)量越多的時候,效率不會大幅度降低,能夠同時監(jiān)聽的 Socket 的數(shù)目也非常的多了,上限就為系統(tǒng)定義的進(jìn)程打開的最大文件描述符個數(shù)。因而,epoll 被稱為解決 C10K 問題的利器。
插個題外話,網(wǎng)上文章不少說,epoll_wait 返回時,對于就緒的事件,epoll使用的是共享內(nèi)存的方式,即用戶態(tài)和內(nèi)核態(tài)都指向了就緒鏈表,所以就避免了內(nèi)存拷貝消耗。
這是錯的!看過 epoll 內(nèi)核源碼的都知道,壓根就沒有使用共享內(nèi)存這個玩意。你可以從下面這份代碼看到, epoll_wait 實現(xiàn)的內(nèi)核代碼中調(diào)用了 __put_user 函數(shù),這個函數(shù)就是將數(shù)據(jù)從內(nèi)核拷貝到用戶空間。
好了,這個題外話就說到這了,我們繼續(xù)!
epoll 支持兩種事件觸發(fā)模式,分別是邊緣觸發(fā)(edge-triggered,ET)和水平觸發(fā)(level-triggered,LT)。
這兩個術(shù)語還挺抽象的,其實它們的區(qū)別還是很好理解的。
- 使用邊緣觸發(fā)模式時,當(dāng)被監(jiān)控的 Socket 描述符上有可讀事件發(fā)生時,服務(wù)器端只會從 epoll_wait 中蘇醒一次,即使進(jìn)程沒有調(diào)用 read 函數(shù)從內(nèi)核讀取數(shù)據(jù),也依然只蘇醒一次,因此我們程序要保證一次性將內(nèi)核緩沖區(qū)的數(shù)據(jù)讀取完;
- 使用水平觸發(fā)模式時,當(dāng)被監(jiān)控的 Socket 上有可讀事件發(fā)生時,服務(wù)器端不斷地從 epoll_wait 中蘇醒,直到內(nèi)核緩沖區(qū)數(shù)據(jù)被 read 函數(shù)讀完才結(jié)束,目的是告訴我們有數(shù)據(jù)需要讀取;
舉個例子,你的快遞被放到了一個快遞箱里,如果快遞箱只會通過短信通知你一次,即使你一直沒有去取,它也不會再發(fā)送第二條短信提醒你,這個方式就是邊緣觸發(fā);如果快遞箱發(fā)現(xiàn)你的快遞沒有被取出,它就會不停地發(fā)短信通知你,直到你取出了快遞,它才消停,這個就是水平觸發(fā)的方式。
這就是兩者的區(qū)別,水平觸發(fā)的意思是只要滿足事件的條件,比如內(nèi)核中有數(shù)據(jù)需要讀,就一直不斷地把這個事件傳遞給用戶;而邊緣觸發(fā)的意思是只有第一次滿足條件的時候才觸發(fā),之后就不會再傳遞同樣的事件了。
如果使用水平觸發(fā)模式,當(dāng)內(nèi)核通知文件描述符可讀寫時,接下來還可以繼續(xù)去檢測它的狀態(tài),看它是否依然可讀或可寫。所以在收到通知后,沒必要一次執(zhí)行盡可能多的讀寫操作。
如果使用邊緣觸發(fā)模式,I/O 事件發(fā)生時只會通知一次,而且我們不知道到底能讀寫多少數(shù)據(jù),所以在收到通知后應(yīng)盡可能地讀寫數(shù)據(jù),以免錯失讀寫的機(jī)會。因此,我們會循環(huán)從文件描述符讀寫數(shù)據(jù),那么如果文件描述符是阻塞的,沒有數(shù)據(jù)可讀寫時,進(jìn)程會阻塞在讀寫函數(shù)那里,程序就沒辦法繼續(xù)往下執(zhí)行。所以,邊緣觸發(fā)模式一般和非阻塞 I/O 搭配使用,程序會一直執(zhí)行 I/O 操作,直到系統(tǒng)調(diào)用(如 read 和 write)返回錯誤,錯誤類型為 EAGAIN 或 EWOULDBLOCK。
一般來說,邊緣觸發(fā)的效率比水平觸發(fā)的效率要高,因為邊緣觸發(fā)可以減少 epoll_wait 的系統(tǒng)調(diào)用次數(shù),系統(tǒng)調(diào)用也是有一定的開銷的的,畢竟也存在上下文的切換。
select/poll 只有水平觸發(fā)模式,epoll 默認(rèn)的觸發(fā)模式是水平觸發(fā),但是可以根據(jù)應(yīng)用場景設(shè)置為邊緣觸發(fā)模式。
另外,使用 I/O 多路復(fù)用時,最好搭配非阻塞 I/O 一起使用,Linux 手冊關(guān)于 select 的內(nèi)容中有如下說明:
Under Linux, select() may report a socket file descriptor as "ready for reading", while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
我谷歌翻譯的結(jié)果:
在Linux下,select() 可能會將一個 socket 文件描述符報告為 "準(zhǔn)備讀取",而后續(xù)的讀取塊卻沒有。例如,當(dāng)數(shù)據(jù)已經(jīng)到達(dá),但經(jīng)檢查后發(fā)現(xiàn)有錯誤的校驗和而被丟棄時,就會發(fā)生這種情況。也有可能在其他情況下,文件描述符被錯誤地報告為就緒。因此,在不應(yīng)該阻塞的 socket 上使用 O_NONBLOCK 可能更安全。
簡單點理解,就是多路復(fù)用 API 返回的事件并不一定可讀寫的,如果使用阻塞 I/O, 那么在調(diào)用 read/write 時則會發(fā)生程序阻塞,因此最好搭配非阻塞 I/O,以便應(yīng)對極少數(shù)的特殊情況。
總結(jié)
最基礎(chǔ)的 TCP 的 Socket 編程,它是阻塞 I/O 模型,基本上只能一對一通信,那為了服務(wù)更多的客戶端,我們需要改進(jìn)網(wǎng)絡(luò) I/O 模型。
比較傳統(tǒng)的方式是使用多進(jìn)程/線程模型,每來一個客戶端連接,就分配一個進(jìn)程/線程,然后后續(xù)的讀寫都在對應(yīng)的進(jìn)程/線程,這種方式處理 100 個客戶端沒問題,但是當(dāng)客戶端增大到 10000 個時,10000 個進(jìn)程/線程的調(diào)度、上下文切換以及它們占用的內(nèi)存,都會成為瓶頸。
為了解決上面這個問題,就出現(xiàn)了 I/O 的多路復(fù)用,可以只在一個進(jìn)程里處理多個文件的 I/O,Linux 下有三種提供 I/O 多路復(fù)用的 API,分別是:select、poll、epoll。
select 和 poll 并沒有本質(zhì)區(qū)別,它們內(nèi)部都是使用「線性結(jié)構(gòu)」來存儲進(jìn)程關(guān)注的 Socket 集合。
在使用的時候,首先需要把關(guān)注的 Socket 集合通過 select/poll 系統(tǒng)調(diào)用從用戶態(tài)拷貝到內(nèi)核態(tài),然后由內(nèi)核檢測事件,當(dāng)有網(wǎng)絡(luò)事件產(chǎn)生時,內(nèi)核需要遍歷進(jìn)程關(guān)注 Socket 集合,找到對應(yīng)的 Socket,并設(shè)置其狀態(tài)為可讀/可寫,然后把整個 Socket 集合從內(nèi)核態(tài)拷貝到用戶態(tài),用戶態(tài)還要繼續(xù)遍歷整個 Socket 集合找到可讀/可寫的 Socket,然后對其處理。
很明顯發(fā)現(xiàn),select 和 poll 的缺陷在于,當(dāng)客戶端越多,也就是 Socket 集合越大,Socket 集合的遍歷和拷貝會帶來很大的開銷,因此也很難應(yīng)對 C10K。
epoll 是解決 C10K 問題的利器,通過兩個方面解決了 select/poll 的問題。
epoll 在內(nèi)核里使用「紅黑樹」來關(guān)注進(jìn)程所有待檢測的 Socket,紅黑樹是個高效的數(shù)據(jù)結(jié)構(gòu),增刪查一般時間復(fù)雜度是 O(logn),通過對這棵黑紅樹的管理,不需要像 select/poll 在每次操作時都傳入整個 Socket 集合,減少了內(nèi)核和用戶空間大量的數(shù)據(jù)拷貝和內(nèi)存分配。
epoll 使用事件驅(qū)動的機(jī)制,內(nèi)核里維護(hù)了一個「鏈表」來記錄就緒事件,只將有事件發(fā)生的 Socket 集合傳遞給應(yīng)用程序,不需要像 select/poll 那樣輪詢掃描整個集合(包含有和無事件的 Socket ),大大提高了檢測的效率。
而且,epoll 支持邊緣觸發(fā)和水平觸發(fā)的方式,而 select/poll 只支持水平觸發(fā),一般而言,邊緣觸發(fā)的方式會比水平觸發(fā)的效率高。
參考資料
https://www.zhihu.com/question/39792257
https://journey-c.github.io/io-multiplexing/#25-io-multiplexing
https://panqiincs.me/2015/08/01/io-multiplexing-with-epoll/
原文鏈接:https://mp.weixin.qq.com/s/Qpa0qXxuIM8jrBqDaXmVNA