1. 程式人生 > >【java】同步、非同步、阻塞、非阻塞

【java】同步、非同步、阻塞、非阻塞

理解同步與非同步

同步是指:傳送方發出資料後,等接收方發回響應以後才發下一個數據包的通訊方式。 

非同步是指:傳送方發出資料後,不等接收方發回響應,接著傳送下個數據包的通訊方式。 

舉個例子:普通B/S模式(同步)    AJAX技術(非同步)

同步:提交請求->等待伺服器處理->處理完畢返回(期間客戶端瀏覽器不能幹任何事)

非同步: 請求通過事件觸發->伺服器處理(期間瀏覽器仍然可以作其他事情)->處理完畢

同步、非同步、阻塞和非阻塞(網路程式設計)

同步

所謂同步,就是在發出一個功能呼叫時,在沒有得到結果之前,該呼叫就不返回。

按照這個定義,其實絕大多數函式都是同步呼叫(例如sin, isdigit等)。但是一般而言,我們在說同步、非同步的時候,特指那些需要其他部件協作或者需要一定時間完成的任務。最常見的例子就是 SendMessage。該函式傳送一個訊息給某個視窗,在對方處理完訊息之前,這個函式不返回。當對方處理完畢以後,該函式才把訊息處理函式所返回的 LRESULT值返回給呼叫者。

非同步

非同步的概念和同步相對。當一個非同步過程呼叫發出後,呼叫者不能立刻得到結果。實際處理這個呼叫的部件在完成後,通過狀態、通知和回撥來通知呼叫者。以CAsycSocket類為例(注意,CSocket從CAsyncSocket派生,但是其功能已經由非同步轉化為同步),當一個客戶端通過呼叫 Connect函式發出一個連線請求後,呼叫者執行緒立刻可以朝下執行。當連線真正建立起來以後,socket底層會發送一個訊息通知該物件。這裡提到執行部件和呼叫者通過三種途徑返回結果:狀態、通知和回撥。可以使用哪一種依賴於執行部件的實現,除非執行部件提供多種選擇,否則不受呼叫者控制。如果執行部件用狀態來通知,那麼呼叫者就需要每隔一定時間檢查一次,效率就很低(有些初學多執行緒程式設計的人,總喜歡用一個迴圈去檢查某個變數的值,這其實是一種很嚴重的錯誤)。如果是使用通知的方式,效率則很高,因為執行部件幾乎不需要做額外的操作。至於回撥函式,其實和通知沒太多區別。

阻塞

阻塞呼叫是指呼叫結果返回之前,當前執行緒會被掛起。函式只有在得到結果之後才會返回。有人也許會把阻塞呼叫和同步呼叫等同起來,實際上他是不同的。對於同步呼叫來說,很多時候當前執行緒還是啟用的,只是從邏輯上當前函式沒有返回而已。例如,我們在CSocket中呼叫Receive函式,如果緩衝區中沒有資料,這個函式就會一直等待,直到有資料才返回。而此時,當前執行緒還會繼續處理各種各樣的訊息。如果主視窗和呼叫函式在同一個執行緒中,除非你在特殊的介面操作函式中呼叫,其實主介面還是應該可以重新整理。socket接收資料的另外一個函式recv則是一個阻塞呼叫的例子。當socket工作在阻塞模式的時候,如果沒有資料的情況下呼叫該函式,則當前執行緒就會被掛起,直到有資料為止。

非阻塞

非阻塞和阻塞的概念相對應,指在不能立刻得到結果之前,該函式不會阻塞當前執行緒,而會立刻返回。

物件的阻塞模式和阻塞函式呼叫

物件是否處於阻塞模式和函式是不是阻塞呼叫有很強的相關性,但是並不是一一對應的。阻塞物件上可以有非阻塞的呼叫方式,我們可以通過一定的API去輪詢狀態,在適當的時候呼叫阻塞函式,就可以避免阻塞。而對於非阻塞物件,呼叫特殊的函式也可以進入阻塞呼叫。函式select就是這樣的一個例子。

  1. 同步阻塞:小明一直盯著下載進度條,到 100% 的時候就完成。
  2. 同步非阻塞:小明提交下載任務後就去幹別的,每過一段時間就去瞄一眼進度條,看到 100% 就完成。
  3. 非同步阻塞:小明換了個有下載完成通知功能的軟體,下載完成就“叮”一聲。不過小明仍然一直等待“叮”的聲音(看起來很傻,不是嗎)
  4. 非同步非阻塞:仍然是那個會“叮”一聲的下載軟體,小明提交下載任務後就去幹別的,聽到“叮”的一聲就知道完成了。

也就是說,同步/非同步是下載軟體的通知方式,或者說 API 被呼叫者的通知方式。阻塞/非阻塞則是小明的等待方式,或者說 API 呼叫者的等待方式。

在不同的場景下,同步/非同步、阻塞/非阻塞的四種組合都有應用。

同步阻塞

同步阻塞是最簡單的方式,就像我們在 C 語言裡呼叫一個函式並等待其返回。

如 stat 系統呼叫獲取檔案元資料,只有同步阻塞一種模式。我在訪問量很大的一個檔案伺服器(mirrors.ustc.edu.cn)上遇到過大量 nginx 程序處於 D(uninterruptible)狀態的問題,就是因為 stat 系統呼叫不提供非阻塞 I/O(O_NONBLOCK)選項(nginx 在能用非阻塞 I/O 的地方都用了非阻塞)。檔案的元資料被從磁碟中讀入進來的時間裡,這個 nginx worker 程序只能在核心態苦苦等待而無法做其他事。不提供 O_NONBLOCK 選項,對核心開發者來說這是省事了,但對使用者來說就要付出效能的代價了。

同步非阻塞

同步非阻塞就是 “每隔一會兒瞄一眼進度條” 的輪詢(polling)方式。

同步非阻塞方式相比同步阻塞方式:

  • 優點是能夠在等待任務完成的時間裡幹其他活了(包括提交其他任務,也就是 “後臺” 可以有多個任務在同時執行)。
  • 缺點是任務完成的響應延遲增大了,因為每過一段時間才去輪詢一次,而任務可能在兩次輪詢之間的任意時間完成。

由於同步非阻塞方式需要不斷輪詢,而 “後臺” 可能有多個任務在同時進行,人們就想到了迴圈查詢多個任務的完成狀態,只要有任何一個任務完成,就去處理它。這就是所謂的 “I/O 多路複用”。UNIX/Linux 下的 select、poll、epoll 就是幹這個的(epoll 比 poll、select 效率高,做的事情是一樣的)。Windows 下則有 WaitForMultipleObjects 和 IO Completion Ports API 與之對應(Windows API 的命名簡直甩 POSIX API 幾條街有木有!)

Linux I/O 多路複用

高併發的程式一般使用同步非阻塞方式而非多執行緒 + 同步阻塞方式。要理解這一點,首先要扯到併發和並行的區別。比如去某部門辦事需要依次去幾個視窗,辦事大廳裡的人數就是併發數,而視窗個數就是並行度。也就是說併發數是指同時進行的任務數(如同時服務的 HTTP 請求),而並行數是可以同時工作的物理資源數量(如 CPU 核數)。通過合理排程任務的不同階段,併發數可以遠遠大於並行度,這就是區區幾個 CPU 可以支援上萬個使用者併發請求的奧祕。在這種高併發的情況下,為每個任務(使用者請求)建立一個程序或執行緒的開銷非常大。而同步非阻塞方式可以把多個 I/O 請求丟到後臺去,這就可以在一個程序裡服務大量的併發 I/O 請求。

非同步非阻塞

非同步非阻塞,就是把一件事丟到 “後臺” 去做,完成之後再通知。

在 Linux 中,通知的方式是 “訊號”。

  • 如果這個程序正在使用者態忙著做別的事(例如在計算兩個矩陣的乘積),那就強行打斷之,呼叫事先註冊的訊號處理函式,這個函式可以決定何時以及如何處理這個非同步任務。由於訊號處理函式是突然闖進來的,因此跟中斷處理程式一樣,有很多事情是不能做的,因此保險起見,一般是把事件 “登記” 一下放進佇列,然後返回該程序原來在做的事。
  • 如果這個程序正在核心態忙著做別的事,例如以同步阻塞方式讀寫磁碟,那就只好把這個通知掛起來了,等到核心態的事情忙完了,快要回到使用者態的時候,再觸發訊號通知。
  • 如果這個程序現在被掛起了,例如無事可做 sleep 了,那就把這個程序喚醒,下次有 CPU 空閒的時候,就會排程到這個程序,觸發訊號通知。

非同步 API 說來輕巧,做來難,這主要是對 API 的實現者而言的。Linux 的非同步 I/O(AIO)支援是 2.6.22 才引入的,還有很多系統呼叫不支援非同步 I/O。Linux 的非同步 I/O 最初是為資料庫設計的,因此通過非同步 I/O 的讀寫操作不會被快取或緩衝,這就無法利用作業系統的快取與緩衝機制。

Windows API 裡的非同步 I/O API(被稱為 Overlapped I/O)則優雅得多,可以在 ReadFileEx、WriteFileEx 等 I/O API 上指定回撥函式,當 I/O 操作完成時就會呼叫它。這相當於在 “訊號” 的基礎上提供了一層封裝。除了指定回撥函式,這些非同步 I/O 請求還可以使用 “傳統” 的同步阻塞方式(WaitForSingleObject)、多路複用的同步非阻塞方式(WaitForMultipleObjects)來等待。多個非同步 I/O 請求也可以繫結到一個 I/O Completion Port 上一起等待。

Windows 非同步 I/O 原理

很多人把 Linux 的 O_NONBLOCK 認為是非同步方式,但事實上這是前面講的同步非阻塞方式。由於 Linux 的非同步 I/O 難用,nginx 早期版本一直使用的是 O_NONBLOCK 和 epoll,從 0.8.11 開始支援非同步 I/O,但預設使用的仍然是同步非阻塞方式。需要指出的是,雖然 Linux 上的 I/O API 略顯粗糙,但每種程式設計框架都有封裝好的非同步 I/O 實現。作業系統少做事,把更多的自由留給使用者,正是 UNIX 的設計哲學,也是 Linux 上程式設計框架百花齊放的一個原因。

非同步阻塞

都有下載完成通知了,我還傻傻地盯著進度條幹什麼?這種看起來很傻的方式也是有用的。有時我們的 API 只提供非同步通知方式,例如在 node.js 裡,但業務邏輯需要的是做完一件事後做另一件事,例如資料庫連線初始化後才能開始接受使用者的 HTTP 請求。這樣的業務邏輯就需要呼叫者是以阻塞方式來工作。

為了在非同步環境裡模擬 “順序執行” 的效果,就需要把同步程式碼轉換成非同步形式,這稱為 CPS(Continuation Passing Style)變換。BYVoid 大神的 continuation.js 庫就是一個 CPS 變換的工具。使用者只需用比較符合人類常理的同步方式書寫程式碼,CPS 變換器會把它轉換成層層巢狀的非同步回撥形式。

CPS 變換後的非同步程式碼示例(來源:continuation.js)

使用者手寫的同步程式碼示例(來源:continuation.js)

另外一種使用阻塞方式的理由是降低響應延遲。如果採用非阻塞方式,一個任務 A 被提交到後臺,就開始做另一件事 B,但 B 還沒做完,A 就完成了,這時要想讓 A 的完成事件被儘快處理(比如 A 是個緊急事務),要麼丟棄做到一半的 B,要麼儲存 B 的中間狀態並切換回 A,任務的切換是需要時間的(不管是從磁碟載入到記憶體,還是從記憶體載入到快取記憶體),這勢必降低 A 的響應速度。因此,對實時系統或者延遲敏感的事務,有時採用阻塞方式比非阻塞方式更好。

最後補充一句,同步/非同步的概念在不同語境下是不同的,本文說的是 API 或者 I/O。在其他語境裡可能是別的意思,例如分散式系統裡的同步表示是各節點按照時鐘節拍同步,而非同步是收到訊息後立即執行。

非阻塞的應用場景主要是 apache、nginx 等需要高併發的程式,以便同時處理多個請求。(這個忘了說了,我補充到原文去)

同步非阻塞和非同步非阻塞主要取決於 API 提供的介面是同步的還是非同步的。

高併發的程式一般使用同步非阻塞方式而非多執行緒 + 同步阻塞方式。要理解這一點,首先要扯到併發和並行的區別。比如去某部門辦事需要依次去幾個視窗,辦事大廳裡的人數就是併發數,而視窗個數就是並行度。也就是說併發數是指同時進行的任務數(如同時服務的 HTTP 請求),而並行數是可以同時工作的物理資源數量(如 CPU 核數)。通過合理排程任務的不同階段,併發數可以遠遠大於並行度,這就是區區幾個 CPU 可以支援上萬個使用者併發請求的奧祕。在這種高併發的情況下,為每個任務(使用者請求)建立一個程序或執行緒的開銷非常大。而同步非阻塞方式可以把多個 I/O 請求丟到後臺去,這就可以在一個程序裡服務大量的併發 I/O 請求。