- 原文作者 : Brett Cannon
- 譯文出自 : 掘金翻譯計劃
- 譯者 : Yushneng
- 校對者: L9m,iThreeKing
作為 Python 核心開發者之一,讓我很想了解這門語言是如何運作的。我發現總有一些陰暗的角落我對其中錯綜復雜的細節不是很清楚,但是為了能夠有助于 Python 的一些問題和其整體設計,我覺得我應該試著去理解 Python 的核心語法和內部運作機制。
但是直到最近我才理解 Python 3.5 中 async
/await
的原理。我知道 Python 3.3 中的 yield from
和 Python 3.4 中的 asyncio
組合得來這一新語法。但較少處理網絡相關的問題 - asyncio
并不僅限于此但確是重要用途 - 使我沒太注意 async
/await
。我知道:
yield from iterator
(本質上)相當于:
for x in iterator:
yield x
我知道 asyncio
是事件循環框架可以進行異步編程,但是我只是知道這里面每個單詞的意思而已,從沒深入研究 async
/await
語法組合背后的原理,我發現不理解 Python 中的異步編程已經對我造成了困擾。因此我決定花時間弄清楚這背后的原理究竟是什么。我從很多人那里得知他們也不了解異步編程的原理,因此我決定寫這篇論文(是的,由于這篇文章花費時間之久以及篇幅之長,我的妻子已經將其定義為一篇論文)。
由于我想要正確地理解這些語法的原理,這篇文章涉及到一些關于 CPython 較為底層的技術細節。如果這些細節超出了你想了解的內容,或者你不能完全理解它們,都沒關系,因為我為了避免這篇文章演變成一本書那么長,省略了一些 CPython 內部的細枝末節(比如說,如果你不知道 code object 有 flags,甚至不知道什么是 code object,這都沒關系,也不用一定要從這篇文字中獲得什么)。我試著在最后一小節中用更直接的方法做了總結,如果覺得文章對你來說細節太多,你完全可以跳過。
關于 Python 協程的歷史課
根據維基百科給出的定義,“協程 是為非搶占式多任務產生子程序的計算機程序組件,協程允許不同入口點在不同位置暫停或開始執行程序”。從技術的角度來說,“協程就是你可以暫停執行的函數”。如果你把它理解成“就像生成器一樣”,那么你就想對了。
退回到 Python 2.2,生成器第一次在PEP 255中提出(那時也把它成為迭代器,因為它實現了迭代器協議)。主要是受到Icon編程語言的啟發,生成器允許創建一個在計算下一個值時不會浪費內存空間的迭代器。例如你想要自己實現一個 range()
函數,你可以用立即計算的方式創建一個整數列表:
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
然而這里存在的問題是,如果你想創建從0到1,000,000這樣一個很大的序列,你不得不創建能容納1,000,000個整數的列表。但是當加入了生成器之后,你可以不用創建完整的序列,你只需要能夠每次保存一個整數的內存即可。
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
讓函數遇到 yield
表達式時暫停執行 - 雖然在 Python 2.5 以前它只是一條語句 - 并且能夠在后面重新執行,這對于減少內存使用、生成無限序列非常有用。
你有可能已經發現,生成器完全就是關于迭代器的。有一種更好的方式生成迭代器當然很好(尤其是當你可以給一個生成器對象添加 __iter__()
方法時),但是人們知道,如果可以利用生成器“暫停”的部分,添加“將東西發送回生成器”的功能,那么 Python 突然就有了協程的概念(當然這里的協程僅限于 Python 中的概念;Python 中真實的協程在后面才會討論)。將東西發送回暫停了的生成器這一特性通過 PEP 342添加到了 Python 2.5。與其它特性一起,PEP 342 為生成器引入了 send()
方法。這讓我們不僅可以暫停生成器,而且能夠傳遞值到生成器暫停的地方。還是以我們的 range()
為例,你可以讓序列向前或向后跳過幾個值:
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
直到PEP 380 為 Python 3.3 添加了 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():
while index < up_to:
yield index
index += 1
yield from gratuitous_refactor()
yield from
通過讓重構變得簡單,也讓你能夠將生成器串聯起來,使返回值可以在調用棧中上下浮動,而不需對編碼進行過多改動。
def bottom():
# Returning the yield lets the value that goes up the call stack to come right back
# down.
return (yield 42)
def middle():
return (yield from bottom())
def top():
return (yield from middle())
# Get the generator.
gen = top()
value = next(gen)
print(value) # Prints '42'.
try:
value = gen.send(value * 2)
except StopIteration as exc:
value = exc.value
print(value) # Prints '84'.
總結
Python 2.2 中的生成器讓代碼執行過程可以暫停。Python 2.5 中可以將值返回給暫停的生成器,這使得 Python 中協程的概念成為可能。加上 Python 3.3 中的 yield from
,使得重構生成器與將它們串聯起來都很簡單。
什么是事件循環?
如果你想了解 async
/await
,那么理解什么是事件循環以及它是如何讓異步編程變為可能就相當重要了。如果你曾做過 GUI 編程 - 包括網頁前端工作 - 那么你已經和事件循環打過交道。但是由于異步編程的概念作為 Python 語言結構的一部分還是最近才有的事,你剛好不知道什么是事件循環也很正常。
回到維基百科,事件循環 “是一種等待程序分配事件或消息的編程架構”。基本上來說事件循環就是,“當A發生時,執行B”。或許最簡單的例子來解釋這一概念就是用每個瀏覽器中都存在的JavaScript事件循環。當你點擊了某個東西(“當A發生時”),這一點擊動作會發送給JavaScript的事件循環,并檢查是否存在注冊過的 onclick
回調來處理這一點擊(“執行B”)。只要有注冊過的回調函數就會伴隨點擊動作的細節信息被執行。事件循環被認為是一種循環是因為它不停地收集事件并通過循環來發如何應對這些事件。
對 Python 來說,用來提供事件循環的 asyncio
被加入標準庫中。asyncio
重點解決網絡服務中的問題,事件循環在這里將來自套接字(socket)的 I/O 已經準備好讀和/或寫作為“當A發生時”(通過selectors
模塊)。除了 GUI 和 I/O,事件循環也經常用于在別的線程或子進程中執行代碼,并將事件循環作為調節機制(例如,合作式多任務)。如果你恰好理解 Python 的 GIL,事件循環對于需要釋放 GIL 的地方很有用。
總結
事件循環提供一種循環機制,讓你可以“在A發生時,執行B”。基本上來說事件循環就是監聽當有什么發生時,同時事件循環也關心這件事并執行相應的代碼。Python 3.4 以后通過標準庫 asyncio
獲得了事件循環的特性。
async
和 await
是如何運作的
Python 3.4 中的方式
在 Python 3.3 中出現的生成器與之后以 asyncio
的形式出現的事件循環之間,Python 3.4 通過并發編程的形式已經對異步編程有了足夠的支持。異步編程簡單來說就是代碼執行的順序在程序運行前是未知的(因此才稱為異步而非同步)。并發編程是代碼的執行不依賴于其他部分,即便是全都在同一個線程內執行(并發不是并行)。例如,下面 Python 3.4 的代碼分別以異步和并發的函數調用實現按秒倒計時。
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()
Python 3.4 中,asyncio.coroutine
修飾器用來標記作為協程的函數,這里的協程是和asyncio
及其事件循環一起使用的。這賦予了 Python 第一個對于協程的明確定義:實現了PEP 342添加到生成器中的這一方法的對象,并通過[collections.abc.Coroutine
這一抽象基類]表征的對象。這意味著突然之間所有實現了協程接口的生成器,即便它們并不是要以協程方式應用,都符合這一定義。為了修正這一點,asyncio
要求所有要用作協程的生成器必須由asyncio.coroutine
修飾。
有了對協程明確的定義(能夠匹配生成器所提供的API),你可以對任何asyncio.Future
對象使用 yield from
,從而將其傳遞給事件循環,暫停協程的執行來等待某些事情的發生( future 對象并不重要,只是asyncio
細節的實現)。一旦 future 對象獲取了事件循環,它會一直在那里監聽,直到完成它需要做的一切。當 future 完成自己的任務之后,事件循環會察覺到,暫停并等待在那里的協程會通過send()
方法獲取future對象的返回值并開始繼續執行。
以上面的代碼為例。事件循環啟動每一個 countdown()
協程,一直執行到遇見其中一個協程的 yield from
和 asyncio.sleep()
。這樣會返回一個 asyncio.Future
對象并將其傳遞給事件循環,同時暫停這一協程的執行。事件循環會監控這一future對象,直到倒計時1秒鐘之后(同時也會檢查其它正在監控的對象,比如像其它協程)。1秒鐘的時間一到,事件循環會選擇剛剛傳遞了future對象并暫停了的 countdown()
協程,將future對象的結果返回給協程,然后協程可以繼續執行。這一過程會一直持續到所有的 countdown()
協程執行完畢,事件循環也被清空。稍后我會給你展示一個完整的例子,用來說明協程/事件循環之類的這些東西究竟是如何運作的,但是首先我想要解釋一下async
和await
。
Python 3.5 從 yield from
到 await
在 Python 3.4 中,用于異步編程并被標記為協程的函數看起來是這樣的:
# This also works in Python 3.5.
@asyncio.coroutine
def py34_coro():
yield from stuff()
Python 3.5 添加了types.coroutine
修飾器,也可以像 asyncio.coroutine
一樣將生成器標記為協程。你可以用 async def
來定義一個協程函數,雖然這個函數不能包含任何形式的 yield
語句;只有 return
和 await
可以從協程中返回值。
async def py35_coro():
await stuff()
雖然 async
和 types.coroutine
的關鍵作用在于鞏固了協程的定義,但是它將協程從一個簡單的接口變成了一個實際的類型,也使得一個普通生成器和用作協程的生成器之間的差別變得更加明確(inspect.iscoroutine()
函數 甚至明確規定必須使用 async
的方式定義才行)。
你將發現不僅僅是 async
,Python 3.5 還引入 await
表達式(只能用于async def
中)。雖然await
的使用和yield from
很像,但await
可以接受的對象卻是不同的。await
當然可以接受協程,因為協程的概念是所有這一切的基礎。但是當你使用 await
時,其接受的對象必須是awaitable 對象:必須是定義了__await__()
方法且這一方法必須返回一個不是協程的迭代器。協程本身也被認為是 awaitable 對象(這也是collections.abc.Coroutine
繼承 collections.abc.Awaitable
的原因)。這一定義遵循 Python 將大部分語法結構在底層轉化成方法調用的傳統,就像 a + b
實際上是a.__add__(b)
或者 b.__radd__(a)
。
yield from
和 await
在底層的差別是什么(也就是types.coroutine
與async def
的差別)?讓我們看一下上面兩則Python 3.5代碼的例子所產生的字節碼在本質上有何差異。py34_coro()
的字節碼是:
>>> dis.dis(py34_coro)
2 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()
的字節碼是:
>>> dis.dis(py35_coro)
1 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()
的asyncio.coroutine
修飾器所帶來的行號的差別,兩者之間唯一可見的差異是GET_YIELD_FROM_ITER
操作碼 對比GET_AWAITABLE
操作碼。兩個函數都被標記為協程,因此在這里沒有差別。GET_YIELD_FROM_ITER
只是檢查參數是生成器還是協程,否則將對其參數調用iter()
方法(只有用在協程內部的時候yield from
所對應的操作碼才可以接受協程對象,在這個例子里要感謝types.coroutine
修飾符將這個生成器在C語言層面標記為CO_ITERABLE_COROUTINE
)。
但是 GET_AWAITABLE
的做法不同,其字節碼像GET_YIELD_FROM_ITER
一樣接受協程,但是不接受沒有被標記為協程的生成器。就像前面討論過的一樣,除了協程以外,這一字節碼還可以接受awaitable對象。這使得yield from
和await
表達式都接受協程但分別接受一般的生成器和awaitable對象。
你可能會想,為什么基于async
的協程和基于生成器的協程會在對應的暫停表達式上面有所不同?主要原因是出于最優化Python性能的考慮,確保你不會將剛好有同樣API的不同對象混為一談。由于生成器默認實現協程的API,因此很有可能在你希望用協程的時候錯用了一個生成器。而由于并不是所有的生成器都可以用在基于協程的控制流中,你需要避免錯誤地使用生成器。但是由于 Python 并不是靜態編譯的,它最好也只能在用基于生成器定義的協程時提供運行時檢查。這意味著當用types.coroutine
時,Python 的編譯器將無法判斷這個生成器是用作協程還是僅僅是普通的生成器(記住,僅僅因為types.coroutine
這一語法的字面意思,并不意味著在此之前沒有人做過types = spam
的操作),因此編譯器只能基于當前的情況生成有著不同限制的操作碼。
關于基于生成器的協程和async
定義的協程之間的差異,我想說明的關鍵點是只有基于生成器的協程可以真正的暫停執行并強制性返回給事件循環。你可能不了解這些重要的細節,因為通常你調用的像是asyncio.sleep()
function 這種事件循環相關的函數,由于事件循環實現他們自己的API,而這些函數會處理這些小的細節。對于我們絕大多數人來說,我們只會跟事件循環打交道,而不需要處理這些細節,因此可以只用async
定義的協程。但是如果你和我一樣好奇為什么不能在async
定義的協程中使用asyncio.sleep()
,那么這里的解釋應該可以讓你頓悟。
總結
讓我們用簡單的話來總結一下。用async def
可以定義得到協程。定義協程的另一種方式是通過types.coroutine
修飾器 -- 從技術實現的角度來說就是添加了 CO_ITERABLE_COROUTINE
標記 -- 或者是collections.abc.Coroutine
的子類。你只能通過基于生成器的定義來實現協程的暫停。
awaitable 對象要么是一個協程要么是一個定義了__await__()
方法的對象 -- 也就是collections.abc.Awaitable
-- 且__await__()
必須返回一個不是協程的迭代器。await
表達式基本上與yield from
相同但只能接受awaitable對象(普通迭代器不行)。async
定義的函數要么包含return
語句 -- 包括所有Python函數缺省的return None
-- 和/或者 await
表達式(yield
表達式不行)。async
函數的限制確保你不會將基于生成器的協程與普通的生成器混合使用,因為對這兩種生成器的期望是非常不同的。
將 async
/await
看做異步編程的 API
我想要重點指出的地方實際上在我看David Beazley's Python Brasil 2015 keynote之前還沒有深入思考過。在他的演講中,David 指出 async
/await
實際上是異步編程的 API (他在 Twitter 上向我重申過)。David 的意思是人們不應該將async
/await
等同于asyncio
,而應該將asyncio
看作是一個利用async
/await
API 進行異步編程的框架。
David 將 async
/await
看作是異步編程的API創建了 curio
項目來實現他自己的事件循環。這幫助我弄清楚 async
/await
是 Python 創建異步編程的原料,同時又不會將你束縛在特定的事件循環中也無需與底層的細節打交道(不像其他編程語言將事件循環直接整合到語言中)。這允許像 curio
一樣的項目不僅可以在較低層面上擁有不同的操作方式(例如 asyncio
利用 future 對象作為與事件循環交流的 API,而 curio
用的是元組),同時也可以集中解決不同的問題,實現不同的性能特性(例如 asyncio
擁有一整套框架來實現運輸層和協議層,從而使其變得可擴展,而 curio
只是簡單地讓用戶來考慮這些但同時也讓它運行地更快)。
考慮到 Python 異步編程的(短暫)歷史,可以理解人們會誤認為 async
/await
== asyncio
。我是說asyncio
幫助我們可以在 Python 3.4 中實現異步編程,同時也是 Python 3.5 中引入async
/await
的推動因素。但是async
/await
的設計意圖就是為了讓其足夠靈活從而不需要依賴asyncio
或者僅僅是為了適應這一框架而扭曲關鍵的設計決策。換句話說,async
/await
延續了 Python 設計盡可能靈活的傳統同時又非常易于使用(實現)。
一個例子
到這里你的大腦可能已經灌滿了新的術語和概念,導致你想要從整體上把握所有這些東西是如何讓你可以實現異步編程的稍微有些困難。為了幫助你讓這一切更加具體化,這里有一個完整的(偽造的)異步編程的例子,將代碼與事件循環及其相關的函數一一對應起來。這個例子里包含的幾個協程,代表著火箭發射的倒計時,并且看起來是同時開始的。這是通過并發實現的異步編程;3個不同的協程將分別獨立運行,并且都在同一個線程內完成。
import datetime
import heapq
import types
import time
class Task:
"""Represent how long a coroutine should 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()
就像我說的,這是偽造出來的,但是如果你用 Python 3.5 去運行,你會發現這三個協程在同一個線程內獨立運行,并且總的運行時間大約是5秒鐘。你可以將Task
,SleepingLoop
和sleep()
看作是事件循環的提供者,就像asyncio
和curio
所提供給你的一樣。對于一般的用戶來說,只有countdown()
和main()
函數中的代碼才是重要的。正如你所見,async
和await
或者是這整個異步編程的過程并沒什么黑科技;只不過是 Python 提供給你幫助你更簡單地實現這類事情的API。
我對未來的希望和夢想
現在我理解了 Python 中的異步編程是如何運作的了,我想要一直用它!這是如此絕妙的概念,比你之前用過的線程好太多了。但是問題在于 Python 3.5 還太新了,async
/await
也太新了。這意味著還沒有太多庫支持這樣的異步編程。例如,為了實現 HTTP 請求你要么不得不自己徒手構建 ,要么用像是 aiohttp
之類的框架 將 HTTP 添加在另外一個事件循環的頂端,或者寄希望于更多像hyper
庫一樣的項目不停涌現,可以提供對于 HTTP 之類的抽象,可以讓你隨便用任何 I/O 庫 來實現你的需求(雖然可惜的是 hyper
目前只支持 HTTP/2)。
對于我個人來說,我希望更多像hyper
一樣的項目可以脫穎而出,這樣我們就可以在從 I/O中讀取與解讀二進制數據之間做出明確區分。這樣的抽象非常重要,因為Python多數 I/O 庫中處理 I/O 和處理數據是緊緊耦合在一起的。Python 的標準庫 http
就有這樣的問題,它不提供 HTTP解析而只有一個連接對象為你處理所有的 I/O。而如果你寄希望于requests
可以支持異步編程,那你的希望已經破滅了,因為 requests
的同步 I/O 已經烙進它的設計中了。Python 在網絡堆棧上很多層都缺少抽象定義,異步編程能力的改進使得 Python 社區有機會對此作出修復。我們可以很方便地讓異步代碼像同步一樣執行,這樣一些填補異步編程空白的工具可以安全地運行在兩種環境中。
我希望 Python 可以讓 async
協程支持 yield
。或者需要用一個新的關鍵詞來實現(可能像 anticipate
之類?),因為不能僅靠async
就實現事件循環讓我很困擾。幸運的是,我不是唯一一個這么想的人,而且PEP 492的作者也和我意見一致,我覺得還是有機會可以移除掉這點小瑕疵。
結論
基本上 async
和 await
產生神奇的生成器,我們稱之為協程,同時需要一些額外的支持例如 awaitable 對象以及將普通生成器轉化為協程。所有這些加到一起來支持并發,這樣才使得 Python 更好地支持異步編程。相比類似功能的線程,這是一個更妙也更簡單的方法。我寫了一個完整的異步編程例子,算上注釋只用了不到100行 Python 代碼 -- 但仍然非常靈活與快速(curio FAQ 指出它比 twisted
要快 30-40%,但是要比 gevent
慢 10-15%,而且全部都是有純粹的 Python 實現的;記住Python 2 + Twisted 內存消耗更少同時比Go更容易調試,想象一下這些能幫你實現什么吧!)。我非常高興這些能夠在 Python 3 中成為現實,我也非常期待 Python 社區可以接納并將其推廣到各種庫和框架中區,可以使我們都能夠受益于 Python 異步編程帶來的好處!