Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解
引言
本篇前半部分屬于知識點,后半部分的[手撕面答環(huán)節(jié)],以問題展開,應(yīng)對面試場景作答,盡量簡短,可以在學(xué)習(xí)了前置知識后,嘗試自己作答復(fù)述喔。
本篇先簡單介紹常見的IO模型,還未深入具體Redis中的應(yīng)用,可以把這節(jié)當(dāng)做【操作系統(tǒng)】來啃hhh
🎨本篇腦圖速覽
🎯常見的幾種網(wǎng)絡(luò)模型?
阻塞 IO
-
過程 1:應(yīng)用程序想要去讀取數(shù)據(jù),他是無法直接去讀取磁盤數(shù)據(jù)的,他需要先到內(nèi)核里邊去等待內(nèi)核操作硬件拿到數(shù)據(jù),這個等待數(shù)據(jù)就緒的過程便是過程1。
-
過程 2:內(nèi)核態(tài)準(zhǔn)備好了,開始拷貝數(shù)據(jù)給用戶緩沖區(qū),便是過程2。
用戶去讀取數(shù)據(jù)時,會去先發(fā)起 recvform 一個命令,去嘗試從內(nèi)核上加載數(shù)據(jù),如果內(nèi)核沒有數(shù)據(jù),那么用戶就會等待,此時內(nèi)核會去從硬件上讀取數(shù)據(jù),內(nèi)核讀取數(shù)據(jù)之后,會把數(shù)據(jù)拷貝到用戶態(tài),并且返回 ok,整個過程,都是阻塞等待的,這就是阻塞 IO
也就是兩個過程都阻塞的話,便是阻塞IO
總結(jié)如下:
顧名思義,阻塞 IO 就是兩個階段都必須阻塞等待:
階段一:
- 用戶進程嘗試讀取數(shù)據(jù)(比如網(wǎng)卡數(shù)據(jù))
- 此時數(shù)據(jù)尚未到達,內(nèi)核需要等待數(shù)據(jù)
- 此時用戶進程也處于阻塞狀態(tài)
階段二:
- 數(shù)據(jù)到達并拷貝到內(nèi)核緩沖區(qū),代表已就緒
- 將內(nèi)核數(shù)據(jù)拷貝到用戶緩沖區(qū)
- 拷貝過程中,用戶進程依然阻塞等待
- 拷貝完成,用戶進程解除阻塞,處理數(shù)據(jù)
流程圖
非阻塞 IO
顧名思義,非阻塞 IO 的 recvfrom 操作會立即返回結(jié)果而不是阻塞用戶進程。
階段一:
- 用戶進程嘗試讀取數(shù)據(jù)(比如網(wǎng)卡數(shù)據(jù))
- 此時數(shù)據(jù)尚未到達,內(nèi)核需要等待數(shù)據(jù)
- 返回異常給用戶進程
- 用戶進程收到 error 后,再次嘗試讀取【忙輪詢】
- 循環(huán)往復(fù),直到數(shù)據(jù)就緒
階段二:
- 將內(nèi)核數(shù)據(jù)拷貝到用戶緩沖區(qū)
- 拷貝過程中,用戶進程依然阻塞等待
- 拷貝完成,用戶進程解除阻塞,處理數(shù)據(jù)
可以看到,非阻塞 IO 模型中,用戶進程在第一個階段是非阻塞,第二個階段是阻塞狀態(tài)。雖然是非阻塞,但性能并沒有得到提高。而且忙等機制會導(dǎo)致 CPU 空轉(zhuǎn),CPU 使用率暴增。
信號驅(qū)動
信號驅(qū)動 IO 是與內(nèi)核建立 SIGIO 的信號關(guān)聯(lián)并設(shè)置回調(diào),當(dāng)內(nèi)核有 FD 就緒時,會發(fā)出 SIGIO 信號通知用戶,期間用戶應(yīng)用可以執(zhí)行其它業(yè)務(wù),無需阻塞等待。
階段一:
- 用戶進程調(diào)用 sigaction ,注冊信號處理函數(shù)
- 內(nèi)核返回成功,開始監(jiān)聽 FD
- 用戶進程不阻塞等待,可以執(zhí)行其它業(yè)務(wù)
- 當(dāng)內(nèi)核數(shù)據(jù)就緒后,回調(diào)用戶進程的 SIGIO 處理函數(shù)
階段二:
- 收到 SIGIO 回調(diào)信號
- 調(diào)用 recvfrom ,讀取
- 內(nèi)核將數(shù)據(jù)拷貝到用戶空間
- 用戶進程處理數(shù)據(jù)
缺點
當(dāng)有大量 IO 操作時,信號較多,SIGIO 處理函數(shù)不能及時處理可能導(dǎo)致信號隊列溢出,而且內(nèi)核空間與用戶空間的頻繁信號交互性能也較低。
異步 IO
這種方式,不僅僅是用戶態(tài)在試圖讀取數(shù)據(jù)后,不阻塞,而且當(dāng)內(nèi)核的數(shù)據(jù)準(zhǔn)備完成后,也不會阻塞
兩個過程都不阻塞
他會由內(nèi)核將所有數(shù)據(jù)處理完成后,由內(nèi)核將數(shù)據(jù)寫入到用戶態(tài)中,然后才算完成,所以性能極高,不會有任何阻塞,全部都由內(nèi)核完成,可以看到,異步 IO 模型中,用戶進程在兩個階段都是非阻塞狀態(tài)。
缺點
得做好限流,不然無腦的給內(nèi)核去干,相當(dāng)于領(lǐng)導(dǎo)不管用戶死活,一股腦塞
🎯Java中常見的IO模型
BIO
上文的阻塞IO
NIO
上文的非阻塞IO
AIO
其實就是上文的異步模型
🎯什么是IO多路復(fù)用
定義 & 流程
當(dāng)用戶進程調(diào)用了select,那么整個進程會被阻塞,而同時,內(nèi)核會"監(jiān)視"所有select負責(zé)的socket,當(dāng)任何一個socket中的數(shù)據(jù)準(zhǔn)備好了,select就會返回。這個時候用戶進程再調(diào)用read操作,將數(shù)據(jù)從內(nèi)核拷貝到用戶進程。
這個模型和阻塞IO的模型其實并沒有太大的不同,事實上還更差一些。因為這里需要使用兩個系統(tǒng)調(diào)用(select和recvfrom),而阻塞IO只調(diào)用了一個系統(tǒng)調(diào)用(recvfrom)。
- 但是,用select的優(yōu)勢在于它可以同時處理多個連接。所以,如果系統(tǒng)的連接數(shù)不是很高的話,使用select/epoll的web server不一定比使用多線程的阻塞IO的web server性能更好,可能延遲還更大;select/epoll的優(yōu)勢并不是對單個連接能處理得更快,而是在于能處理更多的連接。
🎯IO多路復(fù)用的三種實現(xiàn)方式
目前流程的多路復(fù)用 IO 實現(xiàn)主要包括四種: select、poll、epoll、kqueue。下表是他們的一些重要特性的比較:
| select | 較高 | Reactor | windows/Linux | 支持,Reactor 模式 (反應(yīng)器設(shè)計模式)。Linux 操作系統(tǒng)的 kernels 2.4 內(nèi)核版本之前,默認(rèn)使用 select;而目前 windows 下對同步 IO 的支持,都是 select 模型 |
| poll | 較高 | Reactor | Linux | Linux 下的 JAVA NIO 框架,Linux kernels 2.6 內(nèi)核版本之前使用 poll 進行支持。也是使用的 Reactor 模式 |
| epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6 內(nèi)核版本及以后使用 epoll 進行支持;Linux kernels 2.6 內(nèi)核版本之前使用 poll 進行支持;另外一定注意,由于 Linux 下沒有 Windows 下的 IOCP 技術(shù)提供真正的 異步 IO 支持,所以 Linux 下使用 epoll 模擬異步 IO |
| kqueue | 高 | Proactor | Linux | 目前 JAVA 的版本不支持 |
select
select 是 Linux 最早的 I/O 多路復(fù)用技術(shù):
linux 中,一切皆文件,socket 也不例外,我們把需要處理的數(shù)據(jù)封裝成 FD,然后在用戶態(tài)時創(chuàng)建一個 fd_set 的集合(這個集合的大小是要監(jiān)聽的那個 FD 的最大值 + 1,但是大小整體是有限制的 ),這個集合的長度大小是有限制的,同時在這個集合中,標(biāo)明出來我們要控制哪些數(shù)據(jù)。
具體流程
用戶態(tài) :
內(nèi)核態(tài):
🎈源碼&流程
🎐不足之處
需要進行 2 次「遍歷」文件描述符集合,一次是在內(nèi)核態(tài)里,一個次是在用戶態(tài)里 ,而且還會發(fā)生 2 次「拷貝」文件描述符集合,先從用戶空間傳入內(nèi)核空間,由內(nèi)核修改后,再傳出到用戶空間中。
poll
poll 模式對 select 模式做了簡單改進,但性能提升不明顯。
具體流程:
與 select 對比
大小方面:
- select 模式中的 fd_set 大小固定為 1024,而 pollfd 在內(nèi)核中采用鏈表,理論上無上限,但實際上不能這么做,因為的監(jiān)聽 FD 越多,每次遍歷消耗時間也越久,性能反而會下降
🎈epoll
epoll 模式是對 select 和 poll 的改進,它提供了三個函數(shù):eventpoll 、epoll_ctl 、epoll_wait
-
eventpoll 函數(shù)內(nèi)部包含了兩個東西 :
- 紅黑樹 :用來記錄所有的 fd
- 鏈表 : 記錄已就緒的 fd 、
-
epoll_ctl 函數(shù) ,將要監(jiān)聽的 fd 添加到 紅黑樹 上去,并且給每個 fd 綁定一個監(jiān)聽函數(shù),當(dāng) fd 就緒時就會被觸發(fā),這個監(jiān)聽函數(shù)的操作就是 將這個 fd 添加到 鏈表中去。
-
epoll_wait 函數(shù),就緒等待。一開始,用戶態(tài) buffer 中創(chuàng)建一個空的 events 數(shù)組,當(dāng)就緒之后,我們的回調(diào)函數(shù)會把 fd 添加到鏈表中去
- 當(dāng)函數(shù)被調(diào)用的時候,會去檢查鏈表(當(dāng)然這個過程需要參考配置的等待時間,可以等一定時間,也可以一直等)
- 如果鏈表中沒有 fd ,則 fd 會從紅黑樹被添加到鏈表中,此時再將鏈表中的的 fd 復(fù)制到用戶態(tài)的空 events中,并且返回對應(yīng)的操作數(shù)量,用戶態(tài)此時收到響應(yīng)后,會從 events 中拿到已經(jīng)準(zhǔn)備好的數(shù)據(jù),在調(diào)用 讀方法 去拿數(shù)據(jù)。
- 當(dāng)函數(shù)被調(diào)用的時候,會去檢查鏈表(當(dāng)然這個過程需要參考配置的等待時間,可以等一定時間,也可以一直等)
🎈🎈總結(jié)
select 模式存在的三個問題:
- 能監(jiān)聽的 FD 最大不超過 1024
- 每次 select 都需要把所有要監(jiān)聽的 FD 都拷貝到內(nèi)核空間
- 每次都要遍歷所有 FD 來判斷就緒狀態(tài)
poll 模式的問題:
- poll 利用鏈表解決了 select 中監(jiān)聽 FD 上限的問題,但依然要遍歷所有 FD,如果監(jiān)聽較多,性能會下降
epoll 模式中如何解決這些問題的?
- 基于 epoll 實例中的紅黑樹保存要監(jiān)聽的 FD,理論上無上限 ,而且增刪改查效率都非常高,性能不會隨監(jiān)聽
- 每個 FD 只需要執(zhí)行一次 epoll_ctl 添加到紅黑樹,以后每次 epol_wait 無需傳遞任何參數(shù),無需重復(fù)拷貝 FD 到內(nèi)核空間
- 利用 ep_poll_callback 機制來監(jiān)聽 FD 狀態(tài),無需遍歷所有 FD,因此性能不會隨監(jiān)聽的 FD 數(shù)量增多而下降
🎯邊緣觸發(fā)和水平觸發(fā)
epoll 支持兩種事件觸發(fā)模式,分別是邊緣觸發(fā)(edge-triggered,ET)和水平觸發(fā)(level-triggered,LT)。
-
使用邊緣觸發(fā)模式時,當(dāng)被監(jiān)控的 Socket 描述符上有可讀事件發(fā)生時,服務(wù)器端只會從 epoll_wait 中蘇醒一次,即使進程沒有調(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ù)需要讀取;
這個過程是用戶空間去讀內(nèi)核空間
水平觸發(fā)的意思是只要滿足事件的條件,比如內(nèi)核中有數(shù)據(jù)需要讀,就一直不斷地把這個事件傳遞給用戶;而邊緣觸發(fā)的意思是只有第一次滿足條件的時候才觸發(fā),之后就不會再傳遞同樣的事件了。
如果使用水平觸發(fā)模式,當(dāng)內(nèi)核通知文件描述符可讀寫時,接下來還可以繼續(xù)去檢測它的狀態(tài),看它是否依然可讀或可寫。所以在收到通知后,沒必要一次執(zhí)行盡可能多的讀寫操作。
邊緣觸發(fā)注意點
如果使用邊緣觸發(fā)模式,I/O 事件發(fā)生時只會通知一次,而且我們不知道到底能讀寫多少數(shù)據(jù),所以在收到通知后應(yīng)盡可能地讀寫數(shù)據(jù),以免錯失讀寫的機會。
因此,我們會循環(huán)從文件描述符讀寫數(shù)據(jù)【圖中的④操作使用循環(huán)】,那么如果文件描述符是阻塞的,沒有數(shù)據(jù)可讀寫時,進程會阻塞在讀寫函數(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ā)模式。
🍿🍿🍿手撕面答環(huán)節(jié) -- 這是一條分割線
劃掉的部分屬于melo復(fù)述時,發(fā)送的疏漏之處/答錯的地方hhh
🍔select,poll,epoll的區(qū)別
select
用戶注冊了自己需要監(jiān)聽的設(shè)備,記錄在一個fd數(shù)組里邊,拷貝給內(nèi)核態(tài)服務(wù)端,服務(wù)端那邊若準(zhǔn)備好了,會修改fd數(shù)組中對應(yīng)設(shè)備的位置,值改為1,并且把整個fd數(shù)組拷貝回用戶態(tài)
實際上服務(wù)端還要遍歷一遍fd數(shù)組,標(biāo)記就緒的fd為1,拷貝回用戶態(tài)
用戶態(tài)再遍歷一遍fd數(shù)組,找到其中值為1的,說明準(zhǔn)備好了,可以開始拷貝了。
不足之處
涉及到多次拷貝,用戶態(tài)和內(nèi)核態(tài)的切換
poll
跟select的區(qū)別主要在于,不是用fd數(shù)組了,而是用一個鏈表,理論上可以無限節(jié)點,但本質(zhì)上,節(jié)點數(shù)量越多,效率自然隨著降低,有沒有能夠解決這種節(jié)點數(shù)影響效率的限制呢?這個時候epoll就出來了,紅黑樹。
更具體一點是,用戶態(tài)仍然是fd數(shù)組,轉(zhuǎn)到內(nèi)核態(tài)才變?yōu)殒湵泶鎯?/p>
epoll
把要監(jiān)聽的設(shè)備,都注冊到一棵紅黑樹上邊,并給每個節(jié)點綁定監(jiān)聽函數(shù),但服務(wù)端準(zhǔn)備就緒時,會觸發(fā)監(jiān)聽函數(shù),把該節(jié)點拷貝到fd數(shù)組上邊【是就緒鏈表上邊】,并且返回給用戶態(tài)【注意只返回準(zhǔn)備好了的設(shè)備,這是跨時代的進步】
優(yōu)點
select/poll 每次操作時都傳入整個 socket 集合給內(nèi)核,而 epoll 因為在內(nèi)核維護了紅黑樹,可以保存所有待檢測的 socket ,所以只需要傳入一個待檢測的 socket,減少了內(nèi)核和用戶空間大量的數(shù)據(jù)拷貝和內(nèi)存分配。
🍔🎐邊緣觸發(fā)為何建議搭配非阻塞IO?
多路復(fù)用 API 返回的事件并不一定可讀寫的【select() 可能會將一個 socket 文件描述符報告為 "準(zhǔn)備讀取",而后續(xù)的讀取塊卻沒有。例如,當(dāng)數(shù)據(jù)已經(jīng)到達,但經(jīng)檢查后發(fā)現(xiàn)有錯誤的校驗和而被丟棄時,就會發(fā)生這種情況】
虛晃一槍,以為準(zhǔn)備好了要給你數(shù)據(jù)了,但這時被丟棄了【又變成還沒準(zhǔn)備好的狀態(tài)】,我們還傻傻的一直在等待讀取
如果使用阻塞 I/O, 那么在調(diào)用 read/write 時則會發(fā)生程序阻塞,
非阻塞 I/O的話,會忙等輪詢,直到系統(tǒng)調(diào)用(如 read 和 write)返回錯誤,錯誤類型為 EAGAIN 或 EWOULDBLOCK。
阻塞IO:當(dāng)你去讀一個阻塞的文件描述符時,如果在該文件描述符上沒有數(shù)據(jù)可讀,那么它會一直阻塞(通俗一點就是一直卡在調(diào)用函數(shù)那里),直到有數(shù)據(jù)可讀。當(dāng)你去寫一個阻塞的文件描述符時,如果在該文件描述符上沒有空間(通常是緩沖區(qū))可寫,那么它會一直阻塞,直到有空間可寫。
非阻塞IO:當(dāng)你去讀寫一個非阻塞的文件描述符時,不管可不可以讀寫,它都會立即返回,返回成功說明讀寫操作完成了,返回失敗會設(shè)置相應(yīng)errno狀態(tài)碼,根據(jù)這個errno可以進一步執(zhí)行其他處理。它不會像阻塞IO那樣,卡在那里不動!!!
另一種答案
由于ET模式下,需要while循環(huán)調(diào)用read和wirte,直到最后返回特定的錯誤類型才退出循環(huán)。
如果采用非阻塞IO,則可能會在最后一次本應(yīng)該跳出循環(huán)的read調(diào)用阻塞住。
🍔epoll的ET和LT有什么區(qū)別
ET:edge trigger 邊緣觸發(fā),指的是當(dāng)socket準(zhǔn)備好了,服務(wù)端只蘇醒一次,所以用戶緩沖區(qū)要一次性把內(nèi)核緩沖區(qū)讀完,nginx就是采用的ET
LT:level-trigger 水平觸發(fā),socket準(zhǔn)備好了,服務(wù)端會不斷蘇醒,直到用戶緩沖區(qū)把內(nèi)核緩沖區(qū)讀完了,redis就是采用的LT
🍔邊緣觸發(fā)如何保證數(shù)據(jù)讀完
while循環(huán)讀寫,直到最后一次返回特定的錯誤類型【EAGAIN錯誤】
🍔ET模式下的accept問題
在某一時刻,有多個連接同時到達,服務(wù)器的 TCP 就緒隊列瞬間積累多個就緒連接,由于是邊緣觸發(fā)模式,epoll 只會通知一次,accept 只處理一個連接,導(dǎo)致 TCP 就緒隊列中剩下的連接都得不到處理。在這種情形下,我們應(yīng)該如何有效的處理呢?
解決的方法是:解決辦法是用 while 循環(huán)包住 accept 調(diào)用,處理完 TCP 就緒隊列中的所有連接后再退出循環(huán)。
如何知道是否處理完就緒隊列中的所有連接呢?
- accept 返回 -1 并且 errno 設(shè)置為 EAGAIN 就表示所有連接都處理完。
🍔epoll讀到一半又有新事件來了怎么辦?
避免在主進程epoll再次監(jiān)聽到同一個可讀事件,可以把對應(yīng)的描述符設(shè)置為EPOLL_ONESHOT,效果是監(jiān)聽到一次事件后就將對應(yīng)的描述符從監(jiān)聽集合中移除,也就不會再被追蹤到。讀完之后可以再把對應(yīng)的描述符重新手動加上。
總結(jié)
以上是生活随笔為你收集整理的Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 股豆网:2019全国高校名单公布共计29
- 下一篇: MySQL MVCC机制解析