Python 3.5 協程究竟是個啥

發表於2016-09-23

作為 Python 核心開發者之一,讓我很想了解這門語言是如何運作的。我發現總有一些陰暗的角落我對其中錯綜複雜的細節不是很清楚,但是為了能夠有助於 Python 的一些問題和其整體設計,我覺得我應該試著去理解 Python 的核心語法和內部運作機制。

但是直到最近我才理解 Python 3.5 中 async/await 的原理。我知道 Python 3.3 中的 yield fromPython 3.4 中的 asyncio 組合得來這一新語法。但較少處理網路相關的問題 – asyncio 並不僅限於此但確是重要用途 – 使我沒太注意 async/await 。我知道:

(本質上)相當於:

我知道 asyncio 是事件迴圈框架可以進行非同步程式設計,但是我只是知道這裡面每個單詞的意思而已,從沒深入研究 async/await 語法組合背後的原理,我發現不理解 Python 中的非同步程式設計已經對我造成了困擾。因此我決定花時間弄清楚這背後的原理究竟是什麼。我從很多人那裡得知他們也不瞭解非同步程式設計的原理,因此我決定寫這篇論文(是的,由於這篇文章花費時間之久以及篇幅之長,我的妻子已經將其定義為一篇論文)。

由於我想要正確地理解這些語法的原理,這篇文章涉及到一些關於 CPython 較為底層的技術細節。如果這些細節超出了你想了解的內容,或者你不能完全理解它們,都沒關係,因為我為了避免這篇文章演變成一本書那麼長,省略了一些 CPython 內部的細枝末節(比如說,如果你不知道 code object 有 flags,甚至不知道什麼是 code object,這都沒關係,也不用一定要從這篇文字中獲得什麼)。我試著在最後一小節中用更直接的方法做了總結,如果覺得文章對你來說細節太多,你完全可以跳過。

關於 Python 協程的歷史課

根據維基百科給出的定義,“協程 是為非搶佔式多工產生子程式的計算機程式元件,協程允許不同入口點在不同位置暫停或開始執行程式”。從技術的角度來說,“協程就是你可以暫停執行的函式”。如果你把它理解成“就像生成器一樣”,那麼你就想對了。

退回到 Python 2.2,生成器第一次在PEP 255中提出(那時也把它成為迭代器,因為它實現了迭代器協議)。主要是受到Icon程式語言的啟發,生成器允許建立一個在計算下一個值時不會浪費記憶體空間的迭代器。例如你想要自己實現一個 range() 函式,你可以用立即計算的方式建立一個整數列表:

然而這裡存在的問題是,如果你想建立從0到1,000,000這樣一個很大的序列,你不得不建立能容納1,000,000個整數的列表。但是當加入了生成器之後,你可以不用建立完整的序列,你只需要能夠每次儲存一個整數的記憶體即可。

讓函式遇到 yield 表示式時暫停執行 – 雖然在 Python 2.5 以前它只是一條語句 – 並且能夠在後面重新執行,這對於減少記憶體使用、生成無限序列非常有用。

你有可能已經發現,生成器完全就是關於迭代器的。有一種更好的方式生成迭代器當然很好(尤其是當你可以給一個生成器物件新增 __iter__() 方法時),但是人們知道,如果可以利用生成器“暫停”的部分,新增“將東西傳送回生成器”的功能,那麼 Python 突然就有了協程的概念(當然這裡的協程僅限於 Python 中的概念;Python 中真實的協程在後面才會討論)。將東西傳送回暫停了的生成器這一特性通過 PEP 342新增到了 Python 2.5。與其它特性一起,PEP 342 為生成器引入了 send() 方法。這讓我們不僅可以暫停生成器,而且能夠傳遞值到生成器暫停的地方。還是以我們的 range() 為例,你可以讓序列向前或向後跳過幾個值:

直到PEP 380Python 3.3 新增了 yield from之前,生成器都沒有變動。嚴格來說,這一特性讓你能夠從迭代器(生成器剛好也是迭代器)中返回任何值,從而可以乾淨利索的方式重構生成器。

yield from 通過讓重構變得簡單,也讓你能夠將生成器串聯起來,使返回值可以在呼叫棧中上下浮動,而不需對編碼進行過多改動。

總結

Python 2.2 中的生成器讓程式碼執行過程可以暫停。Python 2.5 中可以將值返回給暫停的生成器,這使得 Python 中協程的概念成為可能。加上 Python 3.3 中的 yield from,使得重構生成器與將它們串聯起來都很簡單。

什麼是事件迴圈?

如果你想了解 async/await,那麼理解什麼是事件迴圈以及它是如何讓非同步程式設計變為可能就相當重要了。如果你曾做過 GUI 程式設計 – 包括網頁前端工作 – 那麼你已經和事件迴圈打過交道。但是由於非同步程式設計的概念作為 Python 語言結構的一部分還是最近才有的事,你剛好不知道什麼是事件迴圈也很正常。

回到維基百科,事件迴圈 “是一種等待程式分配事件或訊息的程式設計架構”。基本上來說事件迴圈就是,“當A發生時,執行B”。或許最簡單的例子來解釋這一概念就是用每個瀏覽器中都存在的JavaScript事件迴圈。當你點選了某個東西(“當A發生時”),這一點選動作會傳送給JavaScript的事件迴圈,並檢查是否存在註冊過的 onclick 回撥來處理這一點選(“執行B”)。只要有註冊過的回撥函式就會伴隨點選動作的細節資訊被執行。事件迴圈被認為是一種迴圈是因為它不停地收集事件並通過迴圈來發如何應對這些事件。

對 Python 來說,用來提供事件迴圈的 asyncio 被加入標準庫中。asyncio 重點解決網路服務中的問題,事件迴圈在這裡將來自套接字(socket)的 I/O 已經準備好讀和/或寫作為“當A發生時”(通過selectors模組)。除了 GUI 和 I/O,事件迴圈也經常用於在別的執行緒或子程式中執行程式碼,並將事件迴圈作為調節機制(例如,合作式多工)。如果你恰好理解 Python 的 GIL,事件迴圈對於需要釋放 GIL 的地方很有用。

總結

事件迴圈提供一種迴圈機制,讓你可以“在A發生時,執行B”。基本上來說事件迴圈就是監聽當有什麼發生時,同時事件迴圈也關心這件事並執行相應的程式碼。Python 3.4 以後通過標準庫 asyncio 獲得了事件迴圈的特性。

asyncawait 是如何運作的

Python 3.4 中的方式

在 Python 3.3 中出現的生成器與之後以 asyncio 的形式出現的事件迴圈之間,Python 3.4 通過併發程式設計的形式已經對非同步程式設計有了足夠的支援。非同步程式設計簡單來說就是程式碼執行的順序在程式執行前是未知的(因此才稱為非同步而非同步)。併發程式設計是程式碼的執行不依賴於其他部分,即便是全都在同一個執行緒內執行(併發不是並行)。例如,下面 Python 3.4 的程式碼分別以非同步和併發的函式呼叫實現按秒倒數計時。

Python 3.4 中,asyncio.coroutine 修飾器用來標記作為協程的函式,這裡的協程是和asyncio及其事件迴圈一起使用的。這賦予了 Python 第一個對於協程的明確定義:實現了PEP 342新增到生成器中的這一方法的物件,並通過[collections.abc.Coroutine這一抽象基類]表徵的物件。這意味著突然之間所有實現了協程介面的生成器,即便它們並不是要以協程方式應用,都符合這一定義。為了修正這一點,asyncio 要求所有要用作協程的生成器必須asyncio.coroutine修飾

有了對協程明確的定義(能夠匹配生成器所提供的API),你可以對任何asyncio.Future物件使用 yield from,從而將其傳遞給事件迴圈,暫停協程的執行來等待某些事情的發生( future 物件並不重要,只是asyncio細節的實現)。一旦 future 物件獲取了事件迴圈,它會一直在那裡監聽,直到完成它需要做的一切。當 future 完成自己的任務之後,事件迴圈會察覺到,暫停並等待在那裡的協程會通過send()方法獲取future物件的返回值並開始繼續執行。

以上面的程式碼為例。事件迴圈啟動每一個 countdown() 協程,一直執行到遇見其中一個協程的 yield fromasyncio.sleep() 。這樣會返回一個 asyncio.Future物件並將其傳遞給事件迴圈,同時暫停這一協程的執行。事件迴圈會監控這一future物件,直到倒數計時1秒鐘之後(同時也會檢查其它正在監控的物件,比如像其它協程)。1秒鐘的時間一到,事件迴圈會選擇剛剛傳遞了future物件並暫停了的 countdown() 協程,將future物件的結果返回給協程,然後協程可以繼續執行。這一過程會一直持續到所有的 countdown() 協程執行完畢,事件迴圈也被清空。稍後我會給你展示一個完整的例子,用來說明協程/事件迴圈之類的這些東西究竟是如何運作的,但是首先我想要解釋一下asyncawait

Python 3.5 從 yield fromawait

在 Python 3.4 中,用於非同步程式設計並被標記為協程的函式看起來是這樣的:

Python 3.5 新增了types.coroutine 修飾器,也可以像 asyncio.coroutine 一樣將生成器標記為協程。你可以用 async def 來定義一個協程函式,雖然這個函式不能包含任何形式的 yield 語句;只有 returnawait 可以從協程中返回值。

雖然 asynctypes.coroutine 的關鍵作用在於鞏固了協程的定義,但是它將協程從一個簡單的介面變成了一個實際的型別,也使得一個普通生成器和用作協程的生成器之間的差別變得更加明確(inspect.iscoroutine() 函式 甚至明確規定必須使用 async 的方式定義才行)。

你將發現不僅僅是 async,Python 3.5 還引入 await 表示式(只能用於async def中)。雖然await的使用和yield from很像,但await可以接受的物件卻是不同的。await 當然可以接受協程,因為協程的概念是所有這一切的基礎。但是當你使用 await 時,其接受的物件必須是awaitable 物件:必須是定義了__await__()方法且這一方法必須返回一個不是協程的迭代器。協程本身也被認為是 awaitable 物件(這也是collections.abc.Coroutine 繼承 collections.abc.Awaitable的原因)。這一定義遵循 Python 將大部分語法結構在底層轉化成方法呼叫的傳統,就像 a + b 實際上是a.__add__(b) 或者 b.__radd__(a)

yield fromawait 在底層的差別是什麼(也就是types.coroutineasync def的差別)?讓我們看一下上面兩則Python 3.5程式碼的例子所產生的位元組碼在本質上有何差異。py34_coro()的位元組碼是:

py35_coro()的位元組碼是:

忽略由於py34_coro()asyncio.coroutine 修飾器所帶來的行號的差別,兩者之間唯一可見的差異是GET_YIELD_FROM_ITER操作碼 對比GET_AWAITABLE操作碼。兩個函式都被標記為協程,因此在這裡沒有差別。GET_YIELD_FROM_ITER 只是檢查引數是生成器還是協程,否則將對其引數呼叫iter()方法(只有用在協程內部的時候yield from所對應的操作碼才可以接受協程物件,在這個例子裡要感謝types.coroutine修飾符將這個生成器在C語言層面標記為CO_ITERABLE_COROUTINE)。

但是 GET_AWAITABLE的做法不同,其位元組碼像GET_YIELD_FROM_ITER一樣接受協程,但是接受沒有被標記為協程的生成器。就像前面討論過的一樣,除了協程以外,這一位元組碼還可以接受awaitable物件。這使得yield fromawait表示式都接受協程但分別接受一般的生成器和awaitable物件。

你可能會想,為什麼基於async的協程和基於生成器的協程會在對應的暫停表示式上面有所不同?主要原因是出於最優化Python效能的考慮,確保你不會將剛好有同樣API的不同物件混為一談。由於生成器預設實現協程的API,因此很有可能在你希望用協程的時候錯用了一個生成器。而由於並不是所有的生成器都可以用在基於協程的控制流中,你需要避免錯誤地使用生成器。但是由於 Python 並不是靜態編譯的,它最好也只能在用基於生成器定義的協程時提供執行時檢查。這意味著當用types.coroutine時,Python 的編譯器將無法判斷這個生成器是用作協程還是僅僅是普通的生成器(記住,僅僅因為types.coroutine這一語法的字面意思,並不意味著在此之前沒有人做過types = spam的操作),因此編譯器只能基於當前的情況生成有著不同限制的操作碼。

關於基於生成器的協程和async定義的協程之間的差異,我想說明的關鍵點是隻有基於生成器的協程可以真正的暫停執行並強制性返回給事件迴圈。你可能不瞭解這些重要的細節,因為通常你呼叫的像是asyncio.sleep() function 這種事件迴圈相關的函式,由於事件迴圈實現他們自己的API,而這些函式會處理這些小的細節。對於我們絕大多數人來說,我們只會跟事件迴圈打交道,而不需要處理這些細節,因此可以只用async定義的協程。但是如果你和我一樣好奇為什麼不能在async定義的協程中使用asyncio.sleep(),那麼這裡的解釋應該可以讓你頓悟。

總結

讓我們用簡單的話來總結一下。用async def可以定義得到協程。定義協程的另一種方式是通過types.coroutine修飾器 — 從技術實現的角度來說就是新增了 CO_ITERABLE_COROUTINE標記 — 或者是collections.abc.Coroutine的子類。你只能通過基於生成器的定義來實現協程的暫停。

awaitable 物件要麼是一個協程要麼是一個定義了__await__()方法的物件 — 也就是collections.abc.Awaitable — 且__await__()必須返回一個不是協程的迭代器。await表示式基本上與yield from相同但只能接受awaitable物件(普通迭代器不行)。async定義的函式要麼包含return語句 — 包括所有Python函式預設的return None — 和/或者 await表示式(yield表示式不行)。async函式的限制確保你不會將基於生成器的協程與普通的生成器混合使用,因為對這兩種生成器的期望是非常不同的。

async/await 看做非同步程式設計的 API

我想要重點指出的地方實際上在我看David Beazley’s Python Brasil 2015 keynote之前還沒有深入思考過。在他的演講中,David 指出 async/await 實際上是非同步程式設計的 API (他在 Twitter 上向我重申過)。David 的意思是人們不應該將async/await等同於asyncio,而應該將asyncio看作是一個利用async/await API 進行非同步程式設計的框架。

David 將 async/await 看作是非同步程式設計的API建立了 curio 專案來實現他自己的事件迴圈。這幫助我弄清楚 async/await 是 Python 建立非同步程式設計的原料,同時又不會將你束縛在特定的事件迴圈中也無需與底層的細節打交道(不像其他程式語言將事件迴圈直接整合到語言中)。這允許像 curio 一樣的專案不僅可以在較低層面上擁有不同的操作方式(例如 asyncio 利用 future 物件作為與事件迴圈交流的 API,而 curio 用的是元組),同時也可以集中解決不同的問題,實現不同的效能特性(例如 asyncio 擁有一整套框架來實現運輸層和協議層,從而使其變得可擴充套件,而 curio 只是簡單地讓使用者來考慮這些但同時也讓它執行地更快)。

考慮到 Python 非同步程式設計的(短暫)歷史,可以理解人們會誤認為 async/await == asyncio。我是說asyncio幫助我們可以在 Python 3.4 中實現非同步程式設計,同時也是 Python 3.5 中引入async/await的推動因素。但是async/await 的設計意圖就是為了讓其足夠靈活從而不需要依賴asyncio或者僅僅是為了適應這一框架而扭曲關鍵的設計決策。換句話說,async/await 延續了 Python 設計儘可能靈活的傳統同時又非常易於使用(實現)。

一個例子

到這裡你的大腦可能已經灌滿了新的術語和概念,導致你想要從整體上把握所有這些東西是如何讓你可以實現非同步程式設計的稍微有些困難。為了幫助你讓這一切更加具體化,這裡有一個完整的(偽造的)非同步程式設計的例子,將程式碼與事件迴圈及其相關的函式一一對應起來。這個例子裡包含的幾個協程,代表著火箭發射的倒數計時,並且看起來是同時開始的。這是通過併發實現的非同步程式設計;3個不同的協程將分別獨立執行,並且都在同一個執行緒內完成。

就像我說的,這是偽造出來的,但是如果你用 Python 3.5 去執行,你會發現這三個協程在同一個執行緒內獨立執行,並且總的執行時間大約是5秒鐘。你可以將TaskSleepingLoopsleep()看作是事件迴圈的提供者,就像asynciocurio所提供給你的一樣。對於一般的使用者來說,只有countdown()main()函式中的程式碼才是重要的。正如你所見,asyncawait或者是這整個非同步程式設計的過程並沒什麼黑科技;只不過是 Python 提供給你幫助你更簡單地實現這類事情的API。

我對未來的希望和夢想

現在我理解了 Python 中的非同步程式設計是如何運作的了,我想要一直用它!這是如此絕妙的概念,比你之前用過的執行緒好太多了。但是問題在於 Python 3.5 還太新了,async/await也太新了。這意味著還沒有太多庫支援這樣的非同步程式設計。例如,為了實現 HTTP 請求你要麼不得不自己徒手構建 ,要麼用像是 aiohttp 之類的框架 將 HTTP 新增在另外一個事件迴圈的頂端,或者寄希望於更多像hyper一樣的專案不停湧現,可以提供對於 HTTP 之類的抽象,可以讓你隨便用任何 I/O 庫 來實現你的需求(雖然可惜的是 hyper目前只支援 HTTP/2)。

對於我個人來說,我希望更多像hyper一樣的專案可以脫穎而出,這樣我們就可以在從 I/O中讀取與解讀二進位制資料之間做出明確區分。這樣的抽象非常重要,因為Python多數 I/O 庫中處理 I/O 和處理資料是緊緊耦合在一起的。Python 的標準庫 http就有這樣的問題,它不提供 HTTP解析而只有一個連線物件為你處理所有的 I/O。而如果你寄希望於requests可以支援非同步程式設計,那你的希望已經破滅了,因為 requests 的同步 I/O 已經烙進它的設計中了。Python 在網路堆疊上很多層都缺少抽象定義,非同步程式設計能力的改進使得 Python 社群有機會對此作出修復。我們可以很方便地讓非同步程式碼像同步一樣執行,這樣一些填補非同步程式設計空白的工具可以安全地執行在兩種環境中。

我希望 Python 可以讓 async 協程支援 yield。或者需要用一個新的關鍵詞來實現(可能像 anticipate之類?),因為不能僅靠async就實現事件迴圈讓我很困擾。幸運的是,我不是唯一一個這麼想的人,而且PEP 492的作者也和我意見一致,我覺得還是有機會可以移除掉這點小瑕疵。

結論

基本上 asyncawait 產生神奇的生成器,我們稱之為協程,同時需要一些額外的支援例如 awaitable 物件以及將普通生成器轉化為協程。所有這些加到一起來支援併發,這樣才使得 Python 更好地支援非同步程式設計。相比類似功能的執行緒,這是一個更妙也更簡單的方法。我寫了一個完整的非同步程式設計例子,算上註釋只用了不到100行 Python 程式碼 — 但仍然非常靈活與快速(curio FAQ 指出它比 twisted 要快 30-40%,但是要比 gevent 慢 10-15%,而且全部都是有純粹的 Python 實現的;記住Python 2 + Twisted 記憶體消耗更少同時比Go更容易除錯,想象一下這些能幫你實現什麼吧!)。我非常高興這些能夠在 Python 3 中成為現實,我也非常期待 Python 社群可以接納並將其推廣到各種庫和框架中區,可以使我們都能夠受益於 Python 非同步程式設計帶來的好處!

相關文章