《流暢的Python》筆記。
本篇主要討論一個與生成器看似無關,但實際非常相關的概念:協程。
1. 前言
說到協程(Coroutine),如果是剛接觸Python不久的新手,估計第一個反應是:懵逼,這是個什麼玩意兒?有一點基礎的小夥伴可能會想到程式和執行緒。
其實,和子程式(或者說函式)一樣,協程也是一種程式元件。Donald Knuth曾經說過,子程式是協程的特例。我們都知道,一個子程式就是一次函式呼叫,它只有一個入口和一個出口:呼叫者呼叫子程式,子程式執行完畢,將結果返回給呼叫者。而協程則是多入口和多出口的子程式:呼叫者可以不止一個,執行過程中可以暫停,輸出結果也可以不止一個。
協程和程式、執行緒也是有關係的:為了實現併發,高效利用系統資源,於是有了程式;為了實現更高的併發,以及減小程式切換時的上下文開銷,於是有了執行緒;但即便執行緒切換時的開銷小了,如果執行緒數量一多(比如10K個),這時的上下文切換也不可小覷,於是線上程中加入了協程(這裡之所以是“加入”,是因為協程的概念出現得比執行緒要早)。協程執行在一個執行緒當中,不會發生執行緒的切換,並且,它的啟停可以由使用者自行控制。由於協程在一個執行緒中執行,所以在共享資源時不需要加鎖。
補充:以後有機會單獨出一篇詳細介紹程式、執行緒和協程的文章。
2. 迭代器、生成器和協程
這三者本不應該放在一起,之所以放在一起,是因為生成器將迭代器和協程聯絡了起來,或者說yield
關鍵字將這三者聯絡了起來:生成器可以作為迭代器,生成器又是協程比不可少的組成部分。但千萬不要把迭代器用作協程,也別把協程用作迭代器!這兩者並不應該存在關係。
yield
關鍵字背後的機制很強大,它不僅能向使用者提供資料,還能從使用者那裡獲取資料。而迭代器、生成器和協程這三個概念其實是對yield
關鍵字用法的取捨:
- 凡是含有關鍵字
yield
或者yield from
的函式都是生成器,不管你是用來幹啥; - 如果只是用
yield
來生成資料,或者說向使用者提供資料,那麼這個生成器可以看做迭代器(用作迭代器的生成器); - 如果還想用
yield
來獲取外部的資料,實現雙向資料交換,那麼這個生成器可看做協程(用作協程的生成器)。
這裡先列舉出迭代器和協程在程式碼上最直觀的區別:
def my_iter(): # 用作迭代器的生成器
yield 1; # 作為迭代器,yield關鍵字後面會跟一個資料
yield 2; # 且不關心yield的返回值,沒有賦值語句
def my_co(): # 用作協程的生成器
x = yield # 這種寫法表示希望從使用者處獲取資料,而不向使用者提供資料(其實提供的是None)
y = yield 1 # 這種寫法表示既向使用者提供資料,也希望得到使用者的反饋
複製程式碼
3. 協程
本節主要包括協程的執行過程,協程的4個狀態,協程的預激,協程的終止和異常處理,協程的返回值。
3.1 協程的執行
協程本身有4個狀態(其實就是生成器的4個狀態),可以使用inspect.getgeneratorstate()
函式來確定:
GEN_CREATED
:等待開始執行;GEN_RUNNING
:直譯器正在執行,多執行緒時能看到這個狀態;GEN_SUSPENDED
:在yield
表示式處暫停時的狀態;GEN_CLOSED
:執行結束。
下面通過一個簡單的例子來說明這四個狀態以及協程的執行過程:
>>> def simple_coro(a):
... print("Started a =", a)
... b = yield a
... print("Received b =", b)
... c = yield a + b
... print("End with c=", c)
...
>>> from inspect import getgeneratorstate
>>> my_coro = simple_coro(1)
>>> getgeneratorstate(my_coro)
'GEN_CREATED' # 剛建立的協程所處的狀態,這時協程還沒有被啟用
>>> next(my_coro) ### 第一次呼叫next()叫做預激,這一步非常重要! ###
Started a = 1
1
>>> >>> getgeneratorstate(my_coro)
'GEN_SUSPENDED' # 在yield表示式處暫停時的狀態
>>> my_coro.send(2) # 通過.send()方法將使用者的資料傳給協程
Received b = 2
3
>>> my_coro.send(3)
End with c= 3
Traceback (most recent call last):
File "<input>", line 1, in <module>
StopIteration # 協程(生成器)結束,丟擲StopIteration
>>> getgeneratorstate(my_coro)
'GEN_CLOSED' # 協程結束後的狀態
複製程式碼
解釋:
- 剛建立的協程並沒有啟用,對協程的第一次
next()
呼叫就是預激,這一步非常重要,它將執行到第一yield
表示式處並暫停。對於沒有預激的協程,在呼叫.send(value)
時,如果value
不是None
,直譯器將丟擲異常。對於預激,既可以呼叫next()
函式,也可以.send(None)
(此時會被特殊處理)。但對於yield from
來說則不用預激,它會自動預激。 .send()
方法實現了使用者和協程的互動。yield
是一個表示式(上述程式碼中等號的右邊),它的預設返回值是None
,如果使用者通過.send(value)
傳入了引數value
,那麼這個值將作為協程暫停處的yield
表示式的返回值。- 協程的執行過程:也可以叫做生成器的執行過程。從上一篇中我們知道,呼叫
next()
函式或.send()
方法時,協程會執行到下一個yield
表示式處並暫停。具體來說,比如上述程式碼中的b = yield a
,程式碼其實是停在等號的右邊,yield a
這個表示式還沒有返回,只是把a
傳給了使用者,但還沒有計算出yield a
表示式的返回值,b
因此也沒有被賦值。當程式碼再次執行時,等號右邊的yield a
表示式才返回值,並將這個值賦給b
。如果通過next()
函式讓協程繼續執行,則上一個暫停處的**yield
表示式將返回預設值**None
(b = None
);如果通過.send(value)
讓協程繼續執行,則上一個yield
表示式將返回value
(b = value
)。這也解釋了為什麼要預激協程:如果沒有預激,也就沒有yield
表示式與傳入的value
相對應,自然也就丟擲異常。
3.2 終止協程和異常處理
協程中沒處理的異常會向上冒泡,傳給next()
函式或.send()
方法的呼叫方。不過,我們也可以通過.throw()
方法手動丟擲異常,還可以通過.close()
方法手動結束協程:
generator.throw(exc_type[, exc_value[, traceback]])
:讓生成器在暫停的yield
表示式處丟擲指定的異常。如果生成器處理了這個異常,程式碼會向前執行到下一個yield
表示式yield a
,並將生成的a
作為generator.throw()
的返回值。如果生成器沒有處理丟擲的異常,則會向上冒泡,並且生成器會終止,狀態轉換成GEN_CLOSED
。generator.close()
:使生成器在暫停處的yield
表示式處丟擲GeneratorExit
異常。如果生成器沒有處理這個異常,或者處理時丟擲了StopIteration
異常,.close()
方法直接返回,且不報錯;如果處理GeneratorExit
時丟擲了非StopIteration
異常,則向上冒泡。
3.3 返回值
從上一篇和本篇的程式碼中,不知道大家發現了一個現象沒有:所有的生成器最後都沒有寫return
語句。這其實是有原因的,因為在Python3.3之前,如果生成器返回值,直譯器會報語法錯誤。現在則不會報錯了,但返回的值並不是像普通函式那樣可以直接接收:Python直譯器會把這個返回值繫結到生成器最後丟擲的StopIteration
異常物件的value
屬性中。示例如下:
>>> def test():
... yield 1
... return "This is a test"
...
>>> t = test()
>>> next(t)
1
>>> next(t)
Traceback (most recent call last):
File "<input>", line 1, in <module>
StopIteration: This is a test # StopIteration有了附加資訊
>>> t = test()
>>> next(t)
1
>>> try:
... next(t)
... except StopIteration as si:
... print(si.value) # 獲取返回的值
...
This is a test
複製程式碼
3.4 預激協程的裝飾器
從前文我們知道,如果要使用協程,必須要預激。可以手動通過呼叫next()
函式或者.send(None)
方法。但有時我們會忘記手動預激,此時,我們可以使用裝飾器來自動預激協程,這個裝飾器如下:
from functools import wraps
def coroutine(func):
@wraps(func)
def primer(*args, **kwargs):
gen = func(*args, **kwargs)
next(gen)
return gen
return primer
複製程式碼
提前預激的生成器只能和yield
相容,不能和yield from
相容,因為yield from
會自動預激。所以請確定你的生成器要不要被放在yield from
之後。
4. yield from
上一篇文章說到,對於巢狀生成器,使用yield from
能減少很多程式碼,比如:
def y2():
def y1(): # y1只要是個可迭代物件就行
yield 1
yield 2
# 第一種寫法
for y in y1():
yield y
# 第二種寫法
# yield from y1()
if __name__ == "__main__":
for y in y2():
print(y)
複製程式碼
第二種寫法明顯比第一種簡潔。這是yield from
的一個作用:簡化巢狀迴圈。yield from
後面還可以跟任意可迭代物件,並不是只能跟生成器。
yield from
最重要的作用是起到了類似通道的作用:它能讓客戶端程式碼和子生成器之間進行資料交換。
這裡有幾個術語需要先解釋一下:
- 委派生成器:包含
yield from <iterable>
表示式的生成器函式。 - 子生成器:上述的
<iterable>
部分就是子生成器。<iterable>
也可以是委派生成器,以此類推下去,形成一個鏈條,但這個鏈條最終以一個只使用yield
表示式的簡單生成器結束。 - 呼叫方:呼叫委派生成器的程式碼或物件叫做呼叫方。為了避免歧異,我們把最外層的程式碼,也就是呼叫第一層委派生成器的程式碼叫做客戶端程式碼。
比如上述程式碼,按照沒有yield from
語句的寫法,如果客戶端程式碼想通過y2.send(value)
向y1
傳值,value
只能傳到y2
這一層,如果想再傳入y1
,將要寫大量複雜的程式碼。下面是yield from
的說明圖:
結合上圖,可做如下總結:
yield from
和yield
在使用上並無太大區別;- 委派生成器也是生成器。當第一次對委派生成器呼叫
next()
或.send(None)
時,委派生成器會執行到第一個yield from
表示式並暫停。當客戶端繼續呼叫委派生成器的.send()
,.throw()
和.close()
等方法時,會“直接”作用到最內層的子生成器上,而不是讓委派生成器的程式碼繼續向前執行。只有當子生成器丟擲StopIteration
異常後,委派生成器中的程式碼才繼續執行,並將StopIteration.value
的值作為yield from
表示式的返回值。
補充(可跳過)
這一小節是yield from
的邏輯虛擬碼實現,程式碼較為複雜,看不懂也沒什麼關係,可以跳過,也可直接看最後的總結,並不影響yield from
的使用。
### "RESULT = yield from EXPR"語句的等效程式碼
_i = iter(EXPR) # 得到EXPR的迭代器
try:
_y = next(_i) # 預激!還沒有向客戶端生成值
except StopIteration as _e: # 如果_i丟擲了StopIteration異常
_r = _e.value # _i的最後的返回值。這不是最後的生成值!
else: # 如果呼叫next(_i)一切正常
while 1: # 這是一個無限迴圈
try:
_s = yield _y # 向客戶端傳送子生成器生成的值,然後暫停
except GeneratorExit as _e: # 如果客戶端呼叫.throw(GeneratorExit),或者呼叫close方法
try: # 首先嚐試獲取_i的close方法,因為_i不一定是生成器,普通迭代器不會實現close方法
_m = _i.close
except AttributeError:
pass # 沒有獲取到close方法,什麼也不做
else:
_m() # 如果獲取到了close方法,則呼叫子生成器的close方法
raise _e # 最後不管怎樣,都向上丟擲GeneratorExit異常
except BaseException as _e: # 如果客戶端通過throw()傳入其它異常
_x = sys.exc_info() # 獲取錯誤資訊
try: # 嘗試獲取_i的throw方法,理由和上面的情況一樣
_m = _i.throw
except AttributeError: # 如果沒有這個方法
raise _e # 則向上丟擲使用者傳入的異常
else: # 如果_i有throw方法,即它是一個子生成器
try:
_y = _m(*_x) # 嘗試呼叫子生成器的throw方法
except StopIteration as _e:
_r = _e.value # 如果子生成器丟擲StopIteration,獲取返回的值
break # 並且跳出迴圈
else: # 如果在生成器生成值時沒有異常發生
try: # 試驗證使用者通過.send()方法傳入的值
if _s is None: # 如果傳入的是None
_y = next(_i) # 則嘗試呼叫next(),向前繼續執行
else: # 如果傳入的不是None,則嘗試呼叫子生成器的send方法
_y = _i.send(_s)
# 如果子生成器沒有send方法,則向上報AttributeError
except StopIteration as _e: # 如果子生成器丟擲了StopIteration
_r = _e.value # 獲取子生成器返回的值
break # 並跳出迴圈,回覆委派生成器的執行
RESULT = _r # _r就是yield from EXPR最終的返回值,將其賦予RESULT
複製程式碼
從上面這麼長一串程式碼可以看出,如果沒有yield from
,而我們又想向最內層的子生成器傳值,這得多麻煩。下面總結出幾點yield from
的特性:
- 所有的“直接”其實都是間接的,都是一層一層傳下去,或者一層一層傳上來的,只是我們感覺是直接的而已;
- 呼叫
.send(value)
將值傳給委派生成器時,如果value
是None
,則呼叫子生成器的__next__
方法;否則,呼叫子生成器的.send(value)
; - 當對委派生成器呼叫
.throw()
,委派生成器會先確定子生成器有沒有.throw()
方法,如果有,則呼叫,如果沒有,則向上丟擲AttributeError
異常; - 當客戶端呼叫委派生成器的
.throw(GeneratorExit)
或者.close()
方法時,委派生成器也會先確定子生成器有沒有.close()
方法,如果有,則呼叫子生成器的.close()
方法,由子生成器來丟擲GeneratorExit
異常,委派生成器將這個異常向上傳遞;如果子類沒有.close()
方法,則委派生成器直接丟擲GeneratorExit
異常。Python直譯器會捕獲這個異常,但不會顯示異常資訊。 - 只要子生成器丟擲
StopIteration
異常,不管是使用者通過.throw
方法傳遞的,還是子生成器執行結束時丟擲的,都會導致委派生成器繼續向前執行。
5. 協程計算均值
在《Python學習之路26》中,我們分別用類和閉包來實現了平均值的計算,現在,作為本章最後一個例子,我們使用協程來實現平均值的計算,其中還會用到yield from
和生成器的返回值:
import inspect
def averager():
total = 0.0
count = 0
average = None
while True:
term = yield
if term is None:
break
total += term
count += 1
average = total / count
return average
def grouper(results, key):
while True: # 每個迴圈都會新建averager
results[key] = yield from averager()
def main(data):
results = {}
for key, values in data.items():
group = grouper(results, key) # 每個迴圈都會新建grouper
next(group) # 啟用
for value in values:
group.send(value)
# 此句非常重要,否則不會執行到averager()中的return語句,也就得不到最終的返回值
group.send(None)
print(results)
data = {"list1": [1, 2, 3, 4, 5], "list2": [6, 7, 8, 9, 10]}
if __name__ == "__main__":
main(data)
# 結果:
{'list1': 3.0, 'list2': 8.0}
複製程式碼
不知道大家看到這段程式碼的時候有沒有什麼疑問。當筆者看到grouper()
委派生成器裡的While True:
時,非常疑惑:為啥要加個While
迴圈呢?如果按這個版本,我們在main
中的for
迴圈後檢測group
的狀態,會發現它是GEN_SUSPENDED
,這筆者的強迫症就犯了,怎麼能不是GEN_CLOSED
呢?!而且這個版本每當執行完group.send(None)
後,在grouper()
中又會建立新的averager
,然後當main
中group
更新後,上一個grouper
(也就是剛新建了averager
的grouper
)由於引用數為0,又被回收了。剛新建一個averager
就被回收,這不多此一舉嗎?於是筆者將程式碼改成了如下形式:
def grouper(results, key): # 去掉了迴圈
results[key] = yield from averager()
def main(data):
results = {}
for key, values in data.items():
-- snip --
try: # 手動捕獲異常
group.send(None)
except StopIteration:
continue
複製程式碼
寫出來後發現程式碼並沒有之前的簡潔,但至少group
最後變成了GEN_CLOSED
狀態。至於最後怎麼取捨就看各位了。
迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~