非同步程式設計 101:Python async await發展簡史

年薪四千萬日元的長江發表於2019-04-29

本文參考了:

yield 和 yield from

先讓我們來學習或者回顧一下yieldyield from的用法。如果你很自信自己完成理解了,可以跳到下一部分。

Python3.3提出了一種新的語法:yield from

yield from iterator
複製程式碼

本質上也就相當於:

for x in iterator:
    yield x
複製程式碼

下面的這個例子中,兩個 yield from加起來,就組合得到了一個大的iterable(例子來源於官網3.3 release):

>>> def g(x):
...     yield from range(x, 0, -1)
...     yield from range(x)
...
>>> list(g(5))
[5, 4, 3, 2, 1, 0, 1, 2, 3, 4]
複製程式碼

理解 yield from對於接下來的部分至關重要。想要完全理解 yield from,還是來看看官方給的例子:

def accumulate():
    tally = 0
    while 1:
        next = yield
        if next is None:
            return tally
        tally += next


def gather_tallies(tallies):
    while 1:
        tally = yield from accumulate()
        tallies.append(tally)

tallies = []
acc = gather_tallies(tallies)
next(acc) # Ensure the accumulator is ready to accept values

for i in range(4):
    acc.send(i)
acc.send(None) # Finish the first tally

for i in range(5):
    acc.send(i)
acc.send(None) # Finish the second tally
print(tallies)
複製程式碼

我還專門為此錄製了一段視訊,你可以配合文字一起看,或者你也可以開啟 pycharm 以及任何除錯工具,自己除錯一下。 視訊連結

來一起 break down:

acc = gather_tallies(tallies)這一行開始,由於gather_tallies函式中有一個 yield,所以不會while 1立即執行(你從視訊中可以看到,acc 是一個 generator 型別)。

next(acc)

next()會執行到下一個 yield,或者報StopIteration錯誤。

next(acc)進入到函式體gather_tallies,gather_tallies中有一個yield from accumulate(),next(acc)不會在這一處停,而是進入到『subgenerator』accumulate裡面,然後在next = yield處,遇到了yield,然後暫停函式,返回。

for i in range(4):
    acc.send(i)
複製程式碼

理解一下 acc.send(value)有什麼用:

  • 第一步:回到上一次暫停的地方
  • 第二步:把value 的值賦給 xxx = yield 中的xxx,這個例子中就是next

accumulate函式中的那個while 迴圈,通過判斷next的值是不是 None 來決定要不要退出迴圈。在for i in range(4)這個for迴圈裡面,i 都不為 None,所以 while 迴圈沒有斷。但是,根據我們前面講的:next()會執行到下一個 yield的地方停下來,這個 while 迴圈一圈,又再次遇到了yield,所以他會暫停這個函式,把控制權交還給主執行緒。

理清一下:對於accumulate來說,他的死迴圈是沒有結束的,下一次通過 next()恢復他執行時,他還是在執行他的死迴圈。對於gather_tallies來說,他的yield from accumulate()也還沒執行完。對於整個程式來說,確實在主程式和accumulate函式體之間進行了多次跳轉。

接下來看第一個acc.send(None):這時next變數的值變成了Noneif next is None條件成立,然後返回tally給上一層函式。(計算一下,tally 的值為0 + 1 + 2 + 3 = 6)。這個返回值就賦值給了gather_tallies中的gally。這裡需要注意的是,gather_tallies的死迴圈還沒結束,所以此時呼叫next(acc)不會報StopIteration錯誤。

for i in range(5):
    acc.send(i)
acc.send(None) # Finish the second tally
複製程式碼

這一部分和前面的邏輯是一樣的。acc.send(i)會先進入gather_tallies,然後進入accumulate,把值賦給nextacc.send(None)停止迴圈。最後tally的值為10(0 + 1 + 2 + 3 + 4)。

最終tallies列表為:[6,10]

Python async await發展簡史

看一下 wikipedia 上 Coroutine的定義:

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed.

關鍵點在於by allowing execution to be suspended and resumed.(讓執行可以被暫停和被恢復)。通俗點說,就是:

coroutines are functions whose execution you can pause。(來自How the heck does async/await work in Python 3.5?

非同步程式設計 101:Python async await發展簡史

這不就是生成器嗎?

非同步程式設計 101:Python async await發展簡史

python2.2 - 生成器起源

Python生成器的概念最早起源於 python2.2(2001年)時剔除的 pep255,受Icon 程式語言啟發。

生成器有一個好處,不浪費空間,看下面這個例子:

def eager_range(up_to):
    """Create a list of integers, from 0 to up_to, exclusive."""
    sequence = []
    index = 0
    while index < up_to:
        sequence.append(index)
        index += 1
    return sequence
複製程式碼

如果用這個函式生成一個10W 長度的列表,需要等待 while 迴圈執行結束返回。然後這個sequence列表將會佔據10W 個元素的空間。耗時不說(從能夠第一次能夠使用到 sequence 列表的時間這個角度來看),佔用空間還很大。

藉助上一部分講的yield,稍作修改:

def lazy_range(up_to):
    """Generator to return the sequence of integers from 0 to up_to, exclusive."""
    index = 0
    while index < up_to:
        yield index
        index += 1
複製程式碼

這樣就只需要佔據一個元素的空間了,而且立即就可以用到 range,不需要等他全部生成完。

python2.5 : send stuff back

一些有先見之明的前輩想到,如果我們能夠利用生成器能夠暫停的這一特性,然後想辦法新增 send stuff back 的功能,這不就符合維基百科對於協程的定義了麼?

非同步程式設計 101:Python async await發展簡史

於是就有了pep342

pep342中提到了一個send()方法,允許我們把一個"stuff"送回生成器裡面,讓他接著執行。來看下面這個例子:

def jumping_range(up_to):
    """Generator for the sequence of integers from 0 to up_to, exclusive.

    Sending a value into the generator will shift the sequence by that amount.
    """
    index = 0
    while index < up_to:
        jump = yield index
        if jump is None:
            jump = 1
        index += jump


if __name__ == '__main__':
    iterator = jumping_range(5)
    print(next(iterator))  # 0
    print(iterator.send(2))  # 2
    print(next(iterator))  # 3
    print(iterator.send(-1))  # 2
    for x in iterator:
        print(x)  # 3, 4
複製程式碼

這裡的send把一個『stuff』送進去給生成器,賦值給 jump,然後判斷jump 是不是 None,來執行對應的邏輯。

python3.3 yield from

自從Python2.5之後,關於生成器就沒做什麼大的改進了,直到 Python3.3時提出的pep380。這個 pep 提案提出了yield from這個可以理解為語法糖的東西,使得編寫生成器更加簡潔:

def lazy_range(up_to):
    """Generator to return the sequence of integers from 0 to up_to, exclusive."""
    index = 0
    def gratuitous_refactor():
        nonlocal index
        while index < up_to:
            yield index
            index += 1
    yield from gratuitous_refactor()
複製程式碼

第一節我們已經詳細講過 yield from 了,這裡就不贅述了。

python3.4 asyncio模組

插播:事件迴圈(eventloop)

如果你有 js 程式設計經驗,肯定對事件迴圈有所瞭解。

理解一個概念,最好也是最有bigger的就是翻出 wikipedia:

an event loop "is a programming construct that waits for and dispatches events or messages in a program" - 來源於Event loop - wikipedia

簡單來說,eventloop 實現當 A 事件發生時,做 B 操作。拿瀏覽器中的JavaScript事件迴圈來說,你點選了某個東西(A 事件發生了),就會觸發定義好了的onclick函式(做 B 操作)。

在 Python 中,asyncio 提供了一個 eventloop(回顧一下上一篇的例子),asyncio 主要聚焦的是網路請求領域,這裡的『A 事件發生』主要就是 socket 可以寫、 socket可以讀(通過selectors模組)。

到這個時期,Python 已經通過Concurrent programming的形式具備了非同步程式設計的實力了。

Concurrent programming只在一個 thread 裡面執行。go 語言blog 中有一個非常不錯的視訊:Concurrency is not parallelism,很值得一看。

這個時代的 asyncio 程式碼

這個時期的asyncio程式碼是這樣的:

import asyncio

# Borrowed from http://curio.readthedocs.org/en/latest/tutorial.html.
@asyncio.coroutine
def countdown(number, n):
    while n > 0:
        print('T-minus', n, '({})'.format(number))
        yield from asyncio.sleep(1)
        n -= 1

loop = asyncio.get_event_loop()
tasks = [
    asyncio.ensure_future(countdown("A", 2)),
    asyncio.ensure_future(countdown("B", 3))]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
複製程式碼

輸出結果為:

T-minus 2 (A)
T-minus 3 (B)
T-minus 1 (A)
T-minus 2 (B)
T-minus 1 (B)
複製程式碼

這時使用的是asyncio.coroutine修飾器,用來標記某個函式可以被 asyncio 的事件迴圈使用。

看到yield from asyncio.sleep(1)了嗎?通過對一個asyncio.Future object yield from,就把這個future object 交給了事件迴圈,當這個 object 在等待某件事情發生時(這個例子中就是等待 asyncio.sleep(1),等待 1s 過後),把函式暫停,開始做其他的事情。當這個future object 等待的事情發生時,事件迴圈就會注意到,然後通過呼叫send()方法,讓它從上次暫停的地方恢復執行。

break down 一下上面這個程式碼:

事件迴圈開啟了兩個countdown()協程呼叫,一直執行到yield from asyncio.sleep(1),這會返回一個 future object,然後暫停,接下來事件迴圈會一直監視這兩個future object。1秒過後,事件迴圈就會把 future object send()給coroutine,coroutine又會接著執行,列印出T-minus 2 (A)等。

python3.5 async await

python3.4的

@asyncio.coroutine
def py34_coro():
    yield from stuff()
複製程式碼

到了 Python3.5,可以用一種更加簡潔的語法表示:

async def py35_coro():
    await stuff()
複製程式碼

這種變化,從語法上面來講並沒什麼特別大的區別。真正重要的是,是協程在 Python 中哲學地位的提高。 在 python3.4及之前,非同步函式更多就是一種很普通的標記(修飾器),在此之後,協程變成了一種基本的抽象基礎型別(abstract base class):class collections.abc.Coroutine

How the heck does async/await work in Python 3.5?一文中還講到了asyncawait底層 bytecode 的實現,這裡就不深入了,畢竟篇幅有限。

非同步程式設計 101:Python async await發展簡史

把 async、await看作是API 而不是 implementation

Python 核心開發者(也是我最喜歡的 pycon talker 之一)David M. Beazley在PyCon Brasil 2015的這一個演講中提到:我們應該把 asyncawait看作是API,而不是實現。 也就是說,asyncawait不等於asyncioasyncio只不過是asyncawait的一種實現。(當然是asyncio使得非同步程式設計在 Python3.4中成為可能,從而推動了asyncawait的出現)

他還開源了一個專案github.com/dabeaz/curi…,底層的事件迴圈機制和 asyncio 不一樣,asyncio使用的是future objectcurio使用的是tuple。同時,這兩個 library 有不同的專注點,asyncio 是一整套的框架,curio則相對更加輕量級,使用者自己需要考慮到事情更多。

How the heck does async/await work in Python 3.5?此文還有一個簡單的事件迴圈實現例子,有興趣可以看一下,後面有時間的話也許會一起實現一下。

總結一下

  • 協程只有一個 thread。
  • 作業系統排程程式、協程用事件迴圈排程函式。
  • async、await 把協程在 Python 中的哲學地位提高了一個檔次。

最重要的一點感受是:Nothing is Magic。現在你應該能夠對 Python 的協程有了在整體上有了一個把握。

非同步程式設計 101:Python async await發展簡史

如果你像我一樣真正熱愛電腦科學,喜歡研究底層邏輯,歡迎關注我的微信公眾號:

非同步程式設計 101:Python async await發展簡史

相關文章