最近通過的PEP-0492為 Python 3.5 在處理協程時增加了一些特殊的語法。新功能中很大一部分在3.5 之前的版本就已經有了,不過之前的語法並不算最好的,因為生成器和協程的概念本身就有點容易混淆。PEP-0492 通過使用 async 關鍵字顯式的對生成器和協程做了區分。
本文旨在說明這些新的機制在底層是如何工作的。如果你只是對怎麼使用這些功能感興趣,那我建議你可以忽略這篇文章,而是去看一下內建的 asyncio 模組的文件。如果你對底層的概念感興趣,關心這些底層功能如何能構建你自己的 asyncio 模組,那你會發現本文會有有意思。
本文中我們會完全放棄任何非同步 I/O 方法,而只限於使用多協程的互動。下面是兩個很小的函式:
1 2 3 4 5 6 7 8 9 10 11 |
def coro1(): print("C1: Start") print("C1: Stop") def coro2(): print("C2: Start") print("C2: a") print("C2: b") print("C2: c") print("C2: Stop") |
我們從兩個最簡單的函式開始,coro1和coro2。我們可以按順序來執行這兩個函式:
1 2 |
coro1() coro2() |
我們得到期望的輸出結果:
1 2 3 4 5 6 7 |
C1: Start C1: Stop C2: Start C2: a C2: b C2: c C2: Stop |
不過,基於某些原因,我們可能會期望這些程式碼互動執行。普通的函式做不到這點,所以我們把這些函式轉換成攜程:
1 2 3 4 5 6 7 8 9 10 11 |
async def coro1(): print("C1: Start") print("C1: Stop") async def coro2(): print("C2: Start") print("C2: a") print("C2: b") print("C2: c") print("C2: Stop") |
通過新的 async 關鍵字的魔法,這些函式不再是函式了,現在它們變成了協程(更準確的說是本地協程函式)。普通函式被呼叫的時候,函式體會被執行,但是在呼叫協程函式的時候,函式體並不會被執行,你得到的是一個協程物件:
1 2 3 |
c1 = coro1() c2 = coro2() print(c1, c2) |
輸出:
1 |
<coroutine object coro1 at 0x10ea60990> <coroutine object coro2 at 0x10ea60a40> |
(直譯器還會列印一些執行時的警告資訊,先忽略掉)。
那麼,為什麼要有一個協程物件?程式碼到底如何執行?執行協程的一種方式是使用 await 表示式(使用新的 await 關鍵字)。你可能會想,可以這樣來做:
1 |
await c1 |
不過,你肯定會失望了。await 表示式只有在本地協程函式裡才是有效的。你必須這樣做:
1 2 |
async def main(): await c1 |
接下來問題來了,main 函式又是如何開始執行的呢?
關鍵之處是協程確實是與 Python 的生成器非常相似,也都有一個 send 方法。我們可以通過呼叫 send 方法來啟動一個協程的執行。
1 |
c1.send(None) |
這樣我們的第一個協程終於可以執行完成了,不過我們也得到了一個討厭的 StopIteration 異常:
1 2 3 4 5 6 |
C1: Start C1: Stop Traceback (most recent call last): File "test3.py", line 16, in c1.send(None) StopIteration |
StopIteration 異常是一種標記生成器(或者像這裡的協程)執行結束的機制。雖然這是一個異常,但是確實是我們期望的!我們可以用適當的 try-catch 程式碼將其包起來,這樣就可以避免錯誤提示。接下來我們讓我們的第二個協程也執行起來:
1 2 3 4 5 6 7 8 |
try: c1.send(None) except StopIteration: pass try: c2.send(None) except StopIteration: pass |
現在我們得到了全部的輸出,不過有點讓人失望的是這跟最初的輸出結果沒有啥區別。因此我們增加了不少程式碼,不過還沒有做到交替執行。協程與執行緒相似的地方是多個執行緒之間也可以交替執行,不過與執行緒不同之處在於協程之間的切換是顯式的,而執行緒是隱式的(大多數情況下是更好的方式)。所以我們需要加入顯式切換的程式碼。
通常生成器的 send 方法會一直執行,直到通過 yield 關鍵字放棄執行,也許你認為我們的 coro1 可以改成這個樣子:
1 2 3 4 |
async def coro1(): print("C1: Start") yield print("C1: Stop") |
但是我們不能在協程裡使用 yield。作為替換,我們可以使用新的 await 表示式來暫停協程的執行,直到 awaitable 執行結束。於是我們需要的程式碼類似於 await _something_;問題是這裡 _something_ 是什麼呢?我們必須 await 某個東西,而不是空!這個 PEP 解釋了什麼是可以 await 的(awaitable)。其中一種是另一個本地協程,不過這個對我們瞭解底層細節沒有啥幫助。另一種是通過特定 CPython API 定義的物件,不過我們暫時還不打算引入擴充套件模組,而只限於使用純 Python。除此之外,還剩下兩種選擇:基於生成器的協程物件,或者一個特殊的類似 Future 的物件。
接下來,我們會選擇基於生成器的協程物件。基本上一個 Python 的生成器(例如:某個有yield表示式的函式)可以通過 types.coroutine 裝飾被標記成一個協程。所以,這是一個最簡單的例子:
1 2 3 |
@types.coroutine def switch(): yield |
這定義了一個基於生成器的協程函式。要得到基於生成器的協程物件,只需要執行這個函式。我們可以把我們的 coro1 協程修改成下面這樣:
1 2 3 4 |
async def coro1(): print("C1: Start") await switch() print("C1: Stop") |
通過上面的修改,我們期望 coro1 和 coro2 可以交錯執行。到目前為止,輸出是這樣的:
1 2 3 4 5 6 |
C1: Start C2: Start C2: a C2: b C2: c C2: Stop |
我沒看到正如期望的,在第一條列印語句之後,coro1 停止執行,coro2 接著執行。實際上,我們可以通過下面的程式碼檢視協程物件是如何暫停執行的:
1 |
print("c1 suspended at: {}:{}".format(c1.gi_frame.f_code.co_filename, c1.gi_frame.f_lineno)) |
這可以列印 await 表示式所在的行。(注意:列印的是最外層的 await,所以這裡只是起示例作用,通常情況下用處不大)。
現在的問題是,如何讓 coro1 繼續執行完呢?我們可以再呼叫一次 send,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
try: c1.send(None) except StopIteration: pass try: c2.send(None) except StopIteration: pass try: c1.send(None) except StopIteration: pass |
得到的輸出跟預期一樣:
1 2 3 4 5 6 7 |
C1: Start C2: Start C2: a C2: b C2: c C2: Stop C1: Stop |
目前,我們通過為不同的協程顯式呼叫 send來讓它們都執行結束。通常情況下這種方式不是很好。我們希望的是有一個函式來控制所有的協程的執行,直到全部協程都執行完成。換句話說,我們期望連續不斷的呼叫 send,驅動不同的協程去執行,直到send丟擲 StopIteration 異常。
為此我們新建一個函式,這個函式傳入一個協程列表,函式執行這些協程直到全部結束。我們現在要做的就是呼叫這個函式。
1 2 3 4 5 6 7 8 9 10 |
def run(coros): coros = list(coros) while coros: # Duplicate list for iteration so we can remove from original list. for coro in list(coros): try: coro.send(None) except StopIteration: coros.remove(coro) |
這段程式碼每次從協程列表裡取一個協程執行,如果捕獲到 StopIteration 異常,就把這個協程從佇列裡去掉。
接下來我們把手工呼叫 send 的程式碼去掉,程式碼如下:
1 2 3 |
c1 = coro1() c2 = coro2() run([c1, c2]) |
綜上所述,在 Python 3.5,我們現在可以通過新的 await 和 async 功能很輕鬆的執行協程。本文的相關程式碼可以在github 上找到。