Flask 做者 Armin Ronacher:我不以爲有異步壓力

英文 | I'm not feeling the async pressure【1】html

原做 | Armin Ronacher,2020.01.01node

譯者 | 豌豆花下貓@Python貓python

聲明 :本翻譯基於CC BY-NC-SA 4.0【2】受權協議,內容略有改動,轉載請保留原文出處,請勿用於商業或非法用途。git

異步(async)正風靡一時。異步Python、異步Rust、go、node、.NET,任選一個你最愛的語言生態,它都在使用着一些異步。異步這東西有多好,這在很大程度上取決於語言的生態及其運行時間,但整體而言,它有一些不錯的好處。它使得這種事情變得很是簡單:等待可能須要一些時間才能完成的操做。程序員

它是如此簡單,以致於創造了無數新的方法來坑人(blow ones foot off)。我想討論的一種狀況是,直到系統出現超載,你才意識到本身踩到了腳的那一種,這就是背壓(back pressure)管理的主題。在協議設計中有一個相關術語是流量控制(flow control)。github

什麼是背壓

關於背壓的解釋有不少,我推薦閱讀的一個很好的解釋是:Backpressure explained — the resisted flow of data through software【3】。所以,與其詳細介紹什麼是背壓,我只想對其作一個很是簡短的定義和解釋:背壓是阻礙數據在系統中流通的阻力。背壓聽起來很負面——誰都會想象浴缸因管道堵塞而溢出——但這是爲了節省你的時間。golang

(譯註: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 庫提供的 startserver 函數會運行一個隱藏的 accept 循環。它偵聽套接字,併爲每一個鏈接的套接字生成一個獨立的任務運行着 onclient_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進階系列、好書推薦系列、技術寫做、優質英文推薦與翻譯等等,歡迎關注哦。

相關文章
相關標籤/搜索