一文深度剖析5種IO模型

存儲矩陣 發佈 2022-12-05T14:09:18.883300+00:00

當前我們計算機常見的五種IO模型包括:同步阻塞IO、同步非阻塞IO、IO多路復用、信號驅動IO和異步IO,在介紹5種模型之前,請允許我引用不知哪位高手的比喻:同步阻塞IO - 到女神宿舍樓下給女神發一條微信:「寶貝,我來找你了,一起去吃飯看電影吧」, 然後你就默默的一直等著女神下樓, 這個期間除了等待也做不了其他事情。

遇到的問題

  • 為啥子nginx能同時支撐百萬並發和數十萬連接?
  • 為啥子Redis單線程的性能比多線程的Memcached還要強?
  • 為啥子Dubbo的的通信效率非常高?

其實上面的場景回歸到具體應用上就是一種超強的IO能力,談到IO我們可以先了解有哪些IO模型

追女神的五種技能

當前我們計算機常見的五種IO模型包括:同步阻塞IO、同步非阻塞IO、IO多路復用、信號驅動IO和異步IO,在介紹5種模型之前,請允許我引用不知哪位高手的比喻:

同步阻塞IO - 到女神宿舍樓下給女神發一條微信:「寶貝,我來找你了,一起去吃飯看電影吧」, 然後你就默默的一直等著女神下樓, 這個期間除了等待也做不了其他事情。

同步非阻塞IO - 給女神發微信, 如果她不回, 你就每隔一段時間繼續發, 一直發到女神下樓, 這個期間你除了發簡訊等待不會做其他事情。

IO多路復用 - 找一個樓管阿姨來幫你監視下樓的女生, 這個期間你可以做些其他的事情。比如:去買點水果,或者打打遊戲, 上個廁所等等。IO復用又包括 select, poll, epoll 模式。那麼它們的區別是什麼?

  • select阿姨主動問每一個下樓的女生, 她不知道這個是不是你的女神, 她需要一個一個詢問,如果女生上樓後又下樓,阿姨還是會再問,並且select阿姨能力還有限, 最多一次幫你監視1024個妹子。
  • poll阿姨不限制盯著女生的數量, 只要是經過宿舍樓門口的女生, 都會幫你去問是不是你女神。
  • epoll阿姨不限制盯著女生的數量, 並且也不需要一個一個去問。epoll阿姨會為每個進宿舍樓的女生包包貼上一個大字條,上面寫上女生自己的名字, 只要女生下樓了,epoll阿姨就知道這個是不是你女神了, 然後阿姨再通知你。

上面這些同步IO有一個共同點就是, 當女神走出宿舍門口的時候,你已經站在宿舍門口等著女神的, 此時你屬於阻塞狀態

信號驅動IO:給女神送一款專屬智慧型手機,設置了12個鈴聲提示。當你約女神吃飯的時候,你就對應的撥打吃飯的鈴聲,女神就下樓去吃飯;如果你約女神看電影,就撥打看電影的鈴聲,女神就直接去電影院了......

異步IO:你告訴女神我來了, 然後你就去打遊戲了, 一直到女神下樓了, 發現找不見你了, 女神再給你打電話通知你, 說我下樓了, 你在哪呢? 這時候你才來到宿舍門口。

上面5種追女神的方式你get到了嗎?下面我們在對應的看看作業系統是如何處理這5類IO的。

計算機的IO模型

同步阻塞IO, 看一段基於socket通信的動畫

在Linux中,默認情況下所有socket都是阻塞模式的。當用戶線程調用系統函數read(),內核開始準備數據(從網絡接收數據),內核准備數據完成後,數據從內核拷貝到用戶空間的應用程式緩衝區,數據拷貝完成後,請求才返回。從發起read請求到最終完成內核到應用程式的拷貝,整個過程都是阻塞的。為了提高性能,可以為每個連接都分配一個線程。因此,在大量連接的場景下就需要大量的線程,會造成巨大的性能損耗,這也是傳統阻塞IO的最大缺陷。

非阻塞IO:用戶線程在發起Read請求後立即返回,不用等待內核准備數據的過程。如果Read請求沒讀取到數據,用戶線程會不斷輪詢發起Read請求,直到數據到達(內核准備好數據)後才停止輪詢。非阻塞IO模型雖然避免了由於線程阻塞問題帶來的大量線程消耗,但是頻繁地重複輪詢大大增加了請求次數,對CPU消耗也比較明顯。這種模型在實際應用中很少使用。

不過,這不叫非阻塞 IO,只不過用了多線程的手段使得主線程沒有卡在 read 函數上不往下走罷了。作業系統為我們提供的 read 函數仍然是阻塞的。

所以真正的非阻塞 IO,不能是通過我們用戶層的小把戲,而是要懇請作業系統為我們提供一個非阻塞的 read 函數

這個 read 函數的效果是,如果沒有數據到達時(到達網卡並拷貝到了內核緩衝區),立刻返回一個錯誤值(-1),而不是阻塞地等待。

作業系統提供了這樣的功能,只需要在調用 read 前,將文件描述符設置為非阻塞即可。

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

這樣,就需要用戶線程循環調用 read,直到返回值不為 -1,再開始處理業務。

多路復用IO

上述的處理方式其實已經可以了,我們用了一個主線程進行監聽,每次有一個新的連接進來,我們就新啟動一個線程去發起非阻塞的read()但是這裡有一個很大的問題,就是相當於我們為每一個客戶端都建立了一個線程,服務端的線程資源很容易就被阻塞了,而且創建線程也是很大的系統開銷。

相比於阻塞IO模型,多路復用只是多了一個select/poll/epoll函數。select函數會不斷地輪詢自己所負責的文件描述符/套接字的到達狀態,當某個套接字就緒時,就對這個套接字進行處理。select負責輪詢等待,recvfrom負責拷貝。當用戶進程調用該select,select會監聽所有註冊好的IO,如果所有IO都沒註冊好,調用進程就阻塞。

對於客戶端來說,一般感受不到阻塞,因為請求來了,可以用放到線程池裡執行;但對於執行select的作業系統而言,是阻塞的,需要阻塞地等待某個套接字變為可讀

IO多路復用其實是阻塞在select,poll,epoll這類系統調用上的,復用的是執行select,poll,epoll的線程。

信號驅動IO模型

信號驅動IO模型,應用進程使用sigaction函數,內核會立即返回,也就是說內核准備數據的階段應用進程是非阻塞的。內核准備好數據後向應用進程發送SIGIO信號,接到信號後數據被複製到應用程式進程。

採用這種方式,CPU的利用率很高。不過這種模式下,在大量IO操作的情況下可能造成信號隊列溢出導致信號丟失,造成災難性後果。

異步IO模型

異步IO模型的基本機制是,應用進程告訴內核啟動某個操作,內核操作完成後再通知應用進程。在多路復用IO模型中,socket狀態事件到達,得到通知後,應用進程才開始自行讀取並處理數據。在異步IO模型中,應用進程得到通知時,內核已經讀取完數據並把數據放到了應用進程的緩衝區中,此時應用進程直接使用數據即可。歸納其特點如下:

  • 異步I/O執行的兩個階段都不會阻塞讀寫操作,由內核完成。
  • 完成後內核將數據放到指定的緩衝區,通知應用程式來取。

很明顯,異步IO模型性能很高。不過到目前為止,異步IO和信號驅動IO模型應用並不多見,傳統阻塞IO和多路復用IO模型還是目前應用的主流。Linux2.6版本後才引入異步IO模型,目前很多系統對異步IO模型支持尚不成熟。很多應用場景採用多路復用IO替代異步IO模型。

IO多路復用的目的

提高系統的吞吐能力:與多進程和多線程技術相比,I/O多路復用技術的最大優勢是系統開銷小,系統不必頻繁的創建進程/線程,也不必維護這些進程/線程,從而大大減小了系統的開銷。

如上圖所示,管道的2邊分布著不同的請求和處理服務,大家共用一個溝通通道。左邊的服務等待相關的事件發生時才需要使用管道進行數據傳輸,那麼如何保證這個管道最大程度地為多個服務使用呢?其實就是一個貪心的想法,這個方案就是本文要探討的另一個內容。

IO多路復用的設計理念

I/O多路復用(I/O multiplexing) 指的其實是在單個線程通過記錄跟蹤每一個Socket(I/O流)的狀態來同時管理多個I/O流。本質上都是同步I/O。簡單的說:I/O多路復用就一個線程通過一種機制可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。

IO多路復用的發展和實現

最有名的場景就是nginx的LB服務。nginx使用epoll(一種實現)接收請求,在高並發(很多請求同時打進來)場景中時, epoll會把他們都監視起來,然後像撥開關一樣,誰有數據就撥向誰,然後調用相應的處理函數處理。

常用的IO多路復用實現有三種:select / poll / epoll

  • select是第一個實現 (1983 左右在BSD裡面實現的)。select:http://www.cnblogs.com/Anker/archive/2013/08/14/3258674.html
  • poll 在14年以後(1997年)實現,它修復了select的很多問題。poll:http://www.cnblogs.com/Anker/archive/2013/08/15/3261006.html
  • 5年以後, 在2002, 大神 Davide Libenzi 實現了epoll。epoll:http://www.cnblogs.com/Anker/archive/2013/08/17/3263780.html

select

select系統調用,其實就是輪詢一組 socket,看這組socket 是否有可讀的、可寫的、異常的socket。當timeout設置為0時,表示阻塞操作,此時最少有一個socket準備好,才返回,否則進程阻塞自己,讓出CPU資源;當timeout設置為非0時,表示非阻塞操作,timeout 或者 找到符合條件的socket ,就能返回。

socket的函數聲明:

int select(
    int nfds, //nfds:監控的文件描述符集裡最大文件描述符加1
    fd_set *readfds,// readfds:監控有讀數據到達文件描述符集合,傳入傳出參數
    fd_set *writefds,// writefds:監控寫數據到達文件描述符集合,傳入傳出參數
    fd_set *exceptfds,// exceptfds:監控異常發生達文件描述符集合, 傳入傳出參數
    struct timeval *timeout);// timeout:定時阻塞監控時間,3種情況
//  1.NULL,永遠等下去
//  2.設置timeval,等待固定時間
//  3.設置timeval里時間均為0,檢查描述字後立即返回,輪詢

通過了解select的實現原理,可以推斷出,select存在以下問題:

  1. select 調用需要傳入 fd 數組,需要拷貝一份到內核,高並發場景下這樣的拷貝消耗的資源是驚人的。
  2. select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態,是個同步過程,只不過無系統調用切換上下文的開銷。(內核層可優化為異步事件通知)
  3. select 僅僅返回可讀文件描述符的個數,具體哪個可讀還是要用戶自己遍歷。(可優化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
  4. 傳入的文件描述符的數量有1024的限制。為什麼有這個限制呢?是因為在glibc中,FD_SET結構體是一個數組,整個數組的長度是1024位,每一位表示一個socket句柄。雖然在 linux系統,文件描述符是 從 0 開始,默認最大值是1024,但是可以通過命令調整。因此,1024 並不是內核的限制。(而是glibc的限制)如果想突破1024的限制,那麼不要使用FD_SET read_set,直接 read_set = (fd_set *)malloc(8000/8). //1000位元組,也就是8000位,允許8000個句柄。

poll

poll 也是作業系統提供的系統調用函數。它和 select 的主要區別就是,去掉了 select 只能監聽 1024 個文件描述符的限制,因為傳入的變成了結構體。不在原來的fds上修改,而是將結果放在返回值,因此能精確的得知哪些socket有變化,且不需要重置fds。

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*監控的事件*/
  shortrevents; /*監控事件中滿足條件返回的事件*/
};

epoll

epoll 是優化了select和poll的方案,它解決了 select 和 poll 的一些問題,但是並不代表所以得場景都需要用epoll

還記得上面說的 select 的三個細節麼?

  1. select 調用需要傳入 fd 數組,需要拷貝一份到內核,高並發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不複製)
  2. select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態,是個同步過程,只不過無系統調用切換上下文的開銷。(內核層可優化為異步事件通知)
  3. select 僅僅返回可讀文件描述符的個數,具體哪個可讀還是要用戶自己遍歷。(可優化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)

所以 epoll 主要就是針對這三點進行了改進。

  1. 內核中保存一份文件描述符集合,無需用戶每次都重新傳入,只需告訴內核修改的部分即可。
  2. 內核不再通過輪詢的方式找到就緒的文件描述符,而是通過異步 IO 事件喚醒。
  3. 內核僅會將有 IO 事件的文件描述符返回給用戶,用戶也無需遍歷整個文件描述符集合。

具體,作業系統提供了這三個函數。

第一步,創建一個 epoll 句柄

int epoll_create(int size);

第二步,向內核添加、修改或刪除要監控的文件描述符。

int epoll_ctl(
    int epfd, int op, int fd, struct epoll_event *event);

第三步,類似發起了 select() 調用

int epoll_wait(
    int epfd, struct epoll_event *events, int max events, int timeout);

epoll 在內核里使用「紅黑樹」來關注進程所有待檢測的 Socket,紅黑樹是個高效的數據結構,增刪查一般時間複雜度是 O(logn),通過對這棵黑紅樹的管理,不需要像 select/poll 在每次操作時都傳入整個 Socket 集合,減少了內核和用戶空間大量的數據拷貝和內存分配。

epoll 使用事件驅動的機制,內核里維護了一個「鍊表」來記錄就緒事件,只將有事件發生的 Socket 集合傳遞給應用程式,不需要像 select/poll 那樣輪詢掃描整個集合(包含有和無事件的 Socket ),大大提高了檢測的效率。

epoll有EPOLLLT和EPOLLET兩種觸發模式,LT是默認的模式(水平觸發),ET是「高速」模式(邊緣觸發)。LT模式下,只要這個fd還有數據可讀,每次 epoll_wait都會返回它的事件,提醒用戶程序去操作,而在ET模式中,它只會提示一次,直到下次再有數據流入之前都不會再提示了,無 論fd中是否還有數據可讀。

所以在ET模式下,read一個fd的時候一定要把它的buffer讀光,也就是說一直讀到read的返回值小於請求值,或者 遇到EAGAIN錯誤。還有一個特點是,epoll使用「事件」的就緒通知方式,通過epoll_ctl註冊fd,一旦該fd就緒,內核就會採用類似callback的回調機制來激活該fd,epoll_wait便可以收到通知。

epoll為什麼要有EPOLLET觸發模式?

如果採用EPOLLLT模式的話,系統中一旦有大量你不需要讀寫的就緒文件描述符,它們每次調用epoll_wait都會返回,這樣會大大降低處理程序檢索自己關心的就緒文件描述符的效率.。而採用EPOLLET這種邊沿觸發模式的話,當被監控的文件描述符上有可讀寫事件發生時,epoll_wait()會通知處理程序去讀寫。

如果這次沒有把數據全部讀寫完(如讀寫緩衝區太小),那麼下次調用epoll_wait()時,它不會通知你,也就是它只會通知你一次,直到該文件描述符上出現第二次可讀寫事件才會通知你!這種模式比水平觸發效率高,系統不會充斥大量你不關心的就緒文件描述符。

epoll 支持邊緣觸發和水平觸發的方式,而 select/poll 只支持水平觸發,一般而言,邊緣觸發的方式會比水平觸發的效率高。

舉個例子,你的快遞被放到了一個快遞箱裡,如果快遞箱只會通過簡訊通知你一次,即使你一直沒有去取,它也不會再發送第二條簡訊提醒你,這個方式就是邊緣觸發;如果快遞箱發現你的快遞沒有被取出,它就會不停地發簡訊通知你,直到你取出了快遞,它才消停,這個就是水平觸發的方式。

這就是兩者的區別,水平觸發的意思是只要滿足事件的條件,比如內核中有數據需要讀,就一直不斷地把這個事件傳遞給用戶;而邊緣觸發的意思是只有第一次滿足條件的時候才觸發,之後就不會再傳遞同樣的事件了。

epoll 使用問題

  1. epoll 驚群:多個進程等待在 ep->wq 上,事件觸發後所有進程都被喚醒,但只有其中 1 個進程能夠成功繼續執行的現象。其他被白白喚起的進程等於做了無用功,可能會造成系統負載過高的問題。為了解決 epoll 驚群,內核後續的高版本又提供了 EPOLLEXCLUSIVE 選項和 SO_REUSEPORT 選項,我個人理解兩種解決方案思路上的不同點在於:EPOLLEXCLUSIVE 是在喚起進程階段起作用,只喚起排在隊列最前面的 1 個進程;而 SO_REUSEPORT 是在分配連接時起作用,相當於每個進程自己都有一個獨立的 epoll 實例,內核來決策把連接分配給哪個 epoll
  2. epmutex、ep->mtx、ep->lock 3 把鎖的區別。鎖的粒度和使用目的不同。
  • epmutex 是一個全局互斥鎖,epoll 中一共只有 3 個地方用到這把鎖。分別是 ep_free() 銷毀一個 epoll 實例時、eventpoll_release_file() 清理從 epoll 中已經關閉的文件時、epoll_ctl() 時避免 epoll 間嵌套調用時形成死鎖。我的理解是 epmutex 的鎖粒度最大,用來處理跨 epoll 實例級別的同步操作。
  • ep->mtx 是一個 epoll 內部的互斥鎖,在 ep_scan_ready_list() 掃描就緒列表、eventpoll_release_file() 中執行 ep_remove()刪除一個被監視文件、ep_loop_check_proc()檢查 epoll 是否有循環嵌套或過深嵌套、還有 epoll_ctl() 操作被監視文件增刪改等處有使用。可以看出上述的函數裡都會涉及對 epoll 實例中 rdllist 或紅黑樹的訪問,因此我的理解是 ep->mtx 是一個 epoll 實例內的互斥鎖,用來保護 epoll 實例內部的數據結構的線程安全。
  • ep->lock 是一個 epoll 實例內部的自旋鎖,用來保護 ep->rdllist 的線程安全。自旋鎖的特點是得不到鎖時不會引起進程休眠,所以在 ep_poll_callback 中只能使用 ep->lock,否則就會丟事件。

總結

一切一切的開始都起源於這個 read 系統調用,它是作業系統提供的並且是阻塞的,稱之為阻塞IO。為了破這個局,程式設計師在用戶態通過多線程來防止主線程卡死。後來作業系統發現這個需求比較大,於是在作業系統層面提供了非阻塞的 read 函數,這樣程式設計師就可以在一個線程內完成多個文件描述符的讀取,這就是非阻塞 IO

但多個文件描述符的讀取就需要遍歷,當高並發場景越來越多時,用戶態遍歷的文件描述符也越來越多,相當於在 while 循環里進行了越來越多的系統調用。

後來作業系統又發現這個場景需求量較大,於是又在作業系統層面提供了這樣的遍歷文件描述符的機制,這就是 IO 多路復用。多路復用有三個函數,最開始是 select,然後又發明了 poll 解決了 select 文件描述符的限制,然後又發明了 epoll 解決 select 的三個不足。

關鍵字: