1. 程式人生 > >[翻譯] Python 3.5中async/await的工作機制

[翻譯] Python 3.5中async/await的工作機制

sum con trigger color 調用 普通 計算機程序 mom issue

Python 3.5中async/await的工作機制

多處翻譯出於自己理解,如有疑惑請參考原文 原文鏈接

身為Python核心開發組的成員,我對於這門語言的各種細節充滿好奇。盡管我很清楚自己不可能對這門語言做到全知全能,但哪怕是為了能夠解決各種issue和參與常規的語言設計工作,我也覺得有必要試著接觸和理解Python的內核,弄清楚在底層它是怎麽工作的。

話雖如此,直到最近我才理解了Python3.5中async/await的工作機制。在此之前,對於async/await語法,我只知道Python3.3中的yield from和Python3.4中的asyncio讓這個新語法得以在Python3.5中實現。由於日常工作中沒有接觸多少網絡編程--asyncio

的主要應用領域,雖然它可以做的遠不止於此--我對async/await並沒有關註太多。以代碼來說,我知道:

yield from iterator

(大體)等價於:

from x in iterator:
    yield x

而且我知道asyncio是個事件循環的框架,支持異步編程,還有這些術語所表示的(基本)意義。但未曾真正的深入研究async/await語法,分析從最基礎的指令到實現代碼語法功能的過程,我覺得並沒有理解Python中的異步編程,這一點甚至讓我心煩意亂。因此我決定花點時間弄明白這個語法的工作機制。鑒於我聽到許多人說他們也不理解異步編程的工作機制,我寫出了這篇論文(是的,這篇博文耗費時間之長,字數之多,讓我妻子把它叫做論文)。

由於我希望對這個語法的工作機制有一個完整的理解,這篇論文中會出現涉及CPython的底層技術細節。如果你不關心這些細節,或者無法通過這篇文章完全理解這些細節--限於篇幅,我不可能詳細解釋CPython的每個細節,否則這篇文章就要變成一本書了(例如,如果你不知道代碼對象具有標識位,那就別在意代碼對象是什麽,這不是這篇文章的重點)--那也沒什麽關系。在每個章節的最後,我都添加了一個概念明確的小結,因此如果你對某個章節的內容不感興趣,那麽可以跳過前面的長篇大論,直接閱讀結論。

Python中協程(coroutine)的歷史

根據維基百科,“協程是將多個低優先級的任務轉換成統一類型的子任務,以實現在多個節點之間停止或喚醒程序運行的程序模塊”。這句專業論述翻譯成通俗易懂的話就是,“協程就是可以人為暫停執行的函數”。如果你覺得,“這聽起來像是生成器(generators)”,那麽你是對的。

生成器的概念在Python2.2時的PEP 255中(由於實現了遍歷器的協議,生成器也被成為生成器遍歷器)第一次被引入。主要受到了Icon語言的影響,生成器允許用戶創建一個特殊的遍歷器,在生成下一個值時,不會占用額外的內存,並且實現方式非常簡單(當然,在自定義類中實現__iter__()__next__()方法也可以達到不存儲遍歷器中所有值的效果,但也帶來了額外的工作量)。舉例來說,如果你想實現自己的range()函數,最直接的方式是創建一個整數數組:

def eager_range(up_to):
    """創建一個從0到變量up_to的數組,不包括up_to"""
    sequence = []
    index = []
    while index < up_to:
        sequence.append(index)
        index += 1
    return sequence

簡單直白,但這個函數的問題是,如果你需要的序列很大,比如0到一百萬,你必須創建一個包含了所有整數的長度是一百萬的數組。如果使用生成器,你就可以毫不費力的創建一個從0到上限前一個整數的生成器。所占用的內存也只是每次生成的一個整數。

def lazy_range(up_to):
    """一個從0到變量up_to,不包括up_to的生成器"""
    index = 0
    while index < up_to:
        yield index
        index += 1

函數可以在遇到yield表達式時暫停執行--盡管yield直到Python2.5才出現--然後在下次被調用時繼續執行,這種特性對於節約內存使用有意義深遠,可以用於實現無限長度的序列。

也許你已經註意到了,生成器所操作的都是遍歷器。多一種更好的創建遍歷器的語法的確不錯(當你為一個對象定義__iter__()方法作為生成器時,也會收到類似的提升),但如果我們把生成器的“暫停”功能拿出來,再加上“把事物傳進去”的功能,Python就有了自己的協程功能(暫且把這個當成Python的一個概念,真正的Python中的協程會在後面詳細討論)。Python 2.5中引入了把對象傳進一個被暫停的生成器的功能,這要歸功於PEP 342。拋開與本文無關的內容不看,PEP 342引入了生成器的send()方法。這樣就不光可以暫停生成器,更可以在生成器停止時給它傳回一個值。在上文range()函數的基礎上更近一步,你可以讓函數產生的序列前進或後退:

def jumping_range(up_to):
    """一個從0到變量up_to,不包括up_to的生成器
    傳入生成器的值會讓序列產生對應的位移
    """
    index = 0
    while index < up_to:
        jump = yield index
        if jump is not 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

直到Python 3.3中PEP 380引入yield from之前,生成器都沒有太大的變化。嚴格的說,yield from讓用戶可以輕松便捷的從遍歷器(生成器最常見的應用場景)裏提取每一個值,進而重構生成器。

def lazy_range(up_to):
    """一個從0到變量up_to,不包括up_to的生成器"""
    index = 0
    def gratuitous_refactor():
        nonlocal index
        while index < up_to:
            yield index
            index += 1
    yield from gratuitous_refactor()

同樣出於簡化重構操作的目的,yield from也支持將生成器串連起來,這樣再不同的調用棧之間傳遞值時,不需要對原有代碼做太大的改動。

def bottom():
    """返回yield表達式來允許值通過調用棧進行傳遞"""
    return (yield 42)

def middle():
    return (yield from bottom())

def top():
    return (yield from middle())

# 獲取生成器
gen = top()
value = next(gen)
print(value)  # Prints ‘42‘

try:
    value = gen.send(value * 2)
except StopIteration as exc:
    print("Error!")  # Prints ‘Error!‘
    value = exc.value
print(value)  # Prints ‘84‘

總結

Python2.2引入的生成器使代碼的執行可以被暫停。而在Python2.5中引入的允許傳值給被暫停的生成器的功能,則讓Python中協程的概念成為可能。在Python3.3中引入的yield from讓重構和連接生成器變得更加簡單。

事件循環是什麽?

如果你想理解async/await語法,那麽理解事件循環的定義,知道它如何支持的異步編程,是不可或缺的基礎知識。如果你曾經做過GUI編程--包括網頁前端工作--那麽你已經接觸過事件循環了。但在Python的語言體系中,異步編程的概念還是第一次出現,所以如果不知道事件循環是什麽,也情有可原。

讓我們回到維基百科,事件循環是“在程序中等待、分發事件或消息的編程結構”。簡而言之,事件循環的作用是,“當A發生後,執行B”。最簡單的例子可能是每個瀏覽器中都有的JavaScript事件循環,當你點擊網頁某處("當A發生後"),點擊事件被傳遞給JavaScript的事件循環,然後事件循環檢查網頁上該位置是否有註冊了處理這次點擊事件的onclick回調函數("執行B")。如果註冊了回調函數,那麽回調函數就會接收點擊事件的詳細信息,被調用執行。事件循環會不停的收集發生的事件,循環已註冊的事件操作來找到對應的操作,因此被稱為“循環”。

Python標準庫中的asyncio庫可以提供事件循環。asyncio在網絡編程裏的一個重要應用場景,就是以連接到socket的I/O準備好讀/寫(通過selector模塊實現)事件作為事件循環中的“當A發生後”事件。除了GUI和I/O,事件循環也經常在執行多線程或多進程代碼時充當調度器(例如協同式多任務處理)。如果你知道Python中的GIL(General Interpreter Lock),事件循環在規避GIL限制方面也有很大的作用。

總結

事件循環提供了一個讓你實現“當事件A發生後,執行事件B”功能的循環。簡單來說,事件循環監視事件的發生,如果發生的是事件循環關心(“註冊”過)的事件,那麽事件循環會執行所有被關聯到該事件的操作。在Python3.4中加入標準庫的asyncio使Python也有了事件循環。

asyncawait是怎麽工作的

在Python3.4中的工作方式

在Python3.3推動生成器的發展和Python3.5中事件循環以asyncio的形式出現之間,Python3.4以並發編程的形式實現了異步編程。從本質上說,異步編程就是無法預知執行時間的計算機程序(也就是異步,而非同步)。並發編程的代碼即使運行在同一個線程中,執行時也互不幹擾(並發不是並行)。例如,以下Python3.4的代碼中,並發兩個異步的函數調用,每秒遞減計數,互不幹擾。

import asyncio

# Borrowed from http://curio.readthedocs.org/en/latest/tutorial.html.

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()

在Python3.4中,asyncio.coroutine裝飾器被用於修飾使用asyncio庫並且作為協程在它的事件循環中運行的函數。這是Python中第一次出現明確的協程定義:一種實裝了PEP 342中添加給生成器的方法,基類是抽象類collections.abc.Coroutine的對象。這個定義讓那些原本沒有異步執行意圖的生成器也帶上了協程的特征。而為了解決這種混淆,asyncio規定所有作為協程執行的函數都需要以asyncio.coroutine裝飾器進行修飾。

有了這樣一個明確的協程的定義(同時符合生成器的接口規範),你可以使用yield from將任何asyncio.Future對象傳入事件循環,在等待事件發生時暫停程序執行(future對象是asyncio中的一種對象,此處不再詳述)。future對象進入事件循環後就處於事件循環的監控之下,一旦future對象完成了自身任務,事件循環就會喚醒原本被暫停的協程繼續執行,future對象的返回結果則通過send()方法由事件循環傳遞給協程。

以上文代碼為例,事件循環啟動了兩個調用call()函數的協程,運行到某個協程中包含yield fromasyncio.sleep()語句處,這條語句將一個asyncio.Future對象返回事件循環,暫停協程的執行。這時事件循環會為future對象等待一秒(並監控其他程序,例如另外一個協程),一秒後事件循環喚醒傳出了future對象的被暫停的countdown()協程繼續執行,並把future對象的執行結果歸還給原協程。這個循環過程會持續到countdown()協程結束執行,事件循環中沒有被監控的事件為止。稍後我會用一個完整的例子詳細解釋協程/事件循環結構的工作流程,但首先,我要解釋一下asyncawait是如何工作的。

yield from到Python3.5中的await

在Python3.4中,一個用於異步執行的協程代碼會被標記成以下形式:

# 這種寫法在Python3.5中同樣有效
@asyncio.coroutine
def py34_coro():
    yield from stuff()

Python3.5也添加了一個作用和asyncio.coroutine相同,用於修飾協程函數的裝飾器types.coroutine。也可以使用async def語法定義協程函數,但是這樣定義的協程函數中不能使用yield語句,只允許使用returnawait語句返回數據。

async def py35_coro():
    await stuff()

對同一個協程概念添加的不同語法,是為了規範協程的定義。這些陸續補充的語法使協程從抽象的接口變成了具體的對象類型,讓普通的生成器和協程用的生成器有了明顯的區別(inspect.iscoroutine()方法的判斷標準則比async還要嚴格)。

另外,除了async,Python3.5也引入了await語法(只能在async def定義的函數中使用)。雖然await的使用場景與yield from類似,但是await接收的對象不同。作為由於協程而產生的語法,await接收協程對象簡直理所當然。但是當你對某個對象使用await語法時,技術上說,這個對象必須是可等待對象(awaitable object):一種定義了__await__()方法(返回非協程本身的遍歷器)的對象。協程本身也被視作可等待對象(體現在Python語言設計中,就是collections.abc.Coroutine繼承了collections.abc.Awaitable抽象類)。可等待對象的定義沿用了Python中將大多數語法結構在底層轉換成方法調用的傳統設計思想,例如a + b等價於a.__add__(b)b.__radd__(a)

那麽在編譯器層面,yield fromawait的運行機制有什麽區別(例如types.coroutine修飾的生成器和async def語法定義的函數)呢?讓我們看看上面兩個例子在Python3.5環境下執行時的字節碼細節有什麽不同,py34_coro()執行時的字節碼是:

In [31]: dis.dis(py34_coro)
  3           0 LOAD_GLOBAL              0 (stuff)
              3 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
              6 GET_YIELD_FROM_ITER
              7 LOAD_CONST               0 (None)
             10 YIELD_FROM
             11 POP_TOP
             12 LOAD_CONST               0 (None)
             15 RETURN_VALUE

py35_coro()執行時的字節碼是:

In [33]: dis.dis(py35_coro)
  2           0 LOAD_GLOBAL              0 (stuff)
              3 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
              6 GET_AWAITABLE
              7 LOAD_CONST               0 (None)
             10 YIELD_FROM
             11 POP_TOP
             12 LOAD_CONST               0 (None)
             15 RETURN_VALUE

除了py34_coro代碼中多了一行裝飾器而導致的行號不同,兩組字節碼的區別集中在GET_YIELD_FROM_ITER操作符和GET_AWAITABLE操作符。兩個函數都是以協程的語法聲明的。對於GET_YIELD_FROM_ITER,編譯器只檢查參數是否生成器或者協程,如果不是,就調用iter()函數遍歷參數(types.coroutine裝飾器修飾了生成器,讓代碼對象在C代碼層面附帶了CO_ITERABLE_COROUTINE標識,因此yield from語句可以在協程中接收協程對象)。

GET_AWAITABLE則是另外一番光景。雖然同GET_YIELD_FROM_ITER操作符一樣,字節碼也接收協程對象,但它不會接收沒有協程標記的生成器。而且,正如前文所述,字節碼不止接收協程對象,也可以接收可等待對象。這樣,yield from語句和await語句都可以實現協程概念,但一個接收的是普通的生成器,另一個是可等待對象。

也許你會好奇,為什麽基於async的協程和基於生成器的協程在暫停時接收的對象會不同?這種設計的主要目的是讓用戶不至於混淆兩種類型的協程實現,或者不小心弄錯類似的API的參數類型,甚而影響Python最重要的特性的使用體驗。例如生成器繼承了協程的API,在需要協程時很容易犯使用了普通的生成器的錯誤。生成器的使用場景不限於通過協程實現流程控制的情況,因此很容易的辨別普通生成器和協程也非常重要。可是,Python不是需要預編譯的靜態語言,在使用基於生成器的協程時編譯器只能做到在運行時進行檢查。換句話說,就算使用了types.coroutine裝飾器,編譯器也無法確定生成器會充當本職工作還是扮演協程的角色(記住,即使代碼中明明白白使用了types.coroutine裝飾器,依然有在之前的代碼中類似types = spam這樣的語句存在的可能),編譯器會根據已知的信息,在不同的上下文環境下調用不同的操作符。

對於基於生成器的協程和async定義的協程的區別,我的一個非常重要的觀點是,只有基於生成器的協程可以真正的暫停程序執行,並把外部對象傳入事件循環。當你使用事件循環相關的函數,如asyncio.sleep()時,這些函數與事件循環的交互所用的是框架內部的API,事件循環究竟如何變化,並不需要用戶操心,因此也許你很少看到這種關註底層概念的說法。我們大多數人其實並不需要真正實現一個事件循環,而只需要使用async這樣的語法來通過事件循環實現某個功能。但如果你像我一樣,好奇為什麽我們不能使用async協程實現類似asnycio.sleep()的功能,那麽答案就在這裏。

總結

讓我們總結一下這兩個相似的術語,使用async def可以定義協程,使用types.coroutine裝飾器可以將一個生成器--返回一個不是協程本身的遍歷器--聲明為協程。await語句只能用於可等待對象(await不能作用於普通的生成器),除此之外就和yield from的功能基本相同。async函數定義的協程中一定會有return語句--包括每個Python函數都有的默認返回語句return None--和/或await語句(不能使用yield語句)。對async函數所添加的限制,是為了保證用戶不會混淆它和基於生成器的協程,兩者的期望用途差別很大。

請把async/await視為異步編程的API

David Bzazley的Python Brasil 2015 keynote讓我發現自己忽略了一件很重要的事。在那個演講中,David指出,async/await其實是一種異步編程的API(他在Twitter上對我說過同樣的話)。我想David的意思是,我們不應該把async/await當成asnycio的一種別名,而應該利用async/await,讓asyncio成為異步編程的通用框架。

David對將async/await作為異步編程API的想法深信不疑,甚至在他的curio項目中實現了自己的事件循環。這也側面證明了Python中async/await作為異步編程語法的作用(不像其他集成了事件循環的語言那樣,用戶需要自己實現事件循環和底層細節)。async/await語法讓像curio這樣的項目可以進行不同的底層操作(asyncio使用future對象與事件循環進行交互,而curio使用元祖對象),還讓它們可以有不同的側重和性能優化(為了更廣泛的適用性,asyncio實現了完整的傳輸和協議層框架,而相對簡單的curio則需要用戶實現那些框架,但也因此獲得了更快的運行速度)。

看完了Python中異步編程的(簡略)歷史,很容易得出async/await == asyncio的結論。我想說的是,asyncio導致了Python3.4中異步編程的出現,並且對Python3.5中async/await的產生居功至偉,但是,async/await的靈活的設計,甚至到了可以不使用asyncio的地步,也不需要為了應用asyncio框架而修改架構。簡而言之,async/await語法延續了Python在保證實用性的同時盡可能的讓設計靈活的傳統。

一個例子

看到這裏,你的腦子裏應該已經裝滿了各種新術語和新概念,但對於這些新事物如何實現異步編程卻仍一知半解。為了加深理解,以下是一個(略顯做作的)異步編程的例子,包括完整的從事件循環到相關業務函數的代碼。在這個例子中,協程的用途是實現獨立的火箭發射倒計時器,產生的效果是同步進行的倒計時。這是通過異步編程而實現的函數並發,程序執行是有三個協程運行在在同一個線程中,卻可以彼此互不幹擾。

import datetime
import heapq
import types
import time


class Task:

    """Represent how long a coroutine should wait before starting again.

    Comparison operators are implemented for use by heapq. Two-item
    tuples unfortunately don‘t work because when the datetime.datetime
    instances are equal, comparison falls to the coroutine and they don‘t
    implement comparison methods, triggering an exception.
    
    Think of this as being like asyncio.Task/curio.Task.
    """

    def __init__(self, wait_until, coro):
        self.coro = coro
        self.waiting_until = wait_until

    def __eq__(self, other):
        return self.waiting_until == other.waiting_until

    def __lt__(self, other):
        return self.waiting_until < other.waiting_until


class SleepingLoop:

    """An event loop focused on delaying execution of coroutines.

    Think of this as being like asyncio.BaseEventLoop/curio.Kernel.
    """

    def __init__(self, *coros):
        self._new = coros
        self._waiting = []

    def run_until_complete(self):
        # Start all the coroutines.
        for coro in self._new:
            wait_for = coro.send(None)
            heapq.heappush(self._waiting, Task(wait_for, coro))
        # Keep running until there is no more work to do.
        while self._waiting:
            now = datetime.datetime.now()
            # Get the coroutine with the soonest resumption time.
            task = heapq.heappop(self._waiting)
            if now < task.waiting_until:
                # We‘re ahead of schedule; wait until it‘s time to resume.
                delta = task.waiting_until - now
                time.sleep(delta.total_seconds())
                now = datetime.datetime.now()
            try:
                # It‘s time to resume the coroutine.
                wait_until = task.coro.send(now)
                heapq.heappush(self._waiting, Task(wait_until, task.coro))
            except StopIteration:
                # The coroutine is done.
                pass


@types.coroutine
def sleep(seconds):
    """Pause a coroutine for the specified number of seconds.

    Think of this as being like asyncio.sleep()/curio.sleep().
    """
    now = datetime.datetime.now()
    wait_until = now + datetime.timedelta(seconds=seconds)
    # Make all coroutines on the call stack pause; the need to use `yield`
    # necessitates this be generator-based and not an async-based coroutine.
    actual = yield wait_until
    # Resume the execution stack, sending back how long we actually waited.
    return actual - now


async def countdown(label, length, *, delay=0):
    """Countdown a launch for `length` seconds, waiting `delay` seconds.

    This is what a user would typically write.
    """
    print(label, ‘waiting‘, delay, ‘seconds before starting countdown‘)
    delta = await sleep(delay)
    print(label, ‘starting after waiting‘, delta)
    while length:
        print(label, ‘T-minus‘, length)
        waited = await sleep(1)
        length -= 1
    print(label, ‘lift-off!‘)


def main():
    """Start the event loop, counting down 3 separate launches.

    This is what a user would typically write.
    """
    loop = SleepingLoop(countdown(‘A‘, 5), countdown(‘B‘, 3, delay=2),
                        countdown(‘C‘, 4, delay=1))
    start = datetime.datetime.now()
    loop.run_until_complete()
    print(‘Total elapsed time is‘, datetime.datetime.now() - start)


if __name__ == ‘__main__‘:
    main()

正如前文所說,這個例子是有意為之,如果在Python3.5下運行,你會發現雖然三個協程在同一線程中互不幹擾,但總運行時間是5秒左右。你可以把TaskSleepingLoopsleep()看成asynciocurio這樣生成事件循環的框架提供的接口函數,對普通用戶來說,只有countdown()main()函數才需要關註。到此為止,你應該已經明白,asyncawait語句,甚至整個異步編程,都不是完全無法理解的魔術,async/await只是Python為了讓異步編程更簡便易用而添加的API。

我對未來的願景

我已經理解了Python中的異步編程,我想把它用到所有地方!這個精巧高效的概念完全可以替代原本線程的作用。問題是,Python3.5和async/await都是面世不久的新事物,這就意味著支持異步編程的庫數量不會太多。例如,要發送HTTP請求,你要麽手動構造HTTP請求對象(麻煩透頂),然後用一個類似aiohttp的框架把HTTP放進另外的事件循環(對於aiohttp,是asyncio)開始操作;要麽就等著哪天出現一個像hyper這樣的項目對HTTP這類I/O進行抽象,讓你可以使用任意的I/O庫(遺憾的是,到目前為止hyper只支持HTTP/2)。

我的個人觀點是希望像hyper這樣的項目可以繼續發展,分離從I/O獲取二進制數據和解析二進制數據的邏輯。Python中大部分的I/O庫都是包攬進行I/O操作和處理從I/O接收的數據,因此對操作分離進行抽象意義重大。Python標準庫的http包也存在同樣的問題,有處理I/O的連接對象,卻沒有HTTP解析器。而如果你希望requests庫支持異步編程,那麽你可能要失望了,因為requests從設計上就是同步編程。擁有異步編程能力讓Python社區有機會彌補Python語言中沒有多層網絡棧抽象的缺點。現在Python的優勢是可以像運行同步代碼那樣運行異步代碼,因此填補異步編程空白的工具,可以應用在同步異步兩種場景中。

我還希望Python可以增加async協程對yield語句的支持。這可能需要一個新的關鍵字(也許是anticipate?),但只使用async語法就不能實現事件循環的情況實在不盡人意。幸運的是,在這一點上我不是一個人,PEP 492的作者與我觀點相同,我覺得這個願望很有可能成為現實。

總結

總而言之,asyncawait出現的目的就是為了協程,順便支持可等待對象,也可以把普通生成器轉換成協程。所有這些都是為了實現並發操作,來提升Python中的異步編程體驗。相比使用多線程的編程體驗,協程功能強大並且更為易用--只用了包括註釋在內的不到100行代碼就實現了一個完整的異步編程實例--兼具良好的適用性和運行效率(curio的FAQ裏說它的運行速度比twisted快30-40%,比gevent慢10-15%,別忘了,在Python2+版本中,Twisted用的內存更少而且調試比Go簡單,想想我們可以做到什麽程度!)。能在Python 3中看到async/await的引入,我非常高興,並且期待Python社區接納這個新語法,希望有更多的庫和框架支持async/await語法,讓所有的Python開發者都可以從異步編程中受益。

[翻譯] Python 3.5中async/await的工作機制