Python | 詳解Python中的協程,為什麼說它的底層是生成器?

TechFlow2019發表於2020-09-02

今天是Python專題的第26篇文章,我們來聊聊Python當中的協程。

我們曾經在golang關於goroutine的文章當中簡單介紹過協程的概念,我們再來簡單review一下。協程又稱為是微執行緒,英文名是Coroutine。它和執行緒一樣可以排程,但是不同的是執行緒的啟動和排程需要通過作業系統來處理。並且執行緒的啟動和銷燬需要涉及一些作業系統的變數申請和銷燬處理,需要的時間比較長。而協程呢,它的排程和銷燬都是程式自己來控制的,因此它更加輕量級也更加靈活。

協程有這麼多優點,自然也會有一些缺點,其中最大的缺點就是需要程式語言自己支援,否則的話需要開發者自己通過一些方法來實現協程。對於大部分語言來說,都不支援這一機制。go語言由於天然支援協程,並且支援得非常好,使得它廣受好評,短短几年時間就迅速流行起來。

對於Python來說,本身就有著一個GIL這個巨大的先天問題。GIL是Python的全域性鎖,在它的限制下一個Python程式同一時間只能同時執行一個執行緒,即使是在多核心的機器當中。這就大大影響了Python的效能,尤其是在CPU密集型的工作上。所以為了提升Python的效能,很多開發者想出了使用多程式+協程的方式。一開始是開發者自行實現的,後來在Python3.4的版本當中,官方也收入了這個功能,因此目前可以光明正大地說,Python是支援協程的語言了。

生成器(generator)

生成器我們也在之前的文章當中介紹過,為什麼我們介紹協程需要用到生成器呢,是因為Python的協程底層就是通過生成器來實現的

通過生成器來實現協程的原因也很簡單,我們都知道協程需要切換掛起,而生成器當中有一個yield關鍵字,剛好可以實現這個功能。所以當初那些自己在Python當中開發協程功能的程式設計師都是通過生成器來實現的,我們想要理解Python當中協程的運用,就必須從最原始的生成器開始。

生成器我們很熟悉了,本質上就是帶有yield這個關鍵詞的函式。

def test():
    n = 0
    while n < 10:
        val = yield n 
        print('val = {}'.format(val))
        n += 1

這個函式當中如果沒有yield這個語句,那麼它就是一個普通的Python函式。加上了val = yield n這個語句之後,它有什麼變化呢?

我們嘗試著執行一下:

# 呼叫test函式獲得一個生成器
g = test()
print(next(g))
print(next(g))
print(next(g))

得到這麼一個結果:

image-20200810104610713
image-20200810104610713

輸出的0,1,2很好理解,就是通過next(g)返回的,這個也是生成器的標準用法。奇怪的是為什麼val=None呢?val不應該等於n麼?

這裡想不明白是正常的,因為這裡涉及到了一個新的用法就是生成器的send方法。當我們在yield語句之前加上變數名的時候,它的含義其實是返回yield之後的內容,再從外界接收一個變數。也就是說當我們執行next(g)的時候,會從獲取yield之後的數,當我們執行g.send()時,傳入的值會被賦值給yield之前的數。比如我們把執行的程式碼改成這樣:

g = test()
print(next(g))
g.send('abc')
print(next(g))
print(next(g))

我們再來看執行的結果,會發現是這樣的:

第一行val不再是None,而是我們剛剛傳入的abc了。

佇列排程

生成器每次在執行到yield語句之後都會自然掛起,我們可以利用這一點來當做協程來排程。我們可以自己實現一個簡易的佇列來模擬這個過程。

首先我們宣告一個雙端佇列,每次從佇列左邊頭部獲取任務,排程執行到掛起之後,放入到佇列末尾。相當於我們用迴圈的方式輪詢執行了所有任務,並且這整個全程不涉及任何執行緒建立和銷燬的過程。

class Scheduler:
    def __init__(self):
        self._queue = deque()

    def new_task(self, task):
        self._queue.append(task)

    def run(self):
        while self._queue:
            # 每次從佇列左側獲取task
            task = self._queue.popleft()
            try:
                # 通過next執行之後放入佇列右側
                next(task)
                self._queue.append(task)
            except StopIteration:
                pass



sch = Scheduler()
sch.new_task(test(5))
sch.new_task(test(10))
sch.new_task(test(8))
sch.run()

這個只是一個很簡易的排程方法,事實上結合上yield from以及send功能,我們還可以實現出更加複雜的協程排程方式。但是我們也沒有必要一一窮盡,只需要理解最基礎的方法就可以了,畢竟現在我們使用協程一般也不會自己實現了,都會通過官方原生的工具庫來實現。

@asyncio.coroutine

在Python3.4之後的版本當中,我們可以通過@asyncio.coroutine這個註解來將一個函式封裝成協程執行的生成器。

在吸收了協程這個概念之後,Python對生成器以及協程做了區分。加上了@asyncio.coroutine註解的函式稱為協程函式,我們可以用iscoroutinefunction()方法來判斷一個函式是不是協程函式,通過這個協程函式返回的生成器物件稱為協程物件,我們可以通過iscoroutine方法來判斷一個物件是不是協程物件。

比如我把剛剛寫的函式上加上註解之後再來執行這兩個函式都會得到True:

import asyncio

@asyncio.coroutine
def test(k):
    n = 0
    while n < k:
        yield
        print('n = {}'.format(n))
        n += 1
        
print(asyncio.iscoroutinefunction(test))
print(asyncio.iscoroutine(test(10)))

那我們通過註解將方法轉變成了協程之後,又該怎麼使用呢?

一個比較好的方式是通過asynio庫當中提供的loop工具,比如我們來看這麼一個例子:

loop = asyncio.get_event_loop()
loop.run_until_complete(test(10))
loop.close()

我們通過asyncio.get_event_loop函式建立了一個排程器,通過排程器的run相關的方法來執行一個協程物件。我們可以run_until_complete也可以run_forever,具體怎麼執行要看我們實際的使用場景。

async,await和future

從Python3.5版本開始,引入了async,await和future。我們來簡單說說它們各自的用途,其中async其實就是@asyncio.coroutine,用途是完全一樣的。同樣await代替的是yield from,意為等待另外一個協程結束。

我們用這兩個一改,上面的程式碼就成了:

async def test(k):
    n = 0
    while n < k:
        await asyncio.sleep(0.5)
        print('n = {}'.format(n))
        n += 1

由於我們加上了await,所以每次在列印之前都會等待0.5秒。我們把await換成yield from也是一樣的,只不過用await更加直觀也更加貼合協程的含義。

Future其實可以看成是一個訊號量,我們建立一個全域性的future,當一個協程執行完成之後,將結果存入這個future當中。其他的協程可以await future來實現阻塞。我們來看一個例子就明白了:

future = asyncio.Future()

async def test(k):
    n = 0
    while n < k:
        await asyncio.sleep(0.5)
        print('n = {}'.format(n))
        n += 1
    future.set_result('success')

async def log():
    result = await future
    print(result)


loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait([
    log(),
    test(5)
]))

loop.close()

在這個例子當中我們建立了兩個協程,第一個協程是每隔0.5秒print一個數字,在print完成之後把success寫入到future當中。第二個協程就是等待future當中的資料,之後print出來。

在loop當中我們要排程執行的不在是一個協程物件了而是兩個,所以我們用asyncio當中的wait將這兩個物件包起來。只有當wait當中的兩個物件執行結束,wait才會結束。loop等待的是wait的結束,而wait等待的是傳入其中的協程的結束,這就形成了一個依賴迴圈,等價於這兩個協程物件結束,loop才會結束。

總結

async並不止是可以用在函式上,事實上還有很多其他的用法,比如用在with語句上,用在for迴圈上等等。這些用法比較小眾,細節也很多,就不一一展開了,大家感興趣的可以自行去了解一下。

不知道大家在讀這篇文章的過程當中有沒有覺得有些費勁,如果有的話,其實是很正常的。原因也很簡單,因為Python原生是不支援協程這個概念的,所以在一開始設計的時候也沒有做這方面的準備,是後來覺得有必要才加入的。那麼作為後面加入的內容,必然會對原先的很多內容產生影響,尤其是協程藉助了之前生成器的概念來實現的,那麼必然會有很多耦合不清楚的情況。這也是這一塊的語法很亂,對初學者不友好的原因。

我建議大家可以先了解一下go語言當中的協程的概念和用法再來學習Python當中的async的用法,很多不明白的地方會清晰很多。

今天的文章到這裡就結束了,如果喜歡本文的話,請來一波素質三連,給我一點支援吧(關注、轉發、點贊)。

- END -

原文連結,求個關注

{{uploading-image-175535.png(uploading...)}}

相關文章