非同步程式設計:阻塞與非阻塞
原文:Asynchronous programming. Blocking I/O and non-blocking I/O
作者:luminousmen
這是關於非同步程式設計系列的第一篇文章。整個系列試著回答一個簡單的問題,“什麼是非同步?”
我在一開始深入研究這個問題的時候,我以為自己瞭解“非同步”。但事實是關於“什麼是非同步”我並沒有什麼頭緒,所以我們來尋找答案吧!
整個系列:
- 非同步程式設計:阻塞式IO與非阻塞式IO
- 非同步程式設計:協作式多工
- 非同步程式設計:Await the Future
- 非同步程式設計:Python3.5+
這篇文章中,我們以網路程式設計來討論,不過你可以輕鬆的用其他的IO操作來類比,比如檔案操作。雖然文中的例子採用Python,但這些觀點並不是僅僅針對某種特定的程式語言(我只想說—Python真香)。
在一般C/S架構的應用中,客戶端建立請求傳送給服務端時,服務端會處理請求並響應,這個過程中客戶端與服務端首先都需要建立與對方通訊的連線,這也就是sockets的作用啦。兩端為socket繫結埠後服務端就會在自己的socket中監聽來自客戶端的請求。
如果你看過處理器的處理速度與網路連線的比率,你就知道兩者的差異是幾個數量級。事實上如果我們的應用在進行I/O操作,那麼CPU絕大多數的時間什麼都不做,這種應用被稱為“I/O-bound”。對於構建高效應用而言這是個大?麻煩,因為其他的動作和I/O操作會一直等待著—事實上這些系統都很懶。
有三種方式操作I/O:阻塞式,非阻塞式,非同步。最後一種不適用於網路程式設計,所以對我們而言只有前兩種選擇。
阻塞式I/O
這是在UNIX(POSIX)BSD sockets(Windows中類似,稱呼可能不同但邏輯是一樣的)中應用阻塞式I/O的例子。
在阻塞式I/O中,客戶端建立請求傳送至服務端時,該連結的socket會被一直阻塞到資料讀取或寫入完畢。在這些操作完成之前服務端除了等待什麼也做不了。由此可知在單個執行緒中我們無法同時為更多的連線提供服務。預設情況下,TCP sockets 處於阻塞模式。
客戶端:
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
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”不會被列印,這是因為客戶端了傳送大量的資料,這將一直耗時到socket被阻塞(這一句有點懵逼,原文:which will take time, and until then the socket will get blocked)。
發生了什麼?send()方法會從客戶端的寫快取持續獲取資料並嘗試將所有資料傳送給服務端。當快取空了核心才會再次喚醒程式以獲取下一個被傳輸資料塊。也就是說你的程式碼將被阻塞也無法進行其他的操作。
現在要實現併發請求我們需要多執行緒,即我們為每一個客戶端連線分配一個新的執行緒。我們稍後討論這個。
非阻塞式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)把socket設定為非阻塞式時,它就不會等待操作完成了。所以當我們之後再呼叫send()方法時,它會盡可能多的寫入資料到快取中然後立即返回。
使用非阻塞式I/O,我們就可以在同一個執行緒中同時執行不同socket中的I/O操作。但是I/O操作是否準備就緒我們不得而知,所以我們可能不得不遍歷每個socket去確認,通常就是採用無限迴圈。
為了擺脫這種低效的迴圈我們就需要一套輪詢機制,我們可以輪詢出所有準備就緒的sockets,還可以知道它們之中哪些可以進行新的I/O操作。當有任意sockets準備就緒後我們將執行入隊操作(),之後我們便可以等待為下次I/O操作準備好了的sockets。
有幾種不同的輪詢機制,它們在效能與細節上有所不同,但通常細節隱藏在“引擎蓋下”,對我們來說是不可見的。
相關的關鍵字
Notifications:
- Level Triggering (state)
- Edge Triggering (state changed)
Mechanics:
select()
,poll()
epoll()
,kqueue()
EAGAIN
,EWOULDBLOCK
多工
我們的目標是同時管理多個客戶端。那麼如何確保同時處理多個請求呢?
這有些選擇:
獨立程式
最簡單,也是最早的一種方式就是在將每個請求放在一個獨立的程式中處理。我們可以使用之前的阻塞式I/O,如果這個獨立程式突然崩了也不會對其他程式造成影響,這很棒對不對?
在形式上這些程式之間幾乎沒有任何通用的地方,所以我們需要為每次普通的程式間通訊做額外的事情。此外在任意時刻都有多個程式在等待客戶端請求也是一種資源浪費。
我們看一看在實踐中這是如何工作的,通常主程式啟動後會進行一些操作,例如,監聽,然後設定一些程式作為workers,每個worker可以在同一個socket上等待接受傳入的連線。一旦連線建立,這個worker會與該連線繫結,它負責處理該連線從開始到結束的整個過程,關閉與客戶端通訊的socket後就會重新準備好處理下一次請求。程式可以在建立連線時建立或者提前建立。不同的方式可能會對效能造成影響,不過現在這個問題對我們不重要。
類似的系統有:
- Apache
mod_prefork
; - FastCGI (PHP);
- Phusion Passenger (Ruby on Rails);
- PostgreSQL.
執行緒(OS)
還有一種就是利用作業系統執行緒的方式啦。在一個程式中我們可以建立多個執行緒。
依然可以使用阻塞式I/O,因為只有一個執行緒會被阻塞。OS已經管理好了這些分在各個程式中的執行緒。執行緒比程式更輕量。這代表我們可以建立更多的執行緒。建立1萬個程式很困難,而建立1萬個程式則很輕鬆,並不是說執行緒相比程式更高效而是更輕量。
另一方面,執行緒間沒有隔離,也就是如果執行緒崩了其做在的程式也會崩潰。最麻煩的是程式中的記憶體在工作中的執行緒間是共享的,這代表我們需要考慮執行緒安全問題。比如:資料庫連線或者連線池。
結論
阻塞方法會同步執行—執行程式時阻塞方法的操作會在呼叫後立即直接執行。
非阻塞方法會非同步執行—執行程式時非阻塞方法會在呼叫後馬上返回,真正的操作會在之後執行。
我們可以通過多執行緒與多程式實現多工處理。
下一篇文章我們將討論協作式多工處理已經實現。