Python學習之路35-協程

VPointer發表於2019-03-04

《流暢的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的說明圖:

Python學習之路35-協程

結合上圖,可做如下總結:

  • yield fromyield在使用上並無太大區別;
  • 委派生成器也是生成器。當第一次對委派生成器呼叫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)將值傳給委派生成器時,如果valueNone,則呼叫子生成器的__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,然後當maingroup更新後,上一個grouper(也就是剛新建了averagergrouper)由於引用數為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 ~

Python學習之路35-協程

相關文章