深入理解 Python 虛擬機器:協程初探——不過是生成器而已

一無是處的研究僧發表於2023-10-16

深入理解 Python 虛擬機器:協程初探——不過是生成器而已

在 Python 3.4 Python 引入了一個非常有用的特性——協程,在後續的 Python 版本當中不斷的進行最佳化和改進,引入了新的 await 和 async 語法。在本篇文章當中我們將詳細介紹一下 Python 協程的原理以及虛擬機器具體的實現協程的方式。

什麼是協程

Coroutines are computer program components that allow execution to be suspended and resumed, generalizing subroutines for cooperative multitasking.

根據 wiki 的描述,協程是一個允許停下來和恢復執行的程式,從文字上來看這與我們的常識或者直覺是相互違背的,因為在大多數情況下我們的函式都是執行完才返回的。其實目前 Python 當中早已有了一個特效能夠做到這一點,就是生成器,如果想深入瞭解一下生成器的實現原理和相關的位元組碼可以參考這篇文章 深入理解 Python 虛擬機器:生成器停止背後的魔法

現在在 Python 當中可以使用 async 語法定一個協程函式(當函式使用 async 進行修飾的時候這個函式就是協程函式),當我們呼叫這個函式的時候會返回一個協程物件,而不是直接呼叫函式:

>>> async def hello():
...     return 0
... 
>>> hello()
<coroutine object hello at 0x100a04740>

在 inspect 模組當中也有一個方法用於判斷一個函式是否是協程函式:

import inspect

async def hello():
	return 0

print(inspect.iscoroutinefunction(hello)) # True

在 Python 當中當你想建立一個協程的話,就直接使用一個 async 關鍵字定一個函式,呼叫這個函式就可以得到一個協程物件。

在協程當中可以使用 await 關鍵字等待其他協程完成,當被等待的協程執行完成之後,就會返回到當前協程繼續執行:

import asyncio
import datetime
import time


async def sleep(t):
	time.sleep(t)


async def hello():
	print("start a coroutine", datetime.datetime.now())
	await sleep(3)
	print("wait for 3s", datetime.datetime.now())


if __name__ == '__main__':
	coroutine = hello()
	try:
		coroutine.send(None)
	except StopIteration:
		print("coroutine finished")
start a coroutine 2023-10-15 02:21:33.503505
wait for 3s 2023-10-15 02:21:36.503984
coroutine finished

在上面的程式當中,await sleep(3) 確實等待了 3 秒之後才繼續執行。

協程的實現

在 Python 當中協程其實就是生成器,只不過在生成器的基礎之上稍微包裝了一下,比如在寫成當中的 await 語句,其實作用和 yield from 對於生成器的作用差不多,稍微有點細微差別。我們用幾個例子來詳細分析一下協程和生成器之間的關係:

async def hello():
	return 0

if __name__ == '__main__':
	coroutine = hello()
	print(coroutine)
	try:
		coroutine.send(None)
	except StopIteration:
		print("coroutine finished")

上面的程式碼的輸出結果:

<coroutine object hello at 0x1170200c0>
coroutine finished

在上面的程式碼當中首先呼叫 hello 之後返回一個協程物件,協程物件和生成器物件一樣都有 send 方法,而且作用也一樣都是讓協程開始執行。和生成器一樣當一個生成器執行完成之後會產生 StopIteration 異常,因此需要對異常進行 try catch 處理。和協程還有一個相關的異常為 StopAsyncIteration,這一點我們在之後的文章詳細說。

我們再來寫一個稍微複雜一點例子:

async def bar():
	return "bar"


async def foo():
	name = await bar()
	print(f"{name = }")
	return "foo"


if __name__ == '__main__':
	coroutine = foo()
	try:
		coroutine.send(None)
	except StopIteration as e:
		print(f"{e.value = }")

上面的程式的輸出結果如下所示:

name = 'bar'
e.value = 'foo'

上面兩個協程都正確的執行完了程式碼,我們現在來看一下協程程式的位元組碼是怎麼樣的,上面的 foo 函式對應的位元組碼如下所示:

  9           0 LOAD_GLOBAL              0 (bar)
              2 CALL_FUNCTION            0
              4 GET_AWAITABLE
              6 LOAD_CONST               0 (None)
              8 YIELD_FROM
             10 STORE_FAST               0 (name)

 10          12 LOAD_GLOBAL              1 (print)
             14 LOAD_CONST               1 ('name = ')
             16 LOAD_FAST                0 (name)
             18 FORMAT_VALUE             2 (repr)
             20 BUILD_STRING             2
             22 CALL_FUNCTION            1
             24 POP_TOP

 11          26 LOAD_CONST               2 ('foo')
             28 RETURN_VALUE

在上面的程式碼當中和 await 語句相關的位元組碼有兩條,分別是 GET_AWAITABLE 和 YIELD_FROM,在函式 foo 當中首先會呼叫函式 bar 得到一個協程物件,得到的這個協程物件會放到虛擬機器的棧頂,然後執行 GET_AWAITABLE 這條位元組碼來說對於協程來說相當於沒執行。他具體的操作為彈出棧頂元素,如果棧頂元素是一個協程物件,則直接將這個協程物件再壓回棧頂,如果不是則呼叫物件的 __await__ 方法,將這個方法的返回值壓入棧頂。

然後需要執行的位元組碼就是 YIELD_FROM,這個位元組碼和 "yield from" 語句對應的位元組碼是一樣的,這就是為什麼說協程就是生成器(準確的來說還是有點不一樣,因為協程只是透過生成器的機制來完成,具體的實現需要編譯器、虛擬機器和標準庫協同工作,才能夠很好的完成協程程式,而且在虛擬機器當中與協程有關的物件有好幾個[都是基於生成器])。如果你不瞭解 YIELD_FROM 的工作原理,可以參考這篇文章:深入理解 Python 虛擬機器:生成器停止背後的魔法

我們在使用生成器的方式來重寫上面的程式:

def bar():
	yield # 這條語句的主要作用是將函式程式設計生成器
	return "bar"


def foo():
	name = yield from bar()
	print(f"{name = }")
	return "foo"


if __name__ == '__main__':
	generator = foo()
	try:
		generator.send(None) # 執行到第一條 yield 語句
		generator.send(None) # 從 yield 語句執行完成
	except StopIteration as e:
		print(f"{e.value = }")

我們再來看一下 foo 函式的位元組碼:

  7           0 LOAD_GLOBAL              0 (bar)
              2 CALL_FUNCTION            0
              4 GET_YIELD_FROM_ITER
              6 LOAD_CONST               0 (None)
              8 YIELD_FROM
             10 STORE_FAST               0 (name)

  8          12 LOAD_GLOBAL              1 (print)
             14 LOAD_CONST               1 ('name = ')
             16 LOAD_FAST                0 (name)
             18 FORMAT_VALUE             2 (repr)
             20 BUILD_STRING             2
             22 CALL_FUNCTION            1
             24 POP_TOP

  9          26 LOAD_CONST               2 ('foo')
             28 RETURN_VALUE

位元組碼 GET_YIELD_FROM_ITER 就是從一個物件當中獲取一個生成器。這個位元組碼會彈出棧頂物件,如果物件是一個生成器則直接返回,並且將它再壓入棧頂,如果不是則呼叫物件的 __iter__ 方法,將這個返回物件壓入棧頂。後續執行 YIELD_FROM 方法,就和前面的協程一樣了。

總結

在本篇文章當中簡單的介紹了一下協程是什麼以及在 CPython 當中協程是透過什麼方式實現的,從位元組碼的角度來看, 生成器和協程本質上使用的位元組碼是一樣的,都是使用 YIELD_FROM 位元組碼實現的,協程就是在生成器的基礎之上實現的。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。

相關文章