Flask 作者 Armin Ronacher:我不覺得有非同步壓力
英文 | I'm not feeling the async pressure【1】
原作 | Armin Ronacher,2020.01.01
譯者 | 豌豆花下貓@Python貓
宣告 :本翻譯基於CC BY-NC-SA 4.0【2】授權協議,內容略有改動,轉載請保留原文出處,請勿用於商業或非法用途。
非同步(async)正風靡一時。非同步Python、非同步Rust、go、node、.NET,任選一個你最愛的語言生態,它都在使用著一些非同步。非同步這東西有多好,這在很大程度上取決於語言的生態及其執行時間,但總體而言,它有一些不錯的好處。它使得這種事情變得非常簡單:等待可能需要一些時間才能完成的操作。
它是如此簡單,以至於創造了無數新的方法來坑人(blow ones foot off)。我想討論的一種情況是,直到系統出現超載,你才意識到自己踩到了腳的那一種,這就是背壓(back pressure)管理的主題。在協議設計中有一個相關術語是流量控制(flow control)。
什麼是背壓
關於背壓的解釋有很多,我推薦閱讀的一個很好的解釋是:Backpressure explained — the resisted flow of data through software【3】。因此,與其詳細介紹什麼是背壓,我只想對其做一個非常簡短的定義和解釋:背壓是阻礙資料在系統中流通的阻力。背壓聽起來很負面——誰都會想象浴缸因管道堵塞而溢位——但這是為了節省你的時間。
(譯註:back pressure,除了背壓,還有人譯為“回壓”、“反壓”)
在這裡,我們要處理的東西在所有情況下或多或少都是相同的:我們有一個系統將不同元件組合成一個管道,而該管道需要接收一定數量的傳入訊息。
你可以想象這就像在機場模擬行李運送一樣。行李到達,經過分類,裝入飛機,最後卸下。在這過程中,一件行李要跟其它行李一起,被扔進集裝箱進行運輸。當一個集裝箱裝滿後,需要將其運走。當沒有剩餘的集裝箱時,這就是背壓的自然示例。現在,放行李者不能放了,因為沒有集裝箱。
此時必須做出決定。一種選擇是等待:這通常被稱為排隊(queueing )或緩衝(buffering)。另一種選擇是扔掉一些行李,直到有一個集裝箱到達為止——這被稱為丟棄(dropping)。這聽起來很糟糕,但是稍後我們將探討為什麼有時很重要。
但是,這裡還有另一件事。想象一下,負責將行李放入集裝箱的人在較長的時間內(例如一週)都沒等到集裝箱。最終,如果他們沒有丟棄行李,那麼他們周圍將有數量龐大的行李。最終,他們被迫要整理的行李數量太多,用光了儲存行李的物理空間。到那時,他們最好是告訴機場,在解決好集裝箱問題之前,不能再接收新的行李了。這通常被稱為流量控制【4】,是一個至關重要的網路概念。
通常這些處理管道在每段時間內只能容納一定數量的訊息(如本例中的行李箱)。如果數量超過了它,或者更糟糕的是管道停滯,則可能發生可怕的事情。現實世界中的一個例子是倫敦希思羅機場 5 號航站樓開放,由於其 IT 基礎架構無法正常執行,在 10 天內未能完成運送 42,000 件行李。他們不得不取消 500 多個航班,並且有一段時間,航空公司決定只允許隨身攜帶行李。
背壓很重要
我們從希思羅災難中學到的是,能夠交流背壓至關重要。在現實生活中以及在計算中,時間總是有限的。最終人們會放棄等待某些事情。特別是即使某些事物在內部可以永遠等待,但在外部卻不能。
舉一個現實的例子:如果你的行李需通過倫敦希思羅機場到達目的地巴黎,但是你只能在那呆 7 天,那麼如果行李延遲成 10 天到達,這就毫無意義了。實際上,你希望將行李重新路由(re-routed)回你的家鄉機場。
實際上,承認失敗(你超負載了)比假裝可運作並持續保持緩衝狀態要好,因為到了某個時候,它只會令情況變得更糟。
那麼,為什麼在我們編寫了多年的基於執行緒的軟體時,背壓都沒有被提出,現在卻突然成為討論的話題呢?有諸多因素的結合,其中一些因素很容易使人陷入困境。
糟糕的預設方式
為了理解為什麼背壓在非同步程式碼中很重要,我想為你提供一段看似簡單的 Python asyncio 程式碼,它展示了一些我們不慎忘記了背壓的情況:
from asyncio import start_server, run
async def on_client_connected(reader, writer):
while True:
data = await reader.readline()
if not data:
break
writer.write(data)
async def server():
srv = await start_server(on_client_connected, '127.0.0.1', 8888)
async with srv:
await srv.serve_forever()
run(server())
如果你剛接觸 async/await 概念,請想象一下在呼叫 await 的時候,函式會掛起,直到表示式解析完畢。在這裡,Python 的 asyncio 庫提供的 start_server 函式會執行一個隱藏的 accept 迴圈。它偵聽套接字,併為每個連線的套接字生成一個獨立的任務執行著 on_client_connected 函式。
現在,這看起來非常簡單明瞭。你可以刪除所有的 await 和 async 關鍵字,最終的程式碼看起來與使用執行緒方式編寫的程式碼非常相似。
但是,它隱藏了一個非常關鍵的問題,這是我們所有問題的根源:在某些函式呼叫的前面沒有 await。線上程程式碼中,任何函式都可以 yield。在非同步程式碼中,只有非同步函式可以。在本例中,這意味著 writer.write 方法無法阻塞。那麼它是如何工作的呢?它將嘗試將資料直接寫入到作業系統的無阻塞套接字緩衝區中。
但是,如果緩衝區已滿並且套接字會阻塞,會發生什麼?在用執行緒的情況下,我們可以在此處將其阻塞,這很理想,因為這意味著我們正在施加一些背壓。然而,因為這裡沒有執行緒,所以我們不能這樣做。因此,我們只能在此處進行緩衝或者刪除資料。因為刪除資料是非常糟糕的,所以 Python 選擇了緩衝。
現在,如果有人向其中傳送了很多資料卻沒有讀取,會發生什麼?好了在那種情況下,緩衝區會增大,增大,再增大。這個 API 缺陷就是為什麼 Python 的文件中說,不要只是單獨使用 write,還要接著寫 drain(譯註:消耗、排水):
writer.write(data)
await writer.drain()
drain 會排出緩衝區上多餘的東西。它不會排空整個緩衝區,只會做到令事情不致失控的程度。那麼為什麼 write 不做隱式 drain 呢?好吧,這會是一個大規模的 API 監控,我不確定該如何做到。
這裡非常重要的是大多數套接字都基於 TCP,而 TCP 具有內建的流量控制。writer 只會按照 reader 可接受的速度寫入(給予或佔用一些緩衝空間)。這對開發者完全是隱藏的,因為甚至 BSD 套接字型檔都沒有公開這種隱式的流量控制操作。
那我們在這裡解決背壓問題了嗎?好吧,讓我們看一看線上程世界中會是怎樣。線上程世界中,我們的程式碼很可能會執行固定數量的執行緒,而 accept 迴圈會一直等待,直到執行緒變得可用再接管請求。
然而,在我們的非同步示例中,有無數的連線要處理。這就意味著我們可能收到大量連線,即使這意味著系統可能會過載。在這個非常簡單的示例中,可能不成問題,但請想象一下,如果我們做的是資料庫訪問,會發生什麼。
想象一個數據庫連線池,它最多提供 50 個連線。當大多數連線會在連線池處阻塞時,接受 10000 個連線又有什麼用?
等待與等待著等待
好啦,終於回到了我最初想討論的地方。在大多數非同步系統中,特別是我在 Python 中遇到的大多數情況中,即使你修復了所有套接字層的緩衝行為,也最終會陷入一個將一堆非同步函式連結在一起,而不考慮背壓的世界。
如果我們以資料庫連線池為例,假設只有 50 個可用連線。這意味著我們的程式碼最多可以有 50 個併發的資料庫會話。假設我們希望處理 4 倍多的請求,因為我們期望應用程式執行的許多操作是獨立於資料庫的。一種解決方法是製作一個帶有 200 個令牌的訊號量(semaphore),並在開始時獲取一個。如果我們用完了令牌,就需等待訊號量發放令牌。
但是等一下。現在我們又變成了排隊!我們只是在更前面排。如果令系統嚴重超負荷,那麼我們會從一開始就一直在排隊。因此,現在每個人都將等待他們願意等待的最大時間,然後放棄。更糟糕的是:伺服器可能仍會花一段時間處理這些請求,直到它意識到客戶端已消失,而且不再對響應感興趣。
因此,與其一直等待下去,我們更希望立即獲得反饋。想象你在一個郵局,並且正在從機器上取票,票上會說什麼時候輪到你。這張票很好地表明瞭你需要等待多長時間。如果等待時間太長,你會決定棄票走人,以後再來。請注意,你在郵局裡的排隊等待時間,與實際處理你的請求的時間無關(例如,因為有人需要提取包裹,檢查檔案並採集簽名)。
因此,這是天真的版本,我們只知道自己在等待:
from asyncio.sync import Semaphore
semaphore = Semaphore(200)
async def handle_request(request):
await semaphore.acquire()
try:
return generate_response(request)
finally:
semaphore.release()
對於 handle_request 非同步函式的呼叫者,我們只能看到我們正在等待並且什麼都沒有發生。我們看不到是因為過載而在等待,還是因為生成響應需花費很長時間而在等待。基本上,我們一直在這裡緩衝,直到伺服器最終耗盡記憶體並崩潰。
這是因為我們沒有關於背壓的溝通渠道。那麼我們將如何解決呢?一種選擇是新增一箇中間層。現在不幸的是,這裡的 asyncio 訊號量沒有用,因為它只會讓我們等待。但是假設我們可以詢問訊號量還剩下多少個令牌,那麼我們可以執行類似這樣的操作:
from hypothetical_asyncio.sync import Semaphore, Service
semaphore = Semaphore(200)
class RequestHandlerService(Service):
async def handle(self, request):
await semaphore.acquire()
try:
return generate_response(request)
finally:
semaphore.release()
@property
def is_ready(self):
return semaphore.tokens_available()
現在,我們對系統做了一些更改。現在,我們有一個 RequestHandlerService,其中包含了更多資訊。特別是它具有了準備就緒的概念。該服務可以被詢問是否準備就緒。該操作在本質上是無阻塞的,並且是最佳估量。
現在,呼叫者會將這個:
response = await handle_request(request)
變成這個:
request_handler = RequestHandlerService()
if not request_handler.is_ready:
response = Response(status_code=503)
else:
response = await request_handler.handle(request)
有多種方法可以完成,但是思想是一樣的。在我們真正著手做某件事之前,我們有一種方法來弄清楚成功的可能性,如果我們超負荷了,我們將向上溝通。
現在,我沒有想到如何給這種服務下定義。其設計來自 Rust 的tower【5】和 Rust 的actix-service【6】。兩者對服務特徵的定義都跟它非常相似。
現在,由於它是如此的 racy,因此仍有可能堆積訊號量。現在,你可以冒這種風險,或者還是在 handle 被呼叫時就丟擲失敗。
一個比 asyncio 更好地解決此問題的庫是 trio,它會在訊號量上暴露內部計數器,並提供一個 CapacityLimiter,它是對容量限制做了優化的訊號量,可以防止一些常見的陷阱。
資料流和協議
現在,上面的示例為我們解決了 RPC 樣式的情況。對於每次呼叫,如果系統過載了,我們會盡早得知。許多協議都有非常直接的方式來傳達“伺服器正在載入”的資訊。例如,在 HTTP 中,你可以發出 503,並在 header 中攜帶一個 retry-after 欄位,它會告知客戶端何時可以重試。在下次重試時會新增一個重新評估的自然點,判斷是否要使用相同的請求重試,或者更改某些內容。例如,如果你無法在 15 秒內重試,那麼最好向使用者顯示這種無能,而不是顯示一個無休止的載入圖示。
但是,請求/響應(request/response)式的協議並不是唯一的協議。許多協議都打開了持久連線,讓你傳輸大量的資料。在傳統上,這些協議中有很多是基於 TCP 的,如前所述,它具有內建的流量控制。但是,此流量控制並沒有真正通過套接字型檔公開,這就是為什麼高階協議通常需要向其新增自己的流量控制的原因。例如,在 HTTP2 中,就存在一個自定義流量控制協議,因為 HTTP2 在單個 TCP 連線上,多路複用多個獨立的資料流(streams)。
因為 TCP 在後臺對流量控制進行靜默式管理,這可能會使開發人員陷入一條危險的道路,他們只知從套接字中讀取位元組,並誤以為這是所有該知道的資訊。但是,TCP API 具有誤導性,因為從 API 角度來看,流量控制對使用者完全是隱藏的。當你設計自己的基於資料流的協議時,你需要絕對確保存在雙向通訊通道,即傳送方不僅要傳送,還要讀取,以檢視是否允許它們繼續發。
對於資料流,關注點通常是不同的。許多資料流只是位元組或資料幀的流,你不能僅在它們之間丟棄資料包。更糟糕的是:傳送方通常不容易察覺到它們是否應該放慢速度。在 HTTP2 中,你需要在使用者級別上不斷交錯地讀寫。你必然要在那裡處理流量控制。當你在寫並且被允許寫入時,伺服器將向你傳送 WINDOW_UPDATE 幀。
這意味著資料流程式碼變得更為複雜,因為你首先需要編寫一個可以對傳入流量作控制的框架。例如,hyper-h2【7】Python 庫具有令人驚訝的複雜的檔案上傳伺服器示例,【8】該示例基於 curio 的流量控制,但是還未完成。
新步槍
async/await 很棒,但是它所鼓勵編寫的內容在過載時會導致災難。一方面是因為它如此容易就排隊,但同時因為在使函式變非同步後,會造成 API 損壞。我只能假設這就是為什麼 Python 在資料流 writer 上仍然使用不可等待的 write 函式。
不過,最大的原因是 async/await 使你可以編寫許多人最初無法用執行緒編寫的程式碼。我認為這是一件好事,因為它降低了實際編寫大型系統的障礙。其缺點是,這也意味著許多以前對分散式系統缺乏經驗的開發人員現在即使只編寫一個程式,也遇到了分散式系統的許多問題。由於多路複用的性質,HTTP2 是一種非常複雜的協議,唯一合理的實現方法是基於 async/await 的例子。
遇到這些問題的不僅是 async/await 程式碼。例如,Dask【9】是資料科學程式設計師使用的 Python 並行庫,儘管沒有使用 async/await,但由於缺乏背壓,【10】仍有一些 bug 報告提示系統記憶體不足。但是這些問題是相當根本的。
然而,背壓的缺失是一種具有火箭筒大小的步槍。如果你太晚意識到自己構建了個怪物,那麼在不對程式碼庫進行重大更改的情況下,幾乎不可能修復它,因為你可能忘了在某些本應使用非同步的函式上使用非同步。
其它的程式設計環境對此也無濟於事。人們在所有程式設計環境中都遇到了同樣的問題,包括最新版本的 go 和 Rust。即使在長期開源的非常受歡迎的專案中,找到有關“處理流程控制”或“處理背壓”的開放問題(open issue)也並非罕見,因為事實證明,事後新增這一點確實很困難。例如,go 從 2014 年起就存在一個開放問題,關於給所有檔案系統IO新增訊號量,【11】因為它可能會使主機超載。aiohttp 有一個問題可追溯到2016年,【12】關於客戶端由於背壓不足而導致破壞伺服器。還有很多很多的例子。
如果你檢視 Python 的 hyper-h2文件,將會看到大量令人震驚的示例,其中包含類似“不處理流量控制”、“它不遵守 HTTP/2 流量控制,這是一個缺陷,但在其它方面是沒問題的“,等等。在流量控制一出現的時候,我就認為它非常複雜。很容易假裝這不是個問題,這就是為什麼我們會處於這種混亂狀態的根本原因。流量控制還會增加大量開銷,並且在基準測試中效果不佳。
那麼,對於你們這些非同步庫開發人員,這裡給你們一個新年的解決方案:在文件和 API 中,賦予背壓和流量控制其應得的重視。
相關連結
[1] I'm not feeling the async pressure: https://lucumr.pocoo.org/2020/1/1/async-pressure/
[2] CC BY-NC-SA 4.0: https://creativecommons.org/licenses/by-nc-sa/4.0/
[3] Backpressure explained — the resisted flow of data through software: https://medium.com/@jayphelps/backpressure-explained-the-flow-of-data-through-software-2350b3e77ce7
[4] 流量控制: https://en.wikipedia.org/wiki/Flow_control_(data)
[5] tower: https://github.com/tower-rs/tower
[6] actix-service: https://docs.rs/actix-service/
[7] hyper-h2: https://github.com/python-hyper/hyper-h2
[8] 檔案上傳伺服器示例: https://python-hyper.org/projects/h2/en/stable/curio-example.html
[9] Dask: https://dask.org/
[10] 背壓: https://github.com/dask/distributed/issues/2602
[11] 關於給所有檔案系統IO新增訊號量: https://github.com/golang/go/issues/7903
[12] 有一個問題可追溯到2016年,: https://github.com/aio-libs/aiohttp/issues/1368
公眾號【Python貓】, 本號連載優質的系列文章,有喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫作、優質英文推薦與翻譯等等,歡迎關注哦