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

Python 3.5中async/await的工做機制html

多處翻譯出於本身理解,若有疑惑請參考原文 原文連接前端

身爲Python核心開發組的成員,我對於這門語言的各類細節充滿好奇。儘管我很清楚本身不可能對這門語言作到全知全能,但哪怕是爲了可以解決各類issue和參與常規的語言設計工做,我也以爲有必要試着接觸和理解Python的內核,弄清楚在底層它是怎麼工做的。python

話雖如此,直到最近我才理解了Python3.5中async/await的工做機制。在此以前,對於async/await語法,我只知道Python3.3中的yield fromPython3.4中的asyncio讓這個新語法得以在Python3.5中實現。因爲平常工做中沒有接觸多少網絡編程--asyncio的主要應用領域,雖然它能夠作的遠不止於此--我對async/await並無關注太多。以代碼來講,我知道:git

yield from iterator

(大致)等價於:github

from x in iterator:
    yield x

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

因爲我但願對這個語法的工做機制有一個完整的理解,這篇論文中會出現涉及CPython的底層技術細節。若是你不關心這些細節,或者沒法經過這篇文章徹底理解這些細節--限於篇幅,我不可能詳細解釋CPython的每一個細節,不然這篇文章就要變成一本書了(例如,若是你不知道代碼對象具備標識位,那就別在乎代碼對象是什麼,這不是這篇文章的重點)--那也沒什麼關係。在每一個章節的最後,我都添加了一個概念明確的小結,所以若是你對某個章節的內容不感興趣,那麼能夠跳過前面的長篇大論,直接閱讀結論。web

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.3PEP 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開發者均可以從異步編程中受益。

相關文章
相關標籤/搜索