跳至內容

並行與 async / await

關於路徑操作函式async def 語法的詳細資訊,以及一些關於非同步程式碼、並行和並列的背景知識。

趕時間嗎?

TL;DR(太長不看)

如果您正在使用要求您以 await 呼叫它們的第三方函式庫,例如

results = await some_library()

那麼,請使用 async def 宣告您的路徑操作函式,例如

@app.get('/')
async def read_results():
    results = await some_library()
    return results

注意事項

您只能在使用 async def 建立的函式內使用 await


如果您正在使用與某些東西(資料庫、API、檔案系統等)通訊且不支援使用 await 的第三方函式庫(目前大多數資料庫函式庫都是這種情況),那麼請像平常一樣使用 def 宣告您的路徑操作函式,例如

@app.get('/')
def results():
    results = some_library()
    return results

如果您的應用程式(不知何故)不必與任何其他東西通訊並等待其回應,請使用 async def


如果您只是不知道,請使用普通的 def


注意事項:您可以在路徑操作函式中根據需要混合使用 defasync def,並使用最適合您的選項定義每個函式。FastAPI 會正確處理它們。

無論如何,在上述任何情況下,FastAPI 仍然會非同步運作並且速度非常快。

但是,透過遵循上述步驟,它將能夠進行一些效能優化。

技術細節

現代版本的 Python 支援使用稱為「協程」「非同步程式碼」,並使用asyncawait 語法。

讓我們在以下章節中逐一了解該片語

  • 非同步程式碼
  • asyncawait
  • 協程

非同步程式碼

非同步程式碼僅表示程式語言 💬 有一種方法可以告訴電腦/程式 🤖,在程式碼中的某個時間點,它 🤖 將必須等待其他東西在其他地方完成。假設其他東西稱為「慢速檔案」 📝。

因此,在這段時間內,電腦可以去做一些其他工作,而「慢速檔案」 📝 則會完成。

然後,電腦/程式 🤖 會在每次有機會時回來,因為它正在再次等待,或者當它 🤖 完成了當時的所有工作時。然後它 🤖 會查看它正在等待的任何任務是否已經完成,並執行它必須執行的任何操作。

接下來,它 🤖 會取得第一個完成的任務(假設是我們的「慢速檔案」 📝)並繼續執行它必須對其執行的任何操作。

「等待其他東西」通常是指相對「慢」(與處理器和 RAM 記憶體的速度相比)的 I/O 操作,例如等待

  • 來自用戶端的資料透過網路傳送
  • 您的程式傳送的資料透過網路被用戶端接收
  • 系統讀取磁碟中檔案的內容並將其提供給您的程式
  • 您的程式提供給系統的內容被寫入磁碟
  • 遠端 API 操作
  • 資料庫操作完成
  • 資料庫查詢返回結果
  • 等等。

由於執行時間主要消耗在等待 I/O 操作上,因此它們被稱為「I/O 密集型」操作。

它被稱為「非同步」(asynchronous),是因為電腦/程式不必與耗時的任務「同步」(synchronized),不必空等任務完成的確切時刻才能取得任務結果並繼續工作。

取而代之的是,藉由成為「非同步」系統,任務完成後,可以稍微排隊一下(幾微秒),等待電腦/程式完成它正在做的事情,然後回來取得結果並繼續處理。

與「非同步」相反的「同步」(synchronous) 通常也稱為「循序」(sequential),因為電腦/程式會依序執行所有步驟,即使這些步驟包含等待,才會切換到不同的任務。

併發與漢堡

上述的**非同步**程式碼概念有時也被稱為**「併發」(concurrency)**。它與**「平行」(parallelism)**不同。

**併發**和**平行**都與「不同的事情或多或少同時發生」有關。

但*併發*和*平行*的細節差異很大。

為了看出差異,想像以下關於漢堡的故事:

併發漢堡

你和你的暗戀對象一起去吃速食,你排隊等候,收銀員正在為你前面的人點餐。😍

然後輪到你了,你為你和你的暗戀對象點了兩個非常精緻的漢堡。🍔🍔

收銀員向廚房裡的廚師說了一些話,讓他們知道他們必須準備你的漢堡(即使他們目前正在準備前一位顧客的漢堡)。

你付款。💸

收銀員給你你的號碼牌。

在你等待的時候,你和你的暗戀對象一起找了個桌子坐下,你們聊了很久(因為你們的漢堡很精緻,需要一些時間準備)。

當你和你的暗戀對象坐在桌旁等待漢堡時,你可以利用這段時間欣賞你的暗戀對象有多麼棒、可愛和聰明 ✨😍✨。

在等待和與你的暗戀對象交談的同時,你不時查看櫃檯上顯示的號碼,看看是否已經輪到你了。

然後在某個時刻,終於輪到你了。你去櫃檯領取你的漢堡,然後回到桌邊。

你和你的暗戀對象一起享用漢堡,度過美好的時光。✨

資訊

精美的插圖由 Ketrina Thompson 繪製。 🎨


想像一下,在這個故事中,你就是電腦/程式 🤖。

當你在排隊時,你只是閒置著 😴,等待輪到你,沒有做任何「有生產力」的事情。但排隊速度很快,因為收銀員只是在點餐(而不是準備餐點),所以沒關係。

然後,輪到你的時候,你會做實際的「有生產力」的工作,你瀏覽菜單,決定你想要什麼,詢問你暗戀對象的選擇,付款,確認你給了正確的鈔票或卡片,確認你被收取的費用正確,確認訂單上的品項正確,等等。

但是,即使你還沒有拿到你的漢堡,你與收銀員的工作也「暫停了」⏸,因為你必須等待 🕙 你的漢堡準備好。

但當你離開櫃檯,拿著號碼牌和你的暗戀對象坐在桌邊時,你可以將注意力轉移到你的暗戀對象身上,並「開始」⏯ 🤓 處理這件事。然後你又開始做一些非常「有生產力」的事情,例如與你的暗戀對象調情 😍。

接著,收銀員💁‍♀️說「漢堡做好了」並把你的號碼顯示在櫃檯螢幕上,但你並不會在螢幕上的號碼變成你的號碼時就馬上瘋狂地跳起來。因為你有你的號碼,其他人也有他們的號碼,所以你知道沒有人會偷走你的漢堡。

所以你會等你的心儀對象把故事講完(完成目前的工作⏯ / 正在處理的任務🤓),然後溫柔地微笑,說你要去拿漢堡了⏸。

然後你走到櫃檯🔀,到現在已經完成的初始任務⏯那裡,拿走漢堡,說聲謝謝,再把漢堡帶回餐桌。這樣就完成了與櫃檯互動的步驟/任務⏹。反過來,這也產生了一個新的任務「吃漢堡」🔀⏯,而之前的「拿漢堡」任務已經完成了⏹。

平行漢堡

現在讓我們想像一下,這些不是「併發漢堡」,而是「平行漢堡」。

你和你的心儀對象一起去買平行的速食。

你排隊,同時有幾個(假設是 8 個)身兼廚師的收銀員在為你前面的人點餐。

在你前面的每個人都在等他們的漢堡做好才離開櫃檯,因為 8 個收銀員中的每一個都會在接下一份點餐之前,立刻去準備漢堡。

終於輪到你了,你為你和你的心儀對象點了兩個非常精緻的漢堡。

你付了錢💸。

收銀員去了廚房。

你站在櫃檯前等🕙,這樣才沒有其他人會在你之前拿走你的漢堡,因為沒有號碼牌。

由於你和你的心儀對象忙著不讓任何人插隊拿走你們的漢堡,所以你們無法好好聊天。😞

這是「同步」工作,你和收銀員/廚師👨‍🍳是「同步的」。你必須等待🕙,並在收銀員/廚師👨‍🍳做完漢堡並交給你的那一刻出現在那裡,否則其他人可能會拿走它們。

在櫃檯前等了很久之後🕙,你的收銀員/廚師👨‍🍳終於帶著你的漢堡回來了。

你拿著漢堡和你的心儀對象回到餐桌。

你們就把漢堡吃完了,這樣就結束了。⏹

因為大部分時間都花在櫃檯前等待🕙,所以沒有太多時間聊天或調情。😞

資訊

精美的插圖由 Ketrina Thompson 繪製。 🎨


在這個平行漢堡的場景中,你是一台電腦/程式🤖,它有兩個處理器(你和你的心儀對象),兩個處理器都在等待🕙,並將注意力⏯放在「在櫃檯等待」🕙很長一段時間。

這家速食店有 8 個處理器(收銀員/廚師)。而併發漢堡店可能只有 2 個(一個收銀員和一個廚師)。

但最終的體驗仍然不是很好。😞


這就是漢堡的平行對應故事。🍔

舉一個更「貼近生活」的例子,想像一下銀行。

直到最近,大多數銀行都有多個收銀員👨‍💼👨‍💼👨‍💼👨‍💼和一條長隊伍🕙🕙🕙🕙🕙🕙🕙🕙。

所有的收銀員都一個接一個地為客戶服務👨‍💼⏯。

你必須在隊伍中等候🕙很長時間,否則就會失去你的順位。

你可能不會想帶你的心儀對象😍一起去銀行🏦辦事。

漢堡結論

在這個「與心儀對象一起吃速食漢堡」的場景中,由於有很多等待時間🕙,因此使用併發系統⏸🔀⏯會更有意義。

大多數網路應用程式都是這種情況。

使用者非常多,但你的伺服器卻在等待🕙他們不太穩定的連線發送請求。

然後再次等待🕙回應回來。

這種「等待」🕙是以微秒為單位的,但加總起來,最終還是有很多等待時間。

這就是為什麼在 Web API 中使用非同步 ⏸🔀⏯ 程式碼非常合理的原因。

這種非同步性正是 NodeJS 普及的原因(即使 NodeJS 不是平行處理),而這也是 Go 作為程式語言的優勢所在。

這也是您使用 FastAPI 所能獲得的相同效能等級。

由於您可以同時擁有平行處理和非同步性,因此您可以獲得比大多數測試過的 NodeJS 框架更高的效能,並且與 Go 並駕齊驅,Go 是一種更接近 C 語言的編譯語言 (這都要歸功於 Starlette)

並行比平行更好嗎?

不!這不是故事的寓意。

並行不同於平行。在涉及大量等待的**特定**情況下,它會更好。因此,對於 Web 應用程式開發來說,它通常比平行處理好得多。但並非適用於所有情況。

為了平衡這一點,請想像以下的簡短故事

您必須打掃一間又大又髒的房子。

是的,這就是整個故事.


沒有任何地方需要等待 🕙,只有大量的清潔工作要做,而且遍布房子的多個地方。

您可以像漢堡的例子一樣輪流進行,先客廳,再廚房,但由於您沒有等待 🕙 任何事情,只是不停地清潔,輪流並不會影響任何事情。

無論是否輪流(並行),完成所需的時間都相同,而且您完成的工作量也相同。

但在這種情況下,如果您能帶上 8 位前收銀員/廚師/現在的清潔工,並且他們每個人(加上您)可以負責房子的某個區域進行清潔,您就可以在額外幫助下**平行**完成所有工作,並更快地完成。

在這種情況下,每個清潔工(包括您)都將是一個處理器,負責完成他們的工作。

由於大部分的執行時間都花在實際工作上(而不是等待),而電腦中的工作是由CPU完成的,因此他們將這些問題稱為「CPU 密集型」。


CPU 密集型操作的常見例子是需要複雜數學處理的事情。

例如

  • 音訊影像處理
  • 電腦視覺:一張影像由數百萬個像素組成,每個像素有 3 個值/顏色,處理這些像素通常需要同時計算這些像素上的某些東西。
  • 機器學習:通常需要大量的「矩陣」和「向量」乘法。想像一個巨大的試算表,裡面充滿了數字,並且同時將所有數字相乘。
  • 深度學習:這是機器學習的一個子領域,因此也適用相同的情況。只是沒有單一的數字試算表可以相乘,而是一組巨大的試算表,而且在許多情況下,您會使用特殊的處理器來建立和/或使用這些模型。

並行 + 平行:Web + 機器學習

使用 FastAPI,您可以利用 Web 開發中非常常見的並行性(與 NodeJS 的主要吸引力相同)。

但您也可以利用平行處理和多程序處理(同時運行多個程序)的優勢,來處理像機器學習系統中的**CPU 密集型**工作負載。

再加上 Python 是**資料科學**、機器學習,尤其是深度學習的主要語言,這使得 FastAPI 非常適合資料科學/機器學習 Web API 和應用程式(以及許多其他應用程式)。

要了解如何在生產環境中實現這種並行性,請參閱關於 部署 的章節。

asyncawait

現代版本的 Python 提供了一種非常直觀的方式來定義非同步程式碼。這讓它看起來就像普通的「循序」程式碼,並在適當的時機為您執行「等待」動作。

當某個操作需要等待才能提供結果,並且支援這些新的 Python 功能時,您可以像這樣編寫程式碼:

burgers = await get_burgers(2)

這裡的關鍵是 await。它告訴 Python 必須等待 ⏸ get_burgers(2) 完成其工作 🕙 後,才能將結果儲存在 burgers 中。這樣,Python 就會知道它可以在此期間 🔀 ⏯ 執行其他操作(例如接收另一個請求)。

要讓 await 運作,它必須位於支援這種非同步的函式內。要做到這一點,您只需使用 async def 宣告它

async def get_burgers(number: int):
    # Do some asynchronous stuff to create the burgers
    return burgers

...而不是 def

# This is not asynchronous
def get_sequential_burgers(number: int):
    # Do some sequential stuff to create the burgers
    return burgers

使用 async def,Python 就知道在該函式內,它必須注意 await 運算式,並且它可以「暫停」 ⏸ 該函式的執行,並在返回之前執行其他操作 🔀。

當您想要呼叫 async def 函式時,您必須「等待」它。因此,以下程式碼無法運作:

# This won't work, because get_burgers was defined with: async def
burgers = get_burgers(2)

因此,如果您正在使用一個函式庫,它告訴您可以使用 await 呼叫它,那麼您需要使用 async def 建立使用它的*路徑操作函式*,如下所示:

@app.get('/burgers')
async def read_burgers():
    burgers = await get_burgers(2)
    return burgers

更多技術細節

您可能已經注意到,await 只能在使用 async def 定義的函式內使用。

但同時,使用 async def 定義的函式也必須被「等待」。因此,使用 async def 的函式也只能在同樣使用 async def 定義的函式內呼叫。

那麼,關於先有雞還是先有蛋的問題,您如何呼叫第一個 async 函式呢?

如果您正在使用 FastAPI,您不必擔心這個問題,因為那個「第一個」函式將會是您的*路徑操作函式*,而 FastAPI 會知道如何正確處理。

但如果您想在沒有 FastAPI 的情況下使用 async / await,您也可以這樣做。

編寫您自己的非同步程式碼

Starlette(以及 FastAPI)是基於 AnyIO 構建的,這使得它與 Python 的標準函式庫 asyncioTrio 相容。

特別是,您可以直接使用 AnyIO 來處理需要在您自己的程式碼中使用更進階模式的進階並行使用案例。

即使您沒有使用 FastAPI,您也可以使用 AnyIO 編寫您自己的非同步應用程式,以獲得高度相容性和其優勢(例如*結構化並行*)。

我在 AnyIO 之上建立了另一個函式庫,作為一個輕薄的層,以稍微改進型別註釋並獲得更好的**自動完成**、**行內錯誤**等功能。它還有一個友好的介紹和教學,可以幫助您**理解**和編寫**您自己的非同步程式碼**:Asyncer。如果您需要**將非同步程式碼與常規**(阻塞/同步)程式碼結合使用,它會特別有用。

其他形式的非同步程式碼

這種使用 asyncawait 的風格在這個語言中相對較新。

但它讓處理非同步程式碼變得容易許多。

同樣的語法(或幾乎相同)最近也被包含在現代版本的 JavaScript 中(在瀏覽器和 NodeJS 中)。

但在那之前,處理非同步程式碼相當複雜且困難。

在以前的 Python 版本中,您可以使用執行緒或 Gevent。但程式碼理解、除錯和思考起來都更加複雜。

在以前的 NodeJS / 瀏覽器 JavaScript 版本中,您會使用「回呼」。這會導致 回呼地獄

協程

協程 只是 async def 函式返回的東西的一個非常花俏的術語。Python 知道它類似於一個函式,它可以啟動並在某個時間點結束,但它也可能在內部暫停 ⏸,只要其中有 await

但所有使用 asyncawait 進行非同步程式碼的功能很多時候都被概括為使用「協程」。它可以與 Go 的主要關鍵特性「Goroutines」相提並論。

結論

讓我們看看上面相同的短語

現代版本的 Python 支援使用稱為「協程」「非同步程式碼」,並使用asyncawait 語法。

現在應該更有意義了。 ✨

所有這些都是 FastAPI(透過 Starlette)的動力來源,也是它擁有如此令人印象深刻的效能的原因。

非常技術性的細節

警告

您可以略過這部分。

這些是關於 FastAPI 底層運作方式的非常技術性的細節。

如果您具備相當多的技術知識(協程、執行緒、阻塞等),並且對 FastAPI 如何處理 async def 與普通的 def 感到好奇,請繼續閱讀。

路徑操作函式

當您使用普通的 def 而不是 async def 宣告路徑操作函式時,它會在一個外部執行緒池中運行,然後等待其完成,而不是直接呼叫(因為它會阻塞伺服器)。

如果您來自另一個非同步框架,而該框架的運作方式與上述不同,並且您習慣於使用普通的 def 定義僅進行計算的簡單路徑操作函式以獲得微小的效能提升(約 100 奈秒),請注意,在 FastAPI 中,效果會完全相反。在這些情況下,最好使用 async def,除非您的路徑操作函式使用了執行阻塞 I/O 的程式碼。

儘管如此,在這兩種情況下,FastAPI 的速度很可能 仍然比(或至少與)您之前的框架更快。

依賴項

這同樣適用於 依賴項。如果依賴項是標準的 def 函式而不是 async def,它會在外部執行緒池中運行。

子依賴項

您可以有多個相互依賴的依賴項和子依賴項(作為函數定義的參數),其中一些可以使用 async def 建立,而另一些則使用普通的 def 建立。它仍然可以正常運作,使用普通 def 建立的依賴項將在外部執行緒(來自執行緒池)上被呼叫,而不是被「等待」。

其他工具函數

您直接呼叫的任何其他工具函數都可以使用普通的 defasync def 建立,FastAPI 不會影響您呼叫它的方式。

這與 FastAPI 為您呼叫的函數(*路徑操作函數*和依賴項)形成對比。

如果您的工具函數是使用 def 建立的普通函數,它將直接被呼叫(如同您在程式碼中編寫的那樣),而不是在執行緒池中;如果函數是使用 async def 建立的,那麼您應該在程式碼中呼叫它時使用 await 等待它。


同樣,這些是非常技術性的細節,如果您是特意搜尋它們,可能會對您有所幫助。

否則,您應該遵循上一節的指導原則:趕時間?