深入理解 IO 模型:爲什麼你的程序運行這麼慢?

遇到的問題

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

追女神的五種技能

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

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

IO 多路複用 - 找一個樓管阿姨來幫你監視下樓的女生, 這個期間你可以做些其他的事情。比如:去買點水果,或者打打遊戲, 上個廁所等等。IO 複用又包括 select, poll, 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 模型中,應用進程得到通知時,內核已經讀取完數據並把數據放到了應用進程的緩衝區中,此時應用進程直接使用數據即可。歸納其特點如下:

很明顯,異步 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

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 把鎖的區別。鎖的粒度和使用目的不同。

總結

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

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Ajj08ydcH_vtOOeG0c5W8Q