本文為翻譯作品,若您具備一定的英語閱讀水平,建議閱讀原文。

此文所需閱讀時間,預計10分鐘。

Asynchronous programming. Blocking I/O and non-blocking I/O?

luminousmen.com
圖標

這是關於非同步編程的系列文章的第一篇文章。整個系列嘗試回答一個簡單的問題:「什麼是非同步?」。(註:我不覺得簡單啊。。。)

當我剛開始深入研究這個問題時,年輕的我以為我知道它是什麼。事實證明,年輕的我並無法完整的陳述何為非同步編程。時過境遷,現在讓我們一起討論一下!

整個系列:

黃華智:何為「非同步編程」?系列一:阻塞操作與非阻塞操作?

zhuanlan.zhihu.com
圖標

黃華智:何為「非同步編程」?系列二:合作型多任務處理?

zhuanlan.zhihu.com
圖標

黃華智:何為「非同步編程「?系列三:等待未來?

zhuanlan.zhihu.com
圖標
  • 非同步編程:Python3.5+

在這篇文章中,我們將探討網路請求,不過你可以將其套用到其他的輸入/輸出(I/O)操作,例如:改變文件描述符對應的介面。儘管例子都用的Python,但本文的論述並不限於某一特定的語言(沒啥好說的,我愛Python)。(註:本人對Python知之甚少,但不妨礙理解原作者所表述的內容。)


在客戶端-伺服器應用中,當客戶端向伺服器發起一個請求,該伺服器處理此請求並返回一個響應。為此,客戶端和伺服器首先需要建立一個鏈接,這就是網路介面發揮作用的地方。鏈接建立後,客戶端和伺服器兩者必須將自身綁定到指定的網路介面上,伺服器將開始監聽介面以便接收客戶端發起的請求。(如圖所示)

如果你把注意力放在處理器速度和網路連接數的對比上,你將發現兩者相差幾個數量級。這證明了,當我們的應用程序使用介面操作(I/O),CPU大部分時間都是空閑的,此類應用被稱為I/O密集型(I/O-bound)應用。由於其他動作和下一個I/O操作都在等待,若應用程序對性能要求高,此類表現是主要的障礙。這證明此類系統都是懶蟲(slackers)。

有3種方法可以來管理I/O操作:阻塞非阻塞非同步。最後一種無法應用到網路請求中,因此有兩者方法供我們選擇:阻塞和非阻塞。

Blocking I/O(阻塞I/O)

在UNIX(POSIX)的伯克利介面(BSD sockets)例子中,思考一下此選項(Windows也是一樣的,只是叫法不一樣,其邏輯是一致的)。

使用阻塞I/O,當客戶端向伺服器發起請求,建立鏈接後,處理該連接的介面將被阻塞,直到有一些數據要讀取,或者數據被完全寫入。在此操作完成之前,伺服器除了等待之外,不會有其他動作。由此可得一個最簡單的結論:在單個執行線程中,我們不能提供多個連接。默認情況下,TCP介面處於阻塞模式。

舉個簡單的Python例子,客戶端代碼:

import socket

sock = socket.socket()

host = socket.gethostname()
sock.connect((host, 12345))

data = b"Foo Bar" *10*1024 # Send a lot of data to be sent(準備發送大量的數據)
assert sock.send(data) # Send data till true(等到true,發送數據)
print("Data sent")

服務端代碼:

import socket

s = socket.socket()

host = socket.gethostname()
port = 12345

s.bind((host, port))
s.listen(5)

while True:
conn, addr = s.accept()
data = conn.recv(1024)
while data:
print(data)
data = conn.recv(1024)
print("Data Received")
conn.close()
break

你可以看到伺服器一直在列印我們的消息「」。這個動作一直持續到所有數據都被發送。在上面的代碼中,在數據發送期間,「Data Received」消息將不會被列印,因為客戶端必須發送大量數據, 這佔用了很長的時間,同時介面將被阻塞

上述代碼做了什麼呢?send() 方法嘗試將所有數據傳輸到伺服器,同時客戶端的寫緩衝區(write buffer)將持續獲取數據。當緩衝區變空了,內核將再次喚醒進程,以便於獲取下一個傳輸的數據塊。簡而言之,你的代碼將被阻塞,其不會讓任何動作被執行。

現在用這種方法試著來實現並發請求(並發和並行是兩回事),我們需要多個線程,為了給每個客戶端連接分配一個新線程。稍後我們再討論這個問題。

Non-blocking I/O(非阻塞I/O)

這裡有第二個選擇--非阻塞I/O。望文生義,與前面提到的阻塞I/O差異明顯--取締阻塞,從客戶端的角度來看,所有操作都會立即完成。非阻塞I/O意味著請求直接進入隊列中,通過回調函數返回。真正的I/O操作會在稍後的某個時間點處理。

回到我們的例子,在客戶端進行一些更改:

import socket

sock = socket.socket()

host = socket.gethostname()
sock.connect((host, 12345))
sock.setblocking(0) # Now setting to non-blocking mode(設置為非阻塞模式)

data = b"Foo Bar" *10*1024
assert sock.send(data)
print("Data sent")

現在,如果我們運行此代碼,您可以看到程序將運行一小段時間,它將列印「Data sent」並終止。

上述代碼做了什麼呢?在這裡客戶端沒有發送所有數據。當我們通過調用setblocking(0)使介面處於非阻塞模式時,客戶端不會等待操作完成。因此,當我們調用send()方法時,客戶端會儘可能多地將數據放入緩存區並放回。

利用這種模式,我們可以在一個線程中,使用不同的介面同時處理多個I/O操作。但是,由於介面是否可以執行新的I/O操作的狀態是未知的,我們將用同樣的詢問去連接每個介面,事實上是在一個無限的循環中旋轉。

為了擺脫這種低效的循環,需要一個輪詢準備機制,在此機制中我們可以輪詢所有介面的準備狀態,同時告訴我們哪些介面準備好執行新的I/O操作。當任何一個介面準備就緒,我們將執行隊列操作,之後我們將返回阻塞狀態,再一次等待下一個為I/O操作準備就緒的介面。

輪詢準備有好幾種機制,它們在性能和細節方面有所不同,但通常情況下,實現的細節隱藏在「引擎蓋下」,對於我們來說是不可見的。

Keywords to search:(搜索關鍵詞)

通知:

  • 電平觸發(狀態)
  • 邊沿觸發(狀態已更改)

機制:

  • select(),poll()
  • epoll(), kqueue()
  • EAGAIN, EWOULDBLOCK

Multitasking(多任務處理)

既然我們的目標是同時管理多個客戶端,那麼我們如何確保同時處理多個請求呢?這裡有幾個實現方法:

Separate processes(獨立進程)

歷史上第一種方法也是最簡單的方法,在單獨的進程中處理每個請求。此方法優勢在於,我們可以使用相同的阻塞I/O的API。如果進程突然崩潰,它只會影響在這個特定進程中處理的操作,而不會影響其他的進程。

缺點--溝通困難。一般來說,進程之間幾乎沒有任何共同之處,我們想要進行不對等的溝通,需要額外的努力來同步訪問,等等。此外,在任一特定的時間點,可能會有多個進程只是在等待客戶端請求,這意味著資源的浪費。

讓我們來看看實踐中是如何運行的:通常第一個進程(主進程)開始工作,它的作用有監聽,然後卵生出其他的進程作為工作員,這些工作員進程的每一個都可以接收同一個介面並等待傳入的連接。一旦傳入連接出現,其中一個進程被佔用,此進程接收此連接,從頭到尾的處理它,然後關閉這個介面,並再次轉為準備狀態,以完成下一個請求。動態是有可能的,主進程可以為每一個傳入的連接生成進程,或者提前啟動進程,等等。動態性可能會影響性能特徵,但現在這對我們來說不那麼重要。

此類系統的示例:

  • Apache mod_prefork
  • 適配大多數PHP開發者的FastCGI
  • 適配在Rails上編寫Ruby開發者的Phusion Passenger
  • PostgreSQL

Threads(OS )(線程-操作系統意義上)

另外一個方法是利用操作系統(OS)線程。在一個進程中,我們可以卵生出多個線程。阻塞I/O模式也可以用起來,意味只有一個線程被阻塞。操作系統本身管理線程,它能夠在處理器之間分散線程。線程相對於進程來說更輕量級。實際意義意味著我們可以在同一個系統上生成更多的線程。我們很難運行一萬個進程,但是運行一萬個線程很容易。並不是說線程更有效率,而是線程更輕量級。

另外,線程沒有隔離,意味著如果某個意外錯誤突然發生,它不僅會導致特定線程的崩潰,它還會讓整個進程崩潰。最大的難點在於,進程的內存對於正在工作的線程是共享。我們共享資源--內存,這意味著需要同步訪問。同步訪問內存的問題,對於處理傳入連接的應用程序中的所有線程來說,都是一樣的。舉個最簡單的情況,與資料庫的連接或者資料庫的連接池。要同步對資源的訪問是很難準確地執行的。

有一些問題:

  1. 首先,在同步過程中有可能出現死鎖(deadlocks)。當進程或線程A由於請求的系統資源被另外一個等待進程B保持,而進程B又在等待進程A所持有的另一個資源,這樣雙方都進入了等待狀態,從而發生死鎖;
  2. 同步不足,當我們對共享數據進行競爭性訪問時。簡而言之,兩個線程同時修改數據並銷毀了數據。這樣的程序更難調試,並非所有錯誤都會立即出現。例如,著名的GIL(Global Interpreter Lock)是編寫多線程應用程序最簡單的方法之一。當使用GIL時,我們聲明的所有數據結構,所有的內存都只被整個進程的一個鎖保護。這似乎意味著多線程執行是不可能的,因為只能執行一個線程,由於只有一個鎖並且有人佔用了,剩餘的所有人都無法工作。的確,這是對的,但請記住一點,大多數時候我們不對線程進行任何計算,而是進行網路I/O操作。因此在訪問阻塞I/O操作的那一刻,GIL關閉,線程重置,實際上是切換到另外一個準備執行的線程。因此,從後端的角度來看,使用GIL可能並不是那麼糟糕。當你試圖一些線程中進行矩陣乘法時,使用GIL是令人害怕的,這樣的操作毫無意義,因為一次只能執行一個線程。(這個說法不全對,但這是另外一個故事了)。

Conclusion(結論)

阻塞方法同步執行--當你運行應用程序時,此方法的操作會在調用後直接執行。

非阻塞方法非同步執行--當你運行應用程序時,此方法的操作會立即返回,但實際操作稍後才會執行。

實現多任務處理有幾種方法:線程和進程。

在下一篇文章中,我們將討論合作型多任務及其實現。

推薦閱讀:

相关文章