來自:伯樂在線專欄作者 - 陶邦仁
鏈接:http://blog.jobbole.com/99905/
上一篇《聊聊同步、異步、阻塞與非阻塞》已經通俗的講解了,要理解同步、異步、阻塞與非阻塞重要的兩個概念點了,沒有看過的,建議先看這篇博文理解這兩個概念點。在認知上,建立統一的模型。這樣,大家在繼續看本篇時,才不會理解有偏差。
那麼,在正式開始講Linux IO模型前,比如:同步IO和異步IO,阻塞IO和非阻塞IO分別是什麼,到底有什麼區別?不同的人在不同的上下文下給出的答案是不同的。所以先限定一下本文的上下文。
1 概念說明
在進行解釋之前,首先要說明幾個概念:
用戶空間和內核空間
進程切換
進程的阻塞
文件描述符
緩存 IO
1.1 用戶空間與內核空間
現在操作系統都是采用虛擬存儲器,那麼對32位操作系統而言,它的尋址空間(虛擬存儲空間)為4G(2的32次方)。操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。為了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操作系統將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。針對linux操作系統而言,將最高的1G字節(從虛擬地址0xC0000000到0xFFFFFFFF),供內核使用,稱為內核空間,而將較低的3G字節(從虛擬地址0×00000000到0xBFFFFFFF),供各個進程使用,稱為用戶空間。
1.2 進程切換
為了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程的執行。這種行為被稱為進程切換。因此可以說,任何進程都是在操作系統內核的支持下運行的,是與內核緊密相關的。
從一個進程的運行轉到另一個進程上運行,這個過程中經過下面這些變化:
保存處理機上下文,包括程序計數器和其他寄存器。
更新PCB信息。
把進程的PCB移入相應的隊列,如就緒、在某事件阻塞等隊列。
選擇另一個進程執行,並更新其PCB。
更新內存管理的數據結構。
恢復處理機上下文。
注:總而言之就是很耗資源,具體的可以參考這篇文章:進程切換。
1.3 進程的阻塞
正在執行的進程,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由運行狀態變為阻塞狀態。可見,進程的阻塞是進程自身的一種主動行為,也因此只有處於運行態的進程(獲得CPU),才可能將其轉為阻塞狀態。當進程進入阻塞狀態,是不占用CPU資源的。
1.4 文件描述符fd
文件描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。
文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞著文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。
1.5 緩存 IO
緩存 IO 又被稱作標准 IO,大多數文件系統的默認 IO 操作都是緩存 IO。在 Linux 的緩存 IO 機制中,操作系統會將 IO 的數據緩存在文件系統的頁緩存( page cache )中,也就是說,數據會先被拷貝到操作系統內核的緩沖區中,然後才會從操作系統內核的緩沖區拷貝到應用程序的地址空間。
緩存 IO 的缺點:
數據在傳輸過程中需要在應用程序地址空間和內核進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷是非常大的。
2 Linux IO模型
網絡IO的本質是socket的讀取,socket在linux系統被抽象為流,IO可以理解為對流的操作。剛才說了,對於一次IO訪問(以read舉例),數據會先被拷貝到操作系統內核的緩沖區中,然後才會從操作系統內核的緩沖區拷貝到應用程序的地址空間。所以說,當一個read操作發生時,它會經歷兩個階段:
第一階段:等待數據准備 (Waiting for the data to be ready)。
第二階段:將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)。
對於socket流而言,
第一步:通常涉及等待網絡上的數據分組到達,然後被復制到內核的某個緩沖區。
第二步:把數據從內核緩沖區復制到應用進程緩沖區。
網絡應用需要處理的無非就是兩大類問題,網絡IO,數據計算。相對於後者,網絡IO的延遲,給應用帶來的性能瓶頸大於後者。網絡IO的模型大致有如下幾種:
同步模型(synchronous IO)
阻塞IO(bloking IO)
非阻塞IO(non-blocking IO)
多路復用IO(multiplexing IO)
信號驅動式IO(signal-driven IO)
異步IO(asynchronous IO)
注:由於signal driven IO在實際中並不常用,所以我這只提及剩下的四種IO Model。
在深入介紹Linux IO各種模型之前,讓我們先來探索一下基本 Linux IO 模型的簡單矩陣。如下圖所示:
每個 IO 模型都有自己的使用模式,它們對於特定的應用程序都有自己的優點。本節將簡要對其一一進行介紹。常見的IO模型有阻塞、非阻塞、IO多路復用,異步。以一個生動形象的例子來說明這四個概念。周末我和女友去逛街,中午餓了,我們准備去吃飯。周末人多,吃飯需要排隊,我和女友有以下幾種方案。
2.1 同步阻塞 IO(blocking IO)
2.1.1 場景描述
我和女友點完餐後,不知道什麼時候能做好,只好坐在餐廳裡面等,直到做好,然後吃完才離開。女友本想還和我一起逛街的,但是不知道飯能什麼時候做好,只好和我一起在餐廳等,而不能去逛街,直到吃完飯才能去逛街,中間等待做飯的時間浪費掉了。這就是典型的阻塞。
2.1.2 網絡模型
同步阻塞 IO 模型是最常用的一個模型,也是最簡單的模型。在linux中,默認情況下所有的socket都是blocking。它符合人們最常見的思考邏輯。阻塞就是進程 "被" 休息, CPU處理其它進程去了。
在這個IO模型中,用戶空間的應用程序執行一個系統調用(recvform),這會導致應用程序阻塞,什麼也不干,直到數據准備好,並且將數據從內核復制到用戶進程,最後進程再處理數據,在等待數據到處理數據的兩個階段,整個進程都被阻塞。不能處理別的網絡IO。調用應用程序處於一種不再消費 CPU 而只是簡單等待響應的狀態,因此從處理的角度來看,這是非常有效的。在調用recv()/recvfrom()函數時,發生在內核中等待數據和復制數據的過程,大致如下圖:
2.1.3 流程描述
當用戶進程調用了recv()/recvfrom()這個系統調用,kernel就開始了IO的第一個階段:准備數據(對於網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來)。這個過程需要等待,也就是說數據被拷貝到操作系統內核的緩沖區中是需要一個過程的。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。第二個階段:當kernel一直等到數據准備好了,它就會將數據從kernel中拷貝到用戶內存,然後kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
所以,blocking IO的特點就是在IO執行的兩個階段都被block了。
優點:
能夠及時返回數據,無延遲;
對內核開發者來說這是省事了;
缺點:
對用戶來說處於等待就要付出性能的代價了;
2.2 同步非阻塞 IO(nonblocking IO)
2.2.1 場景描述
我女友不甘心白白在這等,又想去逛商場,又擔心飯好了。所以我們逛一會,回來詢問服務員飯好了沒有,來來回回好多次,飯都還沒吃都快累死了啦。這就是非阻塞。需要不斷的詢問,是否准備好了。
2.2.2 網絡模型
同步非阻塞就是 “每隔一會兒瞄一眼進度條” 的輪詢(polling)方式。在這種模型中,設備是以非阻塞的形式打開的。這意味著 IO 操作不會立即完成,read 操作可能會返回一個錯誤代碼,說明這個命令不能立即滿足(EAGAIN 或 EWOULDBLOCK)。
在網絡IO時候,非阻塞IO也會進行recvform系統調用,檢查數據是否准備好,與阻塞IO不一樣,”非阻塞將大的整片時間的阻塞分成N多的小的阻塞, 所以進程不斷地有機會 ‘被’ CPU光顧”。
也就是說非阻塞的recvform系統調用調用之後,進程並沒有被阻塞,內核馬上返回給進程,如果數據還沒准備好,此時會返回一個error。進程在返回之後,可以干點別的事情,然後再發起recvform系統調用。重復上面的過程,循環往復的進行recvform系統調用。這個過程通常被稱之為輪詢。輪詢檢查內核數據,直到數據准備好,再拷貝數據到進程,進行數據處理。需要注意,拷貝數據整個過程,進程仍然是屬於阻塞的狀態。
在linux下,可以通過設置socket使其變為non-blocking。當對一個non-blocking socket執行讀操作時,流程如圖所示:
2.2.3 流程描述
當用戶進程發出read操作時,如果kernel中的數據還沒有准備好,那麼它並不會block用戶進程,而是立刻返回一個error。從用戶進程角度講,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個error時,它就知道數據還沒有准備好,於是它可以再次發送read操作。一旦kernel中的數據准備好了,並且又再次收到了用戶進程的system call,那麼它馬上就將數據拷貝到了用戶內存,然後返回。
所以,nonblocking IO的特點是用戶進程需要不斷的主動詢問kernel數據好了沒有。
同步非阻塞方式相比同步阻塞方式:
優點:能夠在等待任務完成的時間裡干其他活了(包括提交其他任務,也就是 “後台” 可以有多個任務在同時執行)。
缺點:任務完成的響應延遲增大了,因為每過一段時間才去輪詢一次read操作,而任務可能在兩次輪詢之間的任意時間完成。這會導致整體數據吞吐量的降低。
2.3 IO 多路復用( IO multiplexing)
2.3.1 場景描述
與第二個方案差不多,餐廳安裝了電子屏幕用來顯示點餐的狀態,這樣我和女友逛街一會,回來就不用去詢問服務員了,直接看電子屏幕就可以了。這樣每個人的餐是否好了,都直接看電子屏幕就可以了,這就是典型的IO多路復用。
2.3.2 網絡模型
由於同步非阻塞方式需要不斷主動輪詢,輪詢占據了很大一部分過程,輪詢會消耗大量的CPU時間,而 “後台” 可能有多個任務在同時進行,人們就想到了循環查詢多個任務的完成狀態,只要有任何一個任務完成,就去處理它。如果輪詢不是進程的用戶態,而是有人幫忙就好了。那麼這就是所謂的 “IO 多路復用”。UNIX/Linux 下的 select、poll、epoll 就是干這個的(epoll 比 poll、select 效率高,做的事情是一樣的)。
IO多路復用有兩個特別的系統調用select、poll、epoll函數。select調用是內核級別的,select輪詢相對非阻塞的輪詢的區別在於—前者可以等待多個socket,能實現同時對多個IO端口進行監聽,當其中任何一個socket的數據准好了,就能返回進行可讀,然後進程再進行recvform系統調用,將數據由內核拷貝到用戶進程,當然這個過程是阻塞的。select或poll調用之後,會阻塞進程,與blocking IO阻塞不同在於,此時的select不是等到socket數據全部到達再處理, 而是有了一部分數據就會調用用戶進程來處理。如何知道有一部分數據到達了呢?監視的事情交給了內核,內核負責數據到達的處理。也可以理解為"非阻塞"吧。
I/O復用模型會用到select、poll、epoll函數,這幾個函數也會使進程阻塞,但是和阻塞I/O所不同的的,這兩個函數可以同時阻塞多個I/O操作。而且可以同時對多個讀操作,多個寫操作的I/O函數進行檢測,直到有數據可讀或可寫時(注意不是全部數據可讀或可寫),才真正調用I/O操作函數。
對於多路復用,也就是輪詢多個socket。多路復用既然可以處理多個IO,也就帶來了新的問題,多個IO之間的順序變得不確定了,當然也可以針對不同的編號。具體流程,如下圖所示:
2.3.3 流程描述
IO multiplexing就是我們說的select,poll,epoll,有些地方也稱這種IO方式為event driven IO。select/epoll的好處就在於單個process就可以同時處理多個網絡連接的IO。它的基本原理就是select,poll,epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。
當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的數據准備好了,select就會返回。這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。
多路復用的特點是通過一種機制一個進程能同時等待IO文件描述符,內核監視這些文件描述符(套接字描述符),其中的任意一個進入讀就緒狀態,select, poll,epoll函數就可以返回。對於監視的方式,又可以分為 select, poll, epoll三種方式。
上面的圖和blocking IO的圖其實並沒有太大的不同,事實上,還更差一些。因為這裡需要使用兩個system call (select 和 recvfrom),而blocking IO只調用了一個system call (recvfrom)。但是,用select的優勢在於它可以同時處理多個connection。
所以,如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。(select/epoll的優勢並不是對於單個連接能處理得更快,而是在於能處理更多的連接。)
在IO multiplexing Model中,實際中,對於每一個socket,一般都設置成為non-blocking,但是,如上圖所示,整個用戶的process其實是一直被block的。只不過process是被select這個函數block,而不是被socket IO給block。所以IO多路復用是阻塞在select,epoll這樣的系統調用之上,而沒有阻塞在真正的I/O系統調用如recvfrom之上。
了解了前面三種IO模式,在用戶進程進行系統調用的時候,他們在等待數據到來的時候,處理的方式不一樣,直接等待,輪詢,select或poll輪詢,兩個階段過程:
第一個階段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞。
第二個階段都是阻塞的。
從整個IO過程來看,他們都是順序執行的,因此可以歸為同步模型(asynchronous)。都是進程主動等待且向內核檢查狀態。【此句很重要!!!】
高並發的程序一般使用同步非阻塞方式而非多線程 + 同步阻塞方式。要理解這一點,首先要扯到並發和並行的區別。比如去某部門辦事需要依次去幾個窗口,辦事大廳裡的人數就是並發數,而窗口個數就是並行度。也就是說並發數是指同時進行的任務數(如同時服務的 HTTP 請求),而並行數是可以同時工作的物理資源數量(如 CPU 核數)。通過合理調度任務的不同階段,並發數可以遠遠大於並行度,這就是區區幾個 CPU 可以支持上萬個用戶並發請求的奧秘。在這種高並發的情況下,為每個任務(用戶請求)創建一個進程或線程的開銷非常大。而同步非阻塞方式可以把多個
IO 請求丟到後台去,這就可以在一個進程裡服務大量的並發 IO 請求。
注意:IO多路復用是同步阻塞模型還是異步阻塞模型,在此給大家分析下:
此處仍然不太清楚的,強烈建議大家在細究《聊聊同步、異步、阻塞與非阻塞》中講同步與異步的根本性區別,同步是需要主動等待消息通知,而異步則是被動接收消息通知,通過回調、通知、狀態等方式來被動獲取消息。IO多路復用在阻塞到select階段時,用戶進程是主動等待並調用select函數獲取數據就緒狀態消息,並且其進程狀態為阻塞。所以,把IO多路復用歸為同步阻塞模式。
2.4 信號驅動式IO(signal-driven IO)
信號驅動式I/O:首先我們允許Socket進行信號驅動IO,並安裝一個信號處理函數,進程繼續運行並不阻塞。當數據准備好時,進程會收到一個SIGIO信號,可以在信號處理函數中調用I/O操作函數處理數據。過程如下圖所示:
2.5 異步非阻塞 IO(asynchronous IO)
2.5.1 場景描述
女友不想逛街,又餐廳太吵了,回家好好休息一下。於是我們叫外賣,打個電話點餐,然後我和女友可以在家好好休息一下,飯好了送貨員送到家裡來。這就是典型的異步,只需要打個電話說一下,然後可以做自己的事情,飯好了就送來了。
2.5.2 網絡模型
相對於同步IO,異步IO不是順序執行。用戶進程進行aio_read系統調用之後,無論內核數據是否准備好,都會直接返回給用戶進程,然後用戶態進程可以去做別的事情。等到socket數據准備好了,內核直接復制數據給進程,然後從內核向進程發送通知。IO兩個階段,進程都是非阻塞的。
Linux提供了AIO庫函數實現異步,但是用的很少。目前有很多開源的異步IO庫,例如libevent、libev、libuv。異步過程如下圖所示:
2.5.3 流程描述
用戶進程發起aio_read操作之後,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之後,首先它會立刻返回,所以不會對用戶進程產生任何block。然後,kernel會等待數據准備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel會給用戶進程發送一個signal或執行一個基於線程的回調函數來完成這次 IO 處理過程,告訴它read操作完成了。
在 Linux 中,通知的方式是 “信號”:
如果這個進程正在用戶態忙著做別的事(例如在計算兩個矩陣的乘積),那就強行打斷之,調用事先注冊的信號處理函數,這個函數可以決定何時以及如何處理這個異步任務。由於信號處理函數是突然闖進來的,因此跟中斷處理程序一樣,有很多事情是不能做的,因此保險起見,一般是把事件 “登記” 一下放進隊列,然後返回該進程原來在做的事。
如果這個進程正在內核態忙著做別的事,例如以同步阻塞方式讀寫磁盤,那就只好把這個通知掛起來了,等到內核態的事情忙完了,快要回到用戶態的時候,再觸發信號通知。
如果這個進程現在被掛起了,例如無事可做 sleep 了,那就把這個進程喚醒,下次有 CPU 空閒的時候,就會調度到這個進程,觸發信號通知。
異步 API 說來輕巧,做來難,這主要是對 API 的實現者而言的。Linux 的異步 IO(AIO)支持是 2.6.22 才引入的,還有很多系統調用不支持異步 IO。Linux 的異步 IO 最初是為數據庫設計的,因此通過異步 IO 的讀寫操作不會被緩存或緩沖,這就無法利用操作系統的緩存與緩沖機制。
很多人把 Linux 的 O_NONBLOCK 認為是異步方式,但事實上這是前面講的同步非阻塞方式。需要指出的是,雖然 Linux 上的 IO API 略顯粗糙,但每種編程框架都有封裝好的異步 IO 實現。操作系統少做事,把更多的自由留給用戶,正是 UNIX 的設計哲學,也是 Linux 上編程框架百花齊放的一個原因。
從前面 IO 模型的分類中,我們可以看出 AIO 的動機:
同步阻塞模型需要在 IO 操作開始時阻塞應用程序。這意味著不可能同時重疊進行處理和 IO 操作。
同步非阻塞模型允許處理和 IO 操作重疊進行,但是這需要應用程序根據重現的規則來檢查 IO 操作的狀態。
這樣就剩下異步非阻塞 IO 了,它允許處理和 IO 操作重疊進行,包括 IO 操作完成的通知。
IO多路復用除了需要阻塞之外,select 函數所提供的功能(異步阻塞 IO)與 AIO 類似。不過,它是對通知事件進行阻塞,而不是對 IO 調用進行阻塞。
2.6 關於異步阻塞
有時我們的 API 只提供異步通知方式,例如在 node.js 裡,但業務邏輯需要的是做完一件事後做另一件事,例如數據庫連接初始化後才能開始接受用戶的 HTTP 請求。這樣的業務邏輯就需要調用者是以阻塞方式來工作。
為了在異步環境裡模擬 “順序執行” 的效果,就需要把同步代碼轉換成異步形式,這稱為 CPS(Continuation Passing Style)變換。BYVoid 大神的 continuation.js 庫就是一個 CPS 變換的工具。用戶只需用比較符合人類常理的同步方式書寫代碼,CPS 變換器會把它轉換成層層嵌套的異步回調形式。
另外一種使用阻塞方式的理由是降低響應延遲。如果采用非阻塞方式,一個任務 A 被提交到後台,就開始做另一件事 B,但 B 還沒做完,A 就完成了,這時要想讓 A 的完成事件被盡快處理(比如 A 是個緊急事務),要麼丟棄做到一半的 B,要麼保存 B 的中間狀態並切換回 A,任務的切換是需要時間的(不管是從磁盤載入到內存,還是從內存載入到高速緩存),這勢必降低 A 的響應速度。因此,對實時系統或者延遲敏感的事務,有時采用阻塞方式比非阻塞方式更好
2.7 五種IO模型