同步與非同步Python有何不同?

你是否聽到人們說過,非同步 Python 程式碼比“普通(或同步)Python 程式碼更快?果真是那樣嗎?

“同步”和“非同步”是什麼意思?

Web 應用程式通常要處理許多請求,這些請求在很短的時間段內來自不同的客戶端。為避免處理延遲,必須考慮並行處理多個請求,這通常稱為“併發”。

在本文中,我將繼續使用 Web 應用程式作為例子,但是要記住還有其它型別的應用程式也從併發完成多個任務中獲益,因此這個討論並不僅僅是針對 Web 應用程式的。

術語“同步”和“非同步”指的是編寫併發應用程式的兩種方式。所謂的“同步”伺服器使用底層作業系統支援的執行緒和程序來實現這種併發性。下面是同步部署的一個示意圖:

在這種情況下,我們有 5 臺客戶端,都向應用程式傳送請求。這個應用程式的訪問入口是一個 Web 伺服器,通過將服務分配給一個伺服器 worker 池來充當負載均衡器,這些 worker 可以實現為程序、執行緒或者兩者的結合。這些 worker 執行負載均衡器分配給他們的請求。你使用 Web 應用程式框架(例如 Flask 或 Django)編寫的應用程式邏輯執行在這些 worker 中。

這種型別的方案對於有多個 CPU 的伺服器比較好,因為你可以將 worker 的數量設定為 CPU 的數量,這樣你就能均衡地利用你的處理器核心,而單個 Python 程序由於全域性直譯器鎖(GIL)的限制無法實現這一點。

在缺點方面,上面的示意圖也清楚展示了這種方案的主要侷限。我們有 5 個客戶端,卻只有 4 個 worker。如果這 5 個客戶端在同一時間都傳送請求,那麼負載均衡器會將某一個客戶端之外的所有請求傳送到 worker 池,而剩下的請求不得不保留在一個佇列中,等待有 worker 變得可用。因此,五分之四的請求會立即響應,而剩下的五分之一需要等一會兒。伺服器優化的一個關鍵就在於選擇適當數量的 worker 來防止或最小化給定預期負載的請求阻塞。

一個非同步伺服器的配置很難畫,但是我會盡力而為:

這種型別的伺服器執行在單個程序中,通過迴圈控制。這個迴圈是一個非常有效率的工作管理員和排程器,建立任務來執行由客戶端傳送的請求。與長期存在的伺服器 worker 不同,非同步任務是由迴圈建立,用來處理某個特定的請求,當那個請求完成時,該任務也會被銷燬。任何時候,一臺非同步伺服器都會有上百或上千個活躍的任務,它們都在迴圈的管理下執行自己的工作。

你可能想知道非同步任務之間的並行是如何實現的。這就是有趣的部分,因為一個非同步應用程式通過唯一的協同多工處理來實現這點。這意味著什麼?當一個任務需要等待一個外部事件(例如,一個資料庫伺服器的響應)時,不會像一個同步的 worker 那樣等待,而是會告訴迴圈它需要等待什麼,然後將控制權返回給它。迴圈就能夠在這個任務被資料庫阻塞的時候發現另外一個準備就緒的任務。最終,資料庫將傳送一個響應,而那時迴圈會認為第一個的任務已經準備好再次執行,並將儘快恢復它。

非同步任務暫停和恢復執行的這種能力可能在抽象上很難理解。為了幫助你應用到你已經知道的東西,可以考慮在 Python 中使用await或yield關鍵字這一方法來實現,但你之後會發現這並不是唯一實現非同步任務的方法。

一個非同步應用程式完全執行在單個程序或執行緒中,這可以說是令人吃驚的。當然,這種型別的併發需要遵循一些規則,因此你不能讓一個任務佔用 CPU 太長時間,否則,剩餘的任務會被阻塞。為了非同步執行,所有的任務需要定時主動暫停並將控制權返還給迴圈。為了從非同步方式獲益,一個應用程式需要有經常被 I/O 阻塞的任務,並且沒有太多 CPU 工作。Web 應用程式通常非常適合,特別是當它們需要處理大量客戶端請求時。

在使用一個非同步伺服器時,為了最大化多 CPU 的利用率,通常需要建立一個混合方案,增加一個負載均衡器並在每個 CPU 上執行一個非同步伺服器,如下圖所示:

Python 中實現非同步的 2 種方法

我敢肯定,你知道要在 Python 中寫一個非同步應用程式,你可以使用 asyncio package ,這個包是在協程的基礎上實現了所有非同步應用程式都需要的暫停和恢復特性。其中yield關鍵字,以及更新的async和await都是asyncio構建非同步能力的基礎。

Python 生態系統中還有其它基於協程的非同步方案,例如 Trio 和 Curio 。還有 Twisted ,它是所有協程框架中最古老的,甚至出現得比asyncio都要早。

如果你對編寫非同步 Web 應用程式感興趣,有許多基於協程的非同步框架可以選擇,包括 aiohttp 、 sanic 、 FastAPI 和 Tornado 。

很多人不知道的是,協程只是 Python 中編寫非同步程式碼的兩種方法之一。第二種方法是基於一個叫做 greenlet 的庫,你可以用 pip 安裝它。Greenlets 和協程類似,它們也允許一個 Python 函式暫停執行並稍後恢復,但是它們實現這點的方式完全不同,這意味著 Python 中的非同步生態系統分成兩大類。

協程與 greenlets 之間針對非同步開發最有意思的區別是,前者需要 Python 語言特定的關鍵字和特性才能工作,而後者並不需要。我的意思是,基於協程的應用程式需要使用一種特定的語法來書寫,而基於 greenlet 的應用程式看起來幾乎和普通 Python 程式碼一樣。這非常酷,因為在某些情況下,這讓同步程式碼可以被非同步執行,這是諸如asyncio之類的基於協程的方案做不到的。

那麼在 greenlet 方面,跟asyncio對等的庫有哪些?我知道 3 個基於 greenlet 的非同步包: Gevent 、 Eventlet 和 Meinheld ,儘管最後一個更像是一個 Web 伺服器而不是一個通用的非同步庫。它們都有自己的非同步迴圈實現,而且它們都提供了一個有趣的“monkey-patching”功能,取代了 Python 標準庫中的阻塞函式,例如那些執行網路和執行緒的函式,並基於 greenlets 實現了等效的非阻塞版本。如果你有一些同步程式碼想要非同步執行,這些包會對你有所幫助。

據我所知,唯一明確支援 greenlet 的 Web 框架只有 Flask 。這個框架會自動監測,當你想要執行在一個 greenlet Web 伺服器上時,它會自我進行相應調整,而無需進行任何配置。這麼做時,你需要注意不要呼叫阻塞函式,或者,如果你要呼叫阻塞函式,最好用猴子補丁來“修復”那些阻塞函式。

但是,Flask 並不是唯一受益於 greenlets 的框架。其它 Web 框架,例如 Django 和 Bottle ,雖然沒有 greenlets,但也可以通過結合一個 greenlet Web 伺服器並使用 monkey-patching 修復阻塞函式的方式來非同步執行。

非同步比同步更快嗎?

對於同步和非同步應用程式的效能,存在著一個廣泛的誤解——非同步應用程式比同步應用程式快得多。

對此,我需要澄清一下。無論是用同步方式寫,還是用非同步方式寫,Python 程式碼執行速度是幾乎相同的。除了程式碼,有兩個因素能夠影響一個併發應用程式的效能:上下文切換和可擴充套件性。

上下文切換

在所有執行的任務間公平地共享 CPU 所需的工作,稱為上下文切換,能夠影響應用程式的效能。對同步應用程式來說,這項工作是由作業系統完成的,而且基本上是一個黑箱,不需要配置或微調選項。對非同步應用程式來說,上下文切換是由迴圈完成的。

預設的迴圈實現由asyncio提供,是用 Python 編寫的,效率不是很高。而 uvloop 包提供了一個備選的迴圈方案,其中部分程式碼是用 C 編寫的來實現更好的效能。Gevent 和 Meinheld 所使用的事件迴圈也是用 C 編寫的。Eventlet 用的是 Python 編寫的迴圈。

高度優化的非同步迴圈比作業系統在進行上下文切換方面更有效率,但根據我的經驗,要想看到實際的效率提升,你執行的併發量必須非常大。對於大部分應用程式,我不認為同步和非同步上下文切換之間的效能差距有多明顯。

擴充套件性

我認為非同步更快這個神話的來源是,非同步應用程式通常會更有效地使用 CPU、能更好地進行擴充套件並且擴充套件方式比同步更靈活。

如果上面示意圖中的同步伺服器同時收到 100 個請求,想一下會發生什麼。這個伺服器同時最多隻能處理 4 個請求,因此大部分請求會停留在一個佇列中等待,直到它們被分配一個 worker。

與之形成對比的是,非同步伺服器會立即建立 100 個任務(或者使用混合模式的話,在 4 個非同步 worker 上每個建立 25 個任務)。使用非同步伺服器,所有請求都會立即開始處理而不用等待(儘管公平地說,這種方案也還會有其它瓶頸會減慢速度,例如對活躍的資料庫連線的限制)。

如果這 100 個任務主要使用 CPU,那麼同步和非同步方案會有相似的效能,因為每個 CPU 執行的速度是固定的,Python 執行程式碼的速度總是相同的,應用程式要完成的工作也是相同的。但是,如果這些任務需要做很多 I/O 操作,那麼同步伺服器只能處理 4 個併發請求而不能實現 CPU 的高利用率。而另一方面,非同步伺服器會更好地保持 CPU 繁忙,因為它是並行地執行所有這 100 個請求。

你可能會想,為什麼你不能執行 100 個同步 worker,那樣,這兩個伺服器就會有相同的併發能力。要注意,每個 worker 需要自己的 Python 直譯器以及與之相關聯的所有資源,再加上一份單獨的應用程式拷貝及其資源。你的伺服器和應用程式的大小將決定你可以執行多少個 worker 例項,但通常這個數字不會很大。另一方面,非同步任務非常輕量,都執行在單個 worker 程序的上下文中,因此具有明顯優勢。

綜上所述,只有如下場景時,我們可以說非同步可能比同步快:

  • 存在高負載(沒有高負載,訪問的高併發性就沒有優勢)
  • 任務是 I/O 繫結的(如果任務是 CPU 繫結的,那麼超過 CPU 數目的併發並沒有幫助)
  • 你檢視單位時間內的平均請求處理數。如果你檢視單個請求的處理時間,你不會看到有很大差別,甚至非同步可能更慢,因為非同步有更多併發的任務在爭奪 CPU。

結論

希望本文能解答非同步程式碼的一些困惑和誤解。我希望你能記住以下兩個關鍵點:

  • 非同步應用程式只有在高負載下才會比同步應用程式做得更好
  • 多虧了 greenlets,即使你用一般方式寫程式碼並使用 Flask 或 Django 之類的傳統框架,也能從非同步中受益。

如果你想要了解更多關於非同步系統如何工作的細節,可以檢視 YouTube 上我在 PyCon 的演講 Asynchronous Python for the Complete Beginner 。

作者介紹:

Miguel Grinberg 是一名軟體工程師、攝影師和電影製作人,住在愛爾蘭的德羅赫拉。你可以在 Facebook 、 Google 、 LinkedIn 、 Github 和 Twitter 關注他。

原文連結:

https://blog.miguelgrinberg.com/post/sync-vs-async-python-what-is-the-difference

關注我並轉發此篇文章,私信我“領取資料”,即可免費獲得InfoQ價值4999元迷你書,點選文末「瞭解更多」,即可移步InfoQ官網,獲取最新資訊~