Flask 作者 Armin Ronacher:我不覺得有異步壓力


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 代碼,它展示了一些我們不慎忘記了背壓的情況:

<code>fromasyncioimportstart_server,run

asyncdefon_client_connected(reader,writer):
whileTrue:
data=awaitreader.readline()
ifnotdata:
break
writer.write(data)

asyncdefserver():
srv=awaitstart_server(on_client_connected,'127.0.0.1',8888)
asyncwithsrv:

awaitsrv.serve_forever()

run(server())
/<code>

如果你剛接觸 async/await 概念,請想象一下在調用 await 的時候,函數會掛起,直到表達式解析完畢。在這裡,Python 的 asyncio 庫提供的 start_server 函數會運行一個隱藏的 accept 循環。它偵聽套接字,併為每個連接的套接字生成一個獨立的任務運行著 on_client_connected 函數。

現在,這看起來非常簡單明瞭。你可以刪除所有的 await 和 async 關鍵字,最終的代碼看起來與使用線程方式編寫的代碼非常相似。

但是,它隱藏了一個非常關鍵的問題,這是我們所有問題的根源:在某些函數調用的前面沒有 await。在線程代碼中,任何函數都可以 yield。在異步代碼中,只有異步函數可以。在本例中,這意味著 writer.write 方法無法阻塞。那麼它是如何工作的呢?它將嘗試將數據直接寫入到操作系統的無阻塞套接字緩衝區中。

但是,如果緩衝區已滿並且套接字會阻塞,會發生什麼?在用線程的情況下,我們可以在此處將其阻塞,這很理想,因為這意味著我們正在施加一些背壓。然而,因為這裡沒有線程,所以我們不能這樣做。因此,我們只能在此處進行緩衝或者刪除數據。因為刪除數據是非常糟糕的,所以 Python 選擇了緩衝。

現在,如果有人向其中發送了很多數據卻沒有讀取,會發生什麼?好了在那種情況下,緩衝區會增大,增大,再增大。這個 API 缺陷就是為什麼 Python 的文檔中說,不要只是單獨使用 write,還要接著寫 drain(譯註:消耗、排水):

<code>writer.write(data)
awaitwriter.drain()
/<code>

drain 會排出緩衝區上多餘的東西。它不會排空整個緩衝區,只會做到令事情不致失控的程度。那麼為什麼 write 不做隱式 drain 呢?好吧,這會是一個大規模的 API 監控,我不確定該如何做到。

這裡非常重要的是大多數套接字都基於 TCP,而 TCP 具有內置的流量控制。writer 只會按照 reader 可接受的速度寫入(給予或佔用一些緩衝空間)。這對開發者完全是隱藏的,因為甚至 BSD 套接字庫都沒有公開這種隱式的流量控制操作。

那我們在這裡解決背壓問題了嗎?好吧,讓我們看一看在線程世界中會是怎樣。在線程世界中,我們的代碼很可能會運行固定數量的線程,而 accept 循環會一直等待,直到線程變得可用再接管請求。

然而,在我們的異步示例中,有無數的連接要處理。這就意味著我們可能收到大量連接,即使這意味著系統可能會過載。在這個非常簡單的示例中,可能不成問題,但請想象一下,如果我們做的是數據庫訪問,會發生什麼。

想象一個數據庫連接池,它最多提供 50 個連接。當大多數連接會在連接池處阻塞時,接受 10000 個連接又有什麼用?

等待與等待著等待

好啦,終於回到了我最初想討論的地方。在大多數異步系統中,特別是我在 Python 中遇到的大多數情況中,即使你修復了所有套接字層的緩衝行為,也最終會陷入一個將一堆異步函數鏈接在一起,而不考慮背壓的世界。

如果我們以數據庫連接池為例,假設只有 50 個可用連接。這意味著我們的代碼最多可以有 50 個併發的數據庫會話。假設我們希望處理 4 倍多的請求,因為我們期望應用程序執行的許多操作是獨立於數據庫的。一種解決方法是製作一個帶有 200 個令牌的信號量(semaphore),並在開始時獲取一個。如果我們用完了令牌,就需等待信號量發放令牌。

但是等一下。現在我們又變成了排隊!我們只是在更前面排。如果令系統嚴重超負荷,那麼我們會從一開始就一直在排隊。因此,現在每個人都將等待他們願意等待的最大時間,然後放棄。更糟糕的是:服務器可能仍會花一段時間處理這些請求,直到它意識到客戶端已消失,而且不再對響應感興趣。

因此,與其一直等待下去,我們更希望立即獲得反饋。想象你在一個郵局,並且正在從機器上取票,票上會說什麼時候輪到你。這張票很好地表明瞭你需要等待多長時間。如果等待時間太長,你會決定棄票走人,以後再來。請注意,你在郵局裡的排隊等待時間,與實際處理你的請求的時間無關(例如,因為有人需要提取包裹,檢查文件並採集簽名)。

因此,這是天真的版本,我們只知道自己在等待:

<code>fromasyncio.syncimportSemaphore

semaphore=Semaphore(200)

asyncdefhandle_request(request):
awaitsemaphore.acquire()
try:
returngenerate_response(request)
finally:
semaphore.release()
/<code>

對於 handle_request 異步函數的調用者,我們只能看到我們正在等待並且什麼都沒有發生。我們看不到是因為過載而在等待,還是因為生成響應需花費很長時間而在等待。基本上,我們一直在這裡緩衝,直到服務器最終耗盡內存並崩潰。

這是因為我們沒有關於背壓的溝通渠道。那麼我們將如何解決呢?一種選擇是添加一箇中間層。現在不幸的是,這裡的 asyncio 信號量沒有用,因為它只會讓我們等待。但是假設我們可以詢問信號量還剩下多少個令牌,那麼我們可以執行類似這樣的操作:

<code>fromhypothetical_asyncio.syncimportSemaphore,Service

semaphore=Semaphore(200)

classRequestHandlerService(Service):
asyncdefhandle(self,request):
awaitsemaphore.acquire()
try:
returngenerate_response(request)
finally:
semaphore.release()

@property
defis_ready(self):
returnsemaphore.tokens_available()
/<code>

現在,我們對系統做了一些更改。現在,我們有一個 RequestHandlerService,其中包含了更多信息。特別是它具有了準備就緒的概念。該服務可以被詢問是否準備就緒。該操作在本質上是無阻塞的,並且是最佳估量。

現在,調用者會將這個:

<code>response=awaithandle_request(request)
/<code>

變成這個:

<code>request_handler=RequestHandlerService()
ifnotrequest_handler.is_ready:
response=Response(status_code=503)
else:
response=awaitrequest_handler.handle(request)
/<code>

有多種方法可以完成,但是思想是一樣的。在我們真正著手做某件事之前,我們有一種方法來弄清楚成功的可能性,如果我們超負荷了,我們將向上溝通。

現在,我沒有想到如何給這種服務下定義。其設計來自 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


分享到:


相關文章: