[譯]Python中的非同步IO:一個完整的演練

Tacey Wong發表於2019-08-05

原文:Async IO in Python: A Complete Walkthrough
原文作者: Brad Solomon
原文釋出時間:2019年1月16日
翻譯:Tacey Wong
翻譯時間:2019年7月22日

翻譯僅便於個人學習,熟悉英語的請閱讀原文


目錄


Async IO是一種併發程式設計設計,Python中已經有了獨立的支援,並且從Python3.4到Python3.7得到了快速發展。

你可能疑惑,“併發、並行、執行緒、多處理”。MMP這已經很多了,非同步IO是哪根蔥?”

本教程旨在幫助你回答這個問題,讓你更牢固地掌握Python的非同步IO。

以下是要介紹的內容:

  • 非同步IO:一種與語言無關的範例(模型),它具有許多跨程式語言的實現
  • async/await:兩個 用於定義協程的新Python關鍵字
  • asyncio:為執行和管理協程提供基礎和API的Python包/庫

協程(專用生成器函式)是Python中非同步IO的核心,稍後我們將深入研究它們。

注意:在本文中,使用術語非同步IO來表示與語言無關的非同步IO設計,而asyncio指的是Python包。

開始之前,你需要確保已經配置搭建了可以使用asyncio及其他庫的實驗環境。

搭建自己的實驗環境

你需要安裝Python 3.7+以及aiohttpaiofiles包才能完整地跟隨本文進行實驗。

$ python3.7 -m venv ./py37async
$ source ./py37async/bin/activate  # Windows: .\py37async\Scripts\activate.bat
$ pip install --upgrade pip aiohttp aiofiles  # 可選項: aiodns

有關安裝Python 3.7和設定虛擬環境的幫助,請檢視Python 3安裝和設定指南虛擬環境基礎

ok,let's go!

非同步IO鳥瞰圖

相較於它久經考驗的表親(多程式和多執行緒)來說,非同步IO不太為人所知。本節將從高層全面地介紹非同步IO是什麼,以及哪些場景適合用它。

哪些場景適合非同步IO?

併發和並行是個非常廣泛的主題。因為本文重點介紹非同步IO及其在Python中的實現,現在值得花一點時間將非同步IO與其對應物進行比較,以瞭解非同步IO如何適應更大、有時令人眼花繚亂的難題。

並行:同時執行多個操作。
多程式:是一種實現並行的方法,它需要將任務分散到計算機的中央處理單元(cpu或核心)上。多程式非常適合cpu密集的任務:密集for迴圈和密集數學計算通常屬於這一類。
併發:併發是一個比並行更廣泛的術語。 它表明多個任務能夠以重疊方式執行。 (有一種說法是併發並不意味著並行。)
執行緒:是一種併發執行模型,多個執行緒輪流執行任務。 一個程式可以包含多個執行緒。 由於GIL(全域性直譯器鎖)的存在,Python與執行緒有著複雜的關係,但這超出了本文的範圍。

瞭解執行緒的重要之處是它更適合於io密集的任務。cpu密集型任務的特點是計算機核心從開始到結束都在不斷地工作,而一個IO密集型任務更多的是等待IO的完成。

綜上所述,併發既包括多程式(對於CPU密集任務來說是理想的),也包括執行緒(對於IO密集型任務來說是理想的)。多程式是並行的一種形式,並行是併發的一種特定型別(子集)。Python通過multiprocessing,threading, 和concurrent.futures標準庫為這兩者提供了長期支援。

現在是時候召集一名新成員了!在過去的幾年裡,一個獨立的設計被更全面地嵌入到了CPython中:通過標準庫的asyncio包和新的async/await語言關鍵字實現非同步IO。需要說明的是,非同步IO不是一個新發明的概念,它已經存在或正在構建到其他語言和執行時環境中,比如Golang、C#或者Scala。

Python文件將asyncio包稱為用於編寫併發程式碼的庫。然而,非同步IO既不是多執行緒也不是多程式,它不是建立在其中任何一個之上。事實上非同步IO是一種單程式單執行緒設計:它使用協作式多工操作方式,在本教程結束時你將理解這個術語。換句話說,儘管在單個程式中使用單個執行緒,但非同步IO給人一種併發的感覺。協程(非同步IO的一個核心特性)可以併發地排程,但它們本質上不是併發的。

重申一下,非同步輸入輸出是併發程式設計的一種風格,但不是並行的。與多程式相比,它與執行緒更緊密地結合在一起,但與這兩者截然不同,並且是併發技術包中的獨立成員。

現在還留下了一個詞沒有解釋。 非同步是什麼意思?這不是一個嚴格的定義,但是對於我們這裡的目的,我可以想到/考慮到兩個屬性:

  • 非同步例程能夠在等待其最終結果時“暫停”,並允許其他例程同時執行。
  • 通過上面的機制,非同步程式碼便於併發執行。 換句話說,非同步程式碼提供了併發的外觀和感覺

下面是一個一個將所有內容組合在一起的圖表。 白色術語代表概念,綠色術語代表實現或實現它們的方式:

[譯]Python中的非同步IO:一個完整的演練

(Concurrencey併發、Threading執行緒、Async IO非同步IO、Parallelism並行、Multiprocessing多程式)

我將在這裡停止對併發程式設計模型的比較。本教程重點介紹非同步IO的子元件,如何使用它、以及圍繞它建立的API。要深入研究執行緒、多處理和非同步IO,請暫停這裡並檢視Jim Anderson對(Python中併發性的概述)[https://realpython.com/python-concurrency/]。Jim比我有趣得多,而且參加的會議也比我多。

譯者注:要了解多種併發模型的比較,可以參考(《七週七併發模型》

非同步IO釋義

非同步IO乍一看似乎違反直覺,自相矛盾。如何使用一個執行緒和一個CPU核心來簡化併發程式碼?我從來都不擅長編造例子,所以我想借用Miguel Grinberg2017年PyCon演講中的一個例子,這個例子很好地解釋了一切:

國際象棋大師JuditPolgár舉辦了一個國際象棋比賽,在那裡她扮演多個業餘選手。 她有兩種方式進行比賽:同步和非同步。
假設:

  • 24個對手
  • Judit在5秒鐘內完成一個棋子的移動
  • 每個對手移動一個棋子需要55秒
  • 遊戲平均30對移動(總計60次移動)
    同步版本:Judit一次只玩一場遊戲,從不同時玩兩場,直到遊戲結束。每場比賽需要(55 + 5)* 30 == 1800秒,或30分鐘。 整個比賽需要24 * 30 == 720分鐘,或12小時。
    非同步版本:Judit從一張桌子走到另一張桌子,每張桌子走一步。她離開了牌桌,讓對手在等待的時間裡採取下一步行動。在所有24場比賽中,一個動作需要Judit 24 * 5 == 120秒,即2分鐘。整個比賽現在被縮減到120 * 30 == 3600秒,也就是1小時。

只有一個JuditPolgár,她只有兩隻手,一次只做一次動作。但是,非同步進行將展覽時間從12小時減少到1小時。因此,協同多工處理是一種奇特的方式,可以說一個程式的事件迴圈(稍後會有更多)與多個任務通訊,讓每個任務在最佳時間輪流執行。

非同步IO需要很長的等待時間,否則函式將被阻塞,並允許其他函式在停機期間執行。

非同步IO使用起來不容易

我聽人說過“當你能夠的時候使用非同步IO;必要時使用執行緒”。事實是,構建持久的多執行緒程式碼可能很難,並且容易出錯。非同步IO避免了一些執行緒設計可能遇到的潛在速度障礙。

但這並不是說Python中的非同步IO很容易。警告:當你稍微深入其中時,非同步程式設計也會很困難!Python的非同步模型是圍繞諸如回撥,事件,傳輸,協議和future等概念構建的 -——術語可能令人生畏。事實上,它的API一直在不斷變化,這使得它變得比較難。

幸運的是,asyncio已經相對成熟,其大部分功能不再處於臨時性狀態,而其文件也有了大規模的改善,並且該主題的一些優質資源也開始出現。

asyncio 包和 async/await

現在你已經對非同步輸IO作為一種設計有了一定的瞭解,讓我們來探討一下Python的實現。Python的asyncio包(在Python 3.4中引入)和它的兩個關鍵字async和wait服務於不同的目的,但是它們會一起幫助你宣告、構建、執行和管理非同步程式碼。

async/await 語法和原生協程

警告:小心你在網上讀到的東西。Python的非同步IO API已經從Python 3.4迅速發展到Python 3.7。一些舊的模式不再被使用,一些最初不被允許的東西現在通過新的引入被允許。據我所知,本教程也將很快加入過時的行列。

非同步IO的核心是協程。協程是Python生成器函式的一個專門版本。讓我們從一個基線定義開始,然後隨著你在此處的進展,以此為基礎進行構建:協程是一個函式,它可以在到達返回之前暫停執行,並且可以在一段時間內間接將控制權傳遞給另一個協程。

稍後,你將更深入地研究如何將傳統生成器重新用於協程。目前,瞭解協程如何工作的最簡單方法是開始編寫一些協程程式碼。

讓我們採用沉浸式方法,編寫一些非同步輸入輸出程式碼。這個簡短的程式是非同步IO的Hello World,但它對展示其核心功能大有幫助:

#!/usr/bin/env python3
# countasync.py

import asyncio

async def count():
    print("One")
    await asyncio.sleep(1)
    print("Two")

async def main():
    await asyncio.gather(count(), count(), count())

if __name__ == "__main__":
    import time
    s = time.perf_counter()
    asyncio.run(main())
    elapsed = time.perf_counter() - s
    print(f"{__file__} executed in {elapsed:0.2f} seconds.")

當你執行此檔案時,請注意與僅用def和time.sleep()定義函式相比,看起來有什麼不同:

$ python3 countasync.py
One
One
One
Two
Two
Two
countasync.py executed in 1.01 seconds.

該輸出的順序是非同步IO的核心。與count()的每個呼叫通訊是一個事件迴圈或協調器。當每個任務到達asyncio.sleep(1)時,函式會向事件迴圈發出呼叫,並將控制權交還給它,例如,“我將休眠1秒。在這段時間裡,做一些有意義的事情吧”。

將此與同步版本進行對比::

#!/usr/bin/env python3
# countsync.py

import time

def count():
    print("One")
    time.sleep(1)
    print("Two")

def main():
    for _ in range(3):
        count()

if __name__ == "__main__":
    s = time.perf_counter()
    main()
    elapsed = time.perf_counter() - s
    print(f"{__file__} executed in {elapsed:0.2f} seconds.")

執行時,順序和執行時間會有輕微但嚴重的變化:

$ python3 countsync.py
One
Two
One
Two
One
Two
countsync.py executed in 3.01 seconds.

雖然使用time.sleep()asyncio.sleep()看起來很普通,但是它們可以替代任何涉及等待時間的時間密集型程式。(您可以等待的最普通的事情是一個sleep()呼叫,它基本上什麼也不做。)也就是說,time.sleep()可以表示任何耗時的阻塞函式呼叫,而asyncio.sleep()用於代替非阻塞呼叫(但也需要一些時間來完成)。

你將在下一節中看到,等待某些東西(包括asyncio.sleep()的好處是,周圍的函式可以暫時將控制權交給另一個更容易立即執行某些操作的函式。相比之下,time.sleep()或任何其他阻塞呼叫與非同步Python程式碼不相容,因為它會在睡眠時間內停止所有工作。

非同步IO規則

此時,非同步、wait和它們建立的協程函式的更正式定義已經就緒。這一節有點密集,但是掌握async/await是很有幫助的,所以如果需要的話,可以回到這裡:

  • 語法async def引入了原生協程或非同步生成器。async withasync for表示式也是有效的,稍後你將看到它們。
  • 關鍵詞await將函式控制傳遞迴事件迴圈(它暫停執行周圍的協程)。如果Python在g()的範圍內遇到await f()表示式,這就是await告訴事件迴圈,“暫停執行g()直到我等待的f()的結果 返回 。 與此同時,讓其他東西執行。“

在程式碼中,第二個要點大致是這樣的:

async def g():
    # 在這裡暫停 ,f()執行完之後再返回到這裡。
    return r

關於何時以及能否使用async / await,還有一套嚴格的規則。無論您是在學習語法還是已經使用async / await,這些都非常方便:

  • 使用async def引入的函式是協程。它可以使用waitreturnyield,但所有這些都是可選的。宣告async def noop(): pass是合法的:
    • 使用wait和/或return建立一個coroutine函式。要呼叫coroutine函式,你必須等待它得到結果。
    • 在非同步def塊中使用yield不太常見(並且最近才在Python中合法)。這將建立一個非同步生成器,您可以使用非同步生成器進行迭代。 暫時忘掉非同步生成器,重點關注使用await和/或return的協程函式的語法。
    • 任何使用async def定義的東西都不能使用yield from,這會引發SyntaxError(語法錯誤)。
  • 就像在def函式之外使用yield是一個SyntaxError一樣,在async def協程之外使用wait也是一個SyntaxError

以下是一些簡潔的示例,旨在總結以上幾條規則:

async def f(x):
    y = await z(x)  # OK - `await` and `return` allowed in coroutines
    return y

async def g(x):
    yield x  # OK - this is an async generator

async def m(x):
    yield from gen(x)  # No - SyntaxError

def m(x):
    y = await z(x)  # Still no - SyntaxError (no `async def` here)
    return y

最後,當您使用await f()時,它要求f()是一個awaitable物件。嗯,這不是很有幫助,是嗎? 現在,只要知道一個等待物件是(1)另一個協程或(2)定義返回一個迭代器.__ await __()dunder方法的物件。如果你正在編寫一個程式,在大多數情況下,你只需要擔心第一種情況。

這又給我們帶來了一個你可能會看到的技術上的區別:將函式標記為coroutine的一個老方法是用@asyncio.coroutine來修飾一個普通的def函式。結果是基於生成器的協同程式。自從在Python 3.5中引入async/await語法以來,這種結構已經過時了。

這兩個協程本質上是等價的(都是可 awaitable的),但是第一個協程是基於生成器的,而第二個協程是一個原生協程:

import asyncio

@asyncio.coroutine
def py34_coro():
    """Generator-based coroutine, older syntax"""
    yield from stuff()

async def py35_coro():
    """Native coroutine, modern syntax"""
    await stuff()

如果你自己編寫任何程式碼,為了顯式最好使用本機協程。基於生成器的協程將在Python 3.10中刪除

在本教程的後半部分,我們將僅出於解釋的目的來討論基於生成器的協同程式。引入async / await的原因是使協同程式成為Python的獨立功能,可以很容易地與正常的生成器函式區分開來,從而減少歧義。

不要陷入基於生成器的協程中,這些協同程式已隨著async / await的出現而過時了。如果你堅持async/await語法,它們有自己的小規則集(例如,await不能在基於生成器的協同程式中使用),這些規則在很大程度上是不相關的。

廢話不多說,讓我們來看幾個更復雜的例子。

下面是非同步IO如何減少等待時間的一個例子:給定一個協程makerandom(),它一直在[0,10]範圍內產生隨機整數,直到其中一個超過閾值,你想讓這個協程的多次呼叫不需要等待彼此連續完成。你可以在很大程度上遵循上面兩個指令碼的模式,只需稍作修改:

#!/usr/bin/env python3
# rand.py

import asyncio
import random

# ANSI colors
c = (
    "\033[0m",   # End of color
    "\033[36m",  # Cyan
    "\033[91m",  # Red
    "\033[35m",  # Magenta
)

async def makerandom(idx: int, threshold: int = 6) -> int:
    print(c[idx + 1] + f"Initiated makerandom({idx}).")
    i = random.randint(0, 10)
    while i <= threshold:
        print(c[idx + 1] + f"makerandom({idx}) == {i} too low; retrying.")
        await asyncio.sleep(idx + 1)
        i = random.randint(0, 10)
    print(c[idx + 1] + f"---> Finished: makerandom({idx}) == {i}" + c[0])
    return i

async def main():
    res = await asyncio.gather(*(makerandom(i, 10 - i - 1) for i in range(3)))
    return res

if __name__ == "__main__":
    random.seed(444)
    r1, r2, r3 = asyncio.run(main())
    print()
    print(f"r1: {r1}, r2: {r2}, r3: {r3}")

彩色輸出比我能說的多得多,並讓你瞭解這個指令碼是如何執行的:

[譯]Python中的非同步IO:一個完整的演練

該程式使用一個主協程makerandom(),並在3個不同的輸入上同時執行它。大多數程式將包含小型、模組化的協程和一個包裝器函式,用於將每個較小的協程連結在一起。然後,main()用中央協程對映到某個可迭代的池中收集任務(future)。

在這個小例子中,池是range(3)。在稍後介紹的更全面的示例中,它是一組需要同時請求,解析和處理的URL,main()封裝了每個URL的整個例程。

雖然“製作隨機整數”(CPU密集比這更復雜)可能不是作為asyncio候選者的最佳選擇,但是在示例中存在asyncio.sleep(),旨在模仿不確定等待時間的IO密集程式 。例如,asyncio.sleep()呼叫可能表示在訊息應用程式中的兩個客戶端之間傳送和接收不那麼隨機的整數。

非同步IO設計模式

Async IO附帶了它自己的一組指令碼設計,您將在本節中介紹這些指令碼設計。

鏈式協程

協程的一個關鍵特性是它們可以連結在一起(記住,一個協成物件是awaitable的,所以另外一個協成可以await它)。這允許你將程式分成更小的、可管理的、可回收的協同程式:

#!/usr/bin/env python3
# chained.py

import asyncio
import random
import time

async def part1(n: int) -> str:
    i = random.randint(0, 10)
    print(f"part1({n}) sleeping for {i} seconds.")
    await asyncio.sleep(i)
    result = f"result{n}-1"
    print(f"Returning part1({n}) == {result}.")
    return result

async def part2(n: int, arg: str) -> str:
    i = random.randint(0, 10)
    print(f"part2{n, arg} sleeping for {i} seconds.")
    await asyncio.sleep(i)
    result = f"result{n}-2 derived from {arg}"
    print(f"Returning part2{n, arg} == {result}.")
    return result

async def chain(n: int) -> None:
    start = time.perf_counter()
    p1 = await part1(n)
    p2 = await part2(n, p1)
    end = time.perf_counter() - start
    print(f"-->Chained result{n} => {p2} (took {end:0.2f} seconds).")

async def main(*args):
    await asyncio.gather(*(chain(n) for n in args))

if __name__ == "__main__":
    import sys
    random.seed(444)
    args = [1, 2, 3] if len(sys.argv) == 1 else map(int, sys.argv[1:])
    start = time.perf_counter()
    asyncio.run(main(*args))
    end = time.perf_counter() - start
    print(f"Program finished in {end:0.2f} seconds.")

請仔細注意輸出,其中part1()睡眠時間可變,part2()在結果可用時開始處理結果:

$ python3 chained.py 9 6 3
part1(9) sleeping for 4 seconds.
part1(6) sleeping for 4 seconds.
part1(3) sleeping for 0 seconds.
Returning part1(3) == result3-1.
part2(3, 'result3-1') sleeping for 4 seconds.
Returning part1(9) == result9-1.
part2(9, 'result9-1') sleeping for 7 seconds.
Returning part1(6) == result6-1.
part2(6, 'result6-1') sleeping for 4 seconds.
Returning part2(3, 'result3-1') == result3-2 derived from result3-1.
-->Chained result3 => result3-2 derived from result3-1 (took 4.00 seconds).
Returning part2(6, 'result6-1') == result6-2 derived from result6-1.
-->Chained result6 => result6-2 derived from result6-1 (took 8.01 seconds).
Returning part2(9, 'result9-1') == result9-2 derived from result9-1.
-->Chained result9 => result9-2 derived from result9-1 (took 11.01 seconds).
Program finished in 11.01 seconds.

在此設定中,main()的執行時間將等於它收集和排程的任務的最大執行時間。

使用佇列

asyncio包提供了與queue模組的類類似的queue classes類。在我們到目前為止的示例中,我們並不真正需要佇列結構。在 chained.py中,每個任務(future)都由一組協同程式組成,這些協同程式顯式地相互等待,並在每個鏈上傳遞一個輸入。

還有一種替代結構也可以用於非同步IO:許多生產者,彼此沒有關聯,將專案新增到佇列中。每個生產者可以在交錯、隨機、未宣佈的時間向佇列新增多個項。當商品出現時,一組消費者貪婪地從佇列中取出商品,不等待任何其他訊號。

在這種設計中,沒有任何個體消費者與生產者的連結。消費者事先不知道生產者的數量,甚至不知道將新增到佇列中的累計專案數。

單個生產者或消費者分別從佇列中放置和提取項所需的時間是可變的。佇列充當一個吞吐量,它可以與生產者和消費者通訊,而不需要它們彼此直接通訊。

注意:雖然佇列通常用於執行緒程式,因為queue.Queue()的執行緒安全性。在涉及非同步IO時,您不需要關心執行緒安全性(例外情況是當你將兩者結合時,但在本教程中沒有這樣做。)【譯者注:這裡的兩者結合說的是非同步IO和多執行緒結合】。佇列的一個用例(如這裡的例子)是佇列充當生產者和消費者的傳送器,否則它們不會直接連結或關聯在一起。

這個程式的同步版本看起來相當糟糕:一組阻塞生成器按順序將項新增到佇列中,一次一個生產者。只有在所有生產者完成之後,佇列才可以由一個消費者一次處理一個項一個項地處理。這種設計有大量的延遲。物品可能會閒置在佇列中,而不是立即拿起並處理。

下面是非同步版本asyncq.py
這個工作流程的挑戰在於需要向消費者發出生產完成的訊號。否則,await q.get()將無限期掛起,因為佇列已經被完全處理,但是消費者並不知道生產已經完成。

(非常感謝StackOverflow使用者幫助理順main():關鍵是await q.join(),它將一直阻塞到佇列中的所有項都被接收和處理,然後取消消費者任務,否則這些任務會掛起並無休止地等待其他佇列項出現)

下面是完整的指令碼:

#!/usr/bin/env python3
# asyncq.py

import asyncio
import itertools as it
import os
import random
import time

async def makeitem(size: int = 5) -> str:
    return os.urandom(size).hex()

async def randsleep(a: int = 1, b: int = 5, caller=None) -> None:
    i = random.randint(0, 10)
    if caller:
        print(f"{caller} sleeping for {i} seconds.")
    await asyncio.sleep(i)

async def produce(name: int, q: asyncio.Queue) -> None:
    n = random.randint(0, 10)
    for _ in it.repeat(None, n):  # Synchronous loop for each single producer
        await randsleep(caller=f"Producer {name}")
        i = await makeitem()
        t = time.perf_counter()
        await q.put((i, t))
        print(f"Producer {name} added <{i}> to queue.")

async def consume(name: int, q: asyncio.Queue) -> None:
    while True:
        await randsleep(caller=f"Consumer {name}")
        i, t = await q.get()
        now = time.perf_counter()
        print(f"Consumer {name} got element <{i}>"
              f" in {now-t:0.5f} seconds.")
        q.task_done()

async def main(nprod: int, ncon: int):
    q = asyncio.Queue()
    producers = [asyncio.create_task(produce(n, q)) for n in range(nprod)]
    consumers = [asyncio.create_task(consume(n, q)) for n in range(ncon)]
    await asyncio.gather(*producers)
    await q.join()  # Implicitly awaits consumers, too
    for c in consumers:
        c.cancel()

if __name__ == "__main__":
    import argparse
    random.seed(444)
    parser = argparse.ArgumentParser()
    parser.add_argument("-p", "--nprod", type=int, default=5)
    parser.add_argument("-c", "--ncon", type=int, default=10)
    ns = parser.parse_args()
    start = time.perf_counter()
    asyncio.run(main(**ns.__dict__))
    elapsed = time.perf_counter() - start
    print(f"Program completed in {elapsed:0.5f} seconds.")

前幾個協同程式是輔助函式,它返回隨機字串,小數秒效能計數器和隨機整數。生產者將1到5個專案放入佇列中。 每個專案是(i,t)的元組,其中i是隨機字串,t是生產者嘗試將元組放入佇列的時間。

當消費者將專案拉出時,它只使用專案所在的時間戳計算專案在佇列中所用的時間。

請記住,asyncio.sleep()是用來模擬其他一些更復雜的協同程式的,如果它是一個常規的阻塞函式,會消耗時間並阻塞所有其他的執行。
.

下面是一個有兩個生產者和五個消費者的測試:

$ python3 asyncq.py -p 2 -c 5
Producer 0 sleeping for 3 seconds.
Producer 1 sleeping for 3 seconds.
Consumer 0 sleeping for 4 seconds.
Consumer 1 sleeping for 3 seconds.
Consumer 2 sleeping for 3 seconds.
Consumer 3 sleeping for 5 seconds.
Consumer 4 sleeping for 4 seconds.
Producer 0 added <377b1e8f82> to queue.
Producer 0 sleeping for 5 seconds.
Producer 1 added <413b8802f8> to queue.
Consumer 1 got element <377b1e8f82> in 0.00013 seconds.
Consumer 1 sleeping for 3 seconds.
Consumer 2 got element <413b8802f8> in 0.00009 seconds.
Consumer 2 sleeping for 4 seconds.
Producer 0 added <06c055b3ab> to queue.
Producer 0 sleeping for 1 seconds.
Consumer 0 got element <06c055b3ab> in 0.00021 seconds.
Consumer 0 sleeping for 4 seconds.
Producer 0 added <17a8613276> to queue.
Consumer 4 got element <17a8613276> in 0.00022 seconds.
Consumer 4 sleeping for 5 seconds.
Program completed in 9.00954 seconds.

在這種情況下,專案在幾分之一秒內處理。 延遲可能有兩個原因:

  • 標準的,在很大程度上不可避免的開銷
  • 當一個項出現在佇列中時,所有消費者都在睡覺的情況

關於第二個原因,幸運的是,擴充套件到成百上千的消費者是完全正常的。用python3 asyncq.py -p 5 - c100應該沒有問題。這裡的要點是,理論上,您可以讓不同系統上的不同使用者控制生產者和消費者的管理,佇列充當中央吞吐量。

到目前為止,您已經跳進了火坑。瞭解了三個asyncio呼叫async和await定義的協程並等待的示例。如果你沒有完全關注或者只是想深入瞭解Python中現代協同程式的機制,下一節我們將開始討論這個。

生成器中非同步IO的Roots

之前,您看到了一個基於生成器的舊式協同程式的例子,它已經被更顯式的原生協同程式所淘汰。這個例子值得重新展示一下:

import asyncio

@asyncio.coroutine
def py34_coro():
    """Generator-based coroutine"""
    # No need to build these yourself, but be aware of what they are
    s = yield from stuff()
    return s

async def py35_coro():
    """Native coroutine, modern syntax"""
    s = await stuff()
    return s

async def stuff():
    return 0x10, 0x20, 0x30

作一個實驗,如果py34_coro()或py35_coro()呼叫自身,而不await或不呼叫asyncio.run()或其他asyncio函式,會發生什麼?獨呼叫一個協同程式會返回一個協同程式物件:

>>> py35_coro()
<coroutine object py35_coro at 0x10126dcc8>

這表面上並不是很有趣。 呼叫協同程式的結果是一個awaitable的協程物件。

測驗時間:Python的其他什麼功能跟這一樣?(Python的哪些特性在單獨呼叫時實際上沒有多大作用?)

希望你將生成器作為這個問題的答案,因為協同程式是增強型生成器。 在這方面的行為類似:

>>> def gen():
...     yield 0x10, 0x20, 0x30
...
>>> g = gen()
>>> g  # Nothing much happens - need to iterate with `.__next__()`
<generator object gen at 0x1012705e8>
>>> next(g)
(16, 32, 48)

正如它所發生的那樣,生成器函式是非同步IO的基礎(無論是否使用async def宣告協程而不是舊的@asyncio.coroutine包裝器)。從技術上講,await更接近於yield from而非yield。(但請記住,yield from x()只是替換for i in x():yield i的語法糖)

生成器與非同步IO相關的一個關鍵特性是可以有效地隨意停止和重新啟動生成器。例如,你可以在生成器物件上進行迭代,然後在剩餘的值上繼續迭代。當一個生成器函式達到yield時,它會產生該值,但隨後它會處於空閒狀態,直到它被告知產生其後續值。

這可以通過一個例子來充實:

>>> from itertools import cycle
>>> def endless():
...     """Yields 9, 8, 7, 6, 9, 8, 7, 6, ... forever"""
...     yield from cycle((9, 8, 7, 6))

>>> e = endless()
>>> total = 0
>>> for i in e:
...     if total < 30:
...         print(i, end=" ")
...         total += i
...     else:
...         print()
...         # Pause execution. We can resume later.
...         break
9 8 7 6 9 8 7 6 9 8 7 6 9 8

>>> # Resume
>>> next(e), next(e), next(e)
(6, 9, 8)

await關鍵字的行為類似,標記了一個斷點,協程掛起自己並允許其他協程工作。在這種情況下,“掛起”是指暫時放棄控制但未完全退出或結束協程。請記住,yield,以及由此產生的yield fromawait是發生器執行過程中的一個斷點。

這是函式和生成器之間的根本區別。一個函式要麼全有要麼全無。一旦它開始,它就不會停止,直到它到達一個return,然後將該值推給呼叫者(呼叫它的函式)。另一方面,生成器每次達到yield時都會暫停,不再繼續。它不僅可以將這個值推入呼叫堆疊,而且當您通過對它呼叫next()恢復它時,它還可以保留它的區域性變數。

生成器的第二個特徵雖然鮮為人知,卻也也很重要。也可以通過其.send()方法將值傳送到生成器。這允許生成器(和協同程式)相互呼叫(await)而不會阻塞。我不會再深入瞭解這個功能的細節,因為它主要是為了在幕後實現協同程式,但你不應該真的需要自己直接使用它。

如果你有興趣瞭解更多內容,可以從PEP 342/正式引入協同程式開始。 Brett Cannon的Python中非同步等待(Async-Await)是如何工作的也是一個很好的讀物,asyncio上的PYMOTW文章也是如此。還有David Beazley的[關於協程和併發的有趣課程] 深入探討了協同程式執行的機制。

讓我們嘗試將上述所有文章壓縮成幾句話:

這些協同程式實際上是通過一種非常規的機制執行的。它們的結果是在呼叫其.send()方法時丟擲異常物件的屬性。所有這些都有一些不可靠的細節,但是它可能不會幫助您在實踐中使用這部分語言,所以現在讓我們繼續。

為了聯絡在一起,以下是關於協同作為生成器這個主題的一些關鍵點:

  • 協同程式是利用生成器方法的特性的再利用生成器
  • 舊式基於生成器的協同程式使用yield from來等待協程結果。原生協同程式中的現代Python語法只是將yield from等價替換為await作為等待協程結果的方法。await類似於yield,這樣想通常是有幫助的。
  • await的使用是標誌著斷點的訊號。它允許協程暫時暫停執行並允許程式稍後返回它。

其他特點: async for and Async Generators + Comprehensions

與純async/await一起,Python還允許通過async for非同步迭代非同步迭代器。非同步迭代器的目的是讓它能夠在迭代時在每個階段呼叫非同步程式碼。

這個概念的自然延伸是非同步發生器。回想一下,你可以在原生協程中使用await,return或yield。在Python 3.6中可以使用協程中的yield(通過PEP 525),它引入了非同步生成器,目的是允許await和yield在同一個協程函式體中使用:

>>> async def mygen(u: int = 10):
...     """Yield powers of 2."""
...     i = 0
...     while i < u:
...         yield 2 ** i
...         i += 1
...         await asyncio.sleep(0.1)

最後但同樣重要的是,Python通過async for來實現非同步理解。就像它的同步表兄弟一樣,這主要是語法糖:

>>> async def main():
...     # This does *not* introduce concurrent execution
...     # It is meant to show syntax only
...     g = [i async for i in mygen()]
...     f = [j async for j in mygen() if not (j // 3 % 5)]
...     return g, f
...
>>> g, f = asyncio.run(main())
>>> g
[1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
>>> f
[1, 2, 16, 32, 256, 512]

這是一個關鍵的區別:非同步生成器和理解都不會使迭代併發。它們所做的就是提供同步對等程式的外觀和感覺,但是有能力讓迴圈放棄對事件迴圈的控制,讓其他協同程式執行。

換句話說,非同步迭代器和非同步生成器不是為了在序列或迭代器上同時對映某些函式而設計的。它們僅僅是為了讓封閉的協程允許其他任務輪流使用。async for和async with語句僅在使用純for或with會“破壞”協程中await的性質的情況下才需要。非同步性和併發之間的區別是一個需要掌握的關鍵因素。

事件迴圈和asyncio.run()

您可以將事件迴圈視為一段時間的while True迴圈,它監視協同程式,獲取有關閒置內容的反饋,並查詢可在此期間執行的內容。當協同程式等待的任何內容變得可用時,它能夠喚醒空閒協程。

到目前為止,事件迴圈的整個管理已由一個函式呼叫隱式處理:

asyncio.run(main())  # Python 3.7+

Python 3.7中引入的asyncio.run()負責獲取事件迴圈,執行任務直到它們被標記為完成,然後關閉事件迴圈。

使用get_event_loop()管理asyncio事件迴圈有一種更加冗長的方式。典型的模式如下所示:

loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(main())
finally:
    loop.close()

你可能會在較舊的示例中看到loop.get_event_loop(),但除非你需要對事件迴圈管理控制進行特別的微調,否則asyncio.run()應該足以滿足大多數程式的需要。

如果確實需要在Python程式中與事件迴圈互動,loop是一個老式的Python物件,它支援使用loop.is_running()和loop.is_closed()進行內省/introspection 。如果需要獲得更精細的控制,可以對其進行操作,例如通過將迴圈作為引數傳遞來排程回撥

更重要的是要深入瞭解事件迴圈的機制。關於事件迴圈,這裡有幾點值得強調。

1:協同程式在與事件迴圈繫結之前不會自行做很多事情。

你之前在生成器的解釋中看到了這一點,但值得重申。如果您有一個主協程在等待其他協程,那麼單獨呼叫它幾乎沒有什麼效果:

>>> import asyncio

>>> async def main():
...     print("Hello ...")
...     await asyncio.sleep(1)
...     print("World!")

>>> routine = main()
>>> routine
<coroutine object main at 0x1027a6150>

請記住使用asyncio.run()通過排程main()協程(將來的物件)來實際強制執行,以便在事件迴圈上執行:

>>> asyncio.run(routine)
Hello ...
World!

(其他協同程式可以通過await執行。通常在asyncio.run()中封裝main(),然後從那裡呼叫帶有await的鏈式協程。)

2:預設情況下,非同步IO事件迴圈在單個執行緒和單個CPU核心上執行。通常,在一個CPU核心中執行一個單執行緒事件迴圈是綽綽有餘的。還可以跨多個核心執行事件迴圈。請檢視John Reese談話獲取更多內容,順便提個醒,你的膝上型電腦可能會自發燃燒。

3:事件迴圈是可插入的。也就是說,如果你真的需要,你可以編寫自己的事件迴圈實現,並讓它以相同的方式執行任務。這在uvloop包中得到了很好的演示,這是Cython中事件迴圈的一個實現。

這就是"可插入事件迴圈"這個術語的含義:你可以使用事件迴圈的任何工作實現,與協同程式本身的結構無關。asyncio包本身附帶兩個不同的事件迴圈實現,預設情況下基於選擇器模組。(第二個實現僅適用於Windows。)

一個完整的程式:非同步請求

你已經走了這麼遠,現在是時候享受快樂和無痛的部分了。在本節中,您將使用aiohttp(一種速度極快的非同步http 客戶端/服務端 框架)構建一個抓取網頁的網址收集器areq.py。(我們只需要客戶端部分。)這種工具可以用來對映一組站點之間的連線,這些連結形成一個有向圖

:您可能想知道為什麼Python的requests包與非同步IO不相容。requests構建在urllib3之上,而urllib3又使用Python的http和socket模組。預設情況下,socket操作是阻塞的。這意味著Python不會想await requests.get(url)這樣,因為.get()不是awaitable的。相比之下,aiohttp中幾乎所有東西都是一個awaitable的協程,比如,session.request()response.text(). 它是一個很棒的庫,但是在非同步程式碼中使用requests是有害的。

高層程式結構如下:

  1. 從本地檔案url .txt中讀取url序列。
  2. 傳送對URL的GET請求並解碼生成的內容。 如果這失敗了,在那裡停下來找一個網址。
  3. 在響應的HTML中搜尋href標記內的URL
  4. 將結果寫入foundurls.txt。
  5. 儘可能非同步和併發地執行上述所有操作。(對請求使用aiohttp,對檔案附件使用aiofiles。這是IO的兩個主要示例,非常適合非同步IO模型。)

下是urls.txt的內容。 它並不龐大,並且主要包含高流量的網站:

$ cat urls.txt
https://regex101.com/
https://docs.python.org/3/this-url-will-404.html
https://www.nytimes.com/guides/
https://www.mediamatters.org/
https://1.1.1.1/
https://www.politico.com/tipsheets/morning-money
https://www.bloomberg.com/markets/economics
https://www.ietf.org/rfc/rfc2616.txt

列表中的第二個網址應該返回一個404響應,你需要優雅地處理這個響應。如果你正在執行此程式的擴充套件版本,你可能需要處理比這更多的問題,例如伺服器斷開連線和無限重定向。

求本身應該使用單個會話進行,以充分利用會話的內部連線池。

讓我們來看看完整的程式。之後,我們將一步一步地介紹這些內容:

#!/usr/bin/env python3
# areq.py

"""Asynchronously get links embedded in multiple pages' HMTL."""

import asyncio
import logging
import re
import sys
from typing import IO
import urllib.error
import urllib.parse

import aiofiles
import aiohttp
from aiohttp import ClientSession

logging.basicConfig(
    format="%(asctime)s %(levelname)s:%(name)s: %(message)s",
    level=logging.DEBUG,
    datefmt="%H:%M:%S",
    stream=sys.stderr,
)
logger = logging.getLogger("areq")
logging.getLogger("chardet.charsetprober").disabled = True

HREF_RE = re.compile(r'href="(.*?)"')

async def fetch_html(url: str, session: ClientSession, **kwargs) -> str:
    """GET request wrapper to fetch page HTML.

    kwargs are passed to `session.request()`.
    """

    resp = await session.request(method="GET", url=url, **kwargs)
    resp.raise_for_status()
    logger.info("Got response [%s] for URL: %s", resp.status, url)
    html = await resp.text()
    return html

async def parse(url: str, session: ClientSession, **kwargs) -> set:
    """Find HREFs in the HTML of `url`."""
    found = set()
    try:
        html = await fetch_html(url=url, session=session, **kwargs)
    except (
        aiohttp.ClientError,
        aiohttp.http_exceptions.HttpProcessingError,
    ) as e:
        logger.error(
            "aiohttp exception for %s [%s]: %s",
            url,
            getattr(e, "status", None),
            getattr(e, "message", None),
        )
        return found
    except Exception as e:
        logger.exception(
            "Non-aiohttp exception occured:  %s", getattr(e, "__dict__", {})
        )
        return found
    else:
        for link in HREF_RE.findall(html):
            try:
                abslink = urllib.parse.urljoin(url, link)
            except (urllib.error.URLError, ValueError):
                logger.exception("Error parsing URL: %s", link)
                pass
            else:
                found.add(abslink)
        logger.info("Found %d links for %s", len(found), url)
        return found

async def write_one(file: IO, url: str, **kwargs) -> None:
    """Write the found HREFs from `url` to `file`."""
    res = await parse(url=url, **kwargs)
    if not res:
        return None
    async with aiofiles.open(file, "a") as f:
        for p in res:
            await f.write(f"{url}\t{p}\n")
        logger.info("Wrote results for source URL: %s", url)

async def bulk_crawl_and_write(file: IO, urls: set, **kwargs) -> None:
    """Crawl & write concurrently to `file` for multiple `urls`."""
    async with ClientSession() as session:
        tasks = []
        for url in urls:
            tasks.append(
                write_one(file=file, url=url, session=session, **kwargs)
            )
        await asyncio.gather(*tasks)

if __name__ == "__main__":
    import pathlib
    import sys

    assert sys.version_info >= (3, 7), "Script requires Python 3.7+."
    here = pathlib.Path(__file__).parent

    with open(here.joinpath("urls.txt")) as infile:
        urls = set(map(str.strip, infile))

    outpath = here.joinpath("foundurls.txt")
    with open(outpath, "w") as outfile:
        outfile.write("source_url\tparsed_url\n")

    asyncio.run(bulk_crawl_and_write(file=outpath, urls=urls))

這個指令碼比我們最初的玩具程式要長,所以讓我們把它分解一下。

常量HREF RE是一個正規表示式,用於提取我們最終要搜尋的HTML中的HREF標記:

>>> HREF_RE.search('Go to <a href="https://realpython.com/">Real Python</a>')
<re.Match object; span=(15, 45), match='href="https://realpython.com/"'>

協程 fetch html()是一個GET請求的包裝器,用於發出請求並解碼結果頁面html。它發出請求,等待響應,並在非200狀態的情況下立即提出:

resp = await session.request(method="GET", url=url, **kwargs)
resp.raise_for_status()

如果狀態正常,則fetch_html()返回頁面HTML(str)。值得注意的是,這個函式中沒有執行異常處理。邏輯是將該異常傳播給呼叫者並讓它在那裡處理:

html = await resp.text()

我們等待session.request()resp.text(),因為它們是awaitable的協程。否則,請求/響應週期將是應用程式的長尾、佔用時間的部分,但是對於非同步輸入輸出,fetch_html()允許事件迴圈處理其他可用的作業,例如解析和寫入已經獲取的URLs。

協程鏈中的下一個是parse(),它等待fetch html()獲取給定的URL,然後從該頁面的s html中提取所有的href標記,確保每個標記都是有效的,並將其格式化為絕對路徑。

誠然,parse()的第二部分是阻塞的,但它包括快速正規表示式匹配,並確保發現的連結成為絕對路徑。

在這種特殊情況下,這個同步程式碼應該是快速和不明顯的。但是請記住,在給定的協程內的任何一行都會阻塞其他協程,除非該行使用yield、await或return。如果解析是一個更密集的過程,您可能需要考慮使用executor()中的loop.run_in_executor()在自己的程式中執行這部分。

接下來,協程 write()接受一個檔案物件和一個URL,並等待parse()返回一組已解析的URL,通過使用aiofiles(一個用於非同步檔案IO的包)將每個URL及其源URL非同步地寫入檔案。

最後,bulk_crawl_and_write()作為指令碼的協程鏈的主要入口點。 它使用單個會話,併為最終從urls.txt讀取的每個URL建立任務。

這裡還有幾點值得一提:

  • 預設的客戶機會話有一個最多有100個開啟連線的介面卡。要更改這一點,請將asyncio.connector.TCPConnector的例項傳遞給ClientSession。您也可以按主機指定限制。
  • 可以為整個會話和單個請求指定最大超時
  • 此指令碼還使用async with,它與非同步上下文管理器一起使用。 我沒有專門討論這個概念,因為從同步到非同步上下文管理器的轉換相當簡單。後者必須定義.__ aenter __().__ aexit __()而不是.__ exit __().__enter__()。正如您所料,async with只能在使用async def宣告的協程函式中使用。

如果您想進一步瞭解,GitHub上本教程附帶的檔案有詳細的註釋。

下面是執行的全部榮耀,因為areq.py可以在一秒鐘內獲取、解析和儲存9個url的結果:

$ python3 areq.py
21:33:22 DEBUG:asyncio: Using selector: KqueueSelector
21:33:22 INFO:areq: Got response [200] for URL: https://www.mediamatters.org/
21:33:22 INFO:areq: Found 115 links for https://www.mediamatters.org/
21:33:22 INFO:areq: Got response [200] for URL: https://www.nytimes.com/guides/
21:33:22 INFO:areq: Got response [200] for URL: https://www.politico.com/tipsheets/morning-money
21:33:22 INFO:areq: Got response [200] for URL: https://www.ietf.org/rfc/rfc2616.txt
21:33:22 ERROR:areq: aiohttp exception for https://docs.python.org/3/this-url-will-404.html [404]: Not Found
21:33:22 INFO:areq: Found 120 links for https://www.nytimes.com/guides/
21:33:22 INFO:areq: Found 143 links for https://www.politico.com/tipsheets/morning-money
21:33:22 INFO:areq: Wrote results for source URL: https://www.mediamatters.org/
21:33:22 INFO:areq: Found 0 links for https://www.ietf.org/rfc/rfc2616.txt
21:33:22 INFO:areq: Got response [200] for URL: https://1.1.1.1/
21:33:22 INFO:areq: Wrote results for source URL: https://www.nytimes.com/guides/
21:33:22 INFO:areq: Wrote results for source URL: https://www.politico.com/tipsheets/morning-money
21:33:22 INFO:areq: Got response [200] for URL: https://www.bloomberg.com/markets/economics
21:33:22 INFO:areq: Found 3 links for https://www.bloomberg.com/markets/economics
21:33:22 INFO:areq: Wrote results for source URL: https://www.bloomberg.com/markets/economics
21:33:23 INFO:areq: Found 36 links for https://1.1.1.1/
21:33:23 INFO:areq: Got response [200] for URL: https://regex101.com/
21:33:23 INFO:areq: Found 23 links for https://regex101.com/
21:33:23 INFO:areq: Wrote results for source URL: https://regex101.com/
21:33:23 INFO:areq: Wrote results for source URL: https://1.1.1.1/

還不算太寒酸! 作為完整性檢查,你可以檢查輸出的行數。 在我做這個實驗的時候,它是626,但請記住,這可能會發生變動:

$ wc -l foundurls.txt
     626 foundurls.txt

$ head -n 3 foundurls.txt
source_url  parsed_url
https://www.bloomberg.com/markets/economics https://www.bloomberg.com/feedback
https://www.bloomberg.com/markets/economics https://www.bloomberg.com/notices/tos

下一步:如果你想增加難度,可以讓這個網路爬蟲進行遞迴。您可以使用aio-redis跟蹤樹中已爬網的URL,以避免請求它們兩次,並使用Python的networkx庫進行連結。 記住要友好一點。將1000個併發請求傳送到一個小的、毫無防備的網站是非常糟糕的。有一些方法可以限制您在一個批處理中進行的併發請求數,例如使用asyncio的sempahore物件或使用類似這樣的模式。

上下文中的非同步IO

既然您已經看到了相當多的程式碼,讓我們回過頭來考慮一下什麼時候非同步IO是一個理想的選擇,以及如何進行比較來得出這個結論,或者選擇其他不同的併發模型。

何時以及為何非同步IO是正確的選擇?

本教程不適用於非同步IO與執行緒、多處理的擴充套件論述。然而,瞭解非同步IO何時可能是三者中最好的候選是很有用的。

關於非同步IO與多處理之間的鬥爭實際上根本不是一場戰爭。事實上,它們可以一起使用。如果你有多個相當統一的CPU密集型任務(一個很好的例子是scikit-learn或keras等庫中的網格搜尋),多程式應該是一個明顯的選擇。

如果所有函式都使用阻塞呼叫,那麼將async放在每個函式之前不是一個好主意。(這實際上會降低你的程式碼速度。)是正如前面提到的,非同步IO和多處理可以在一些地方和諧共存

執行緒的伸縮性也比非同步IO要差,因為執行緒是具有有限可用性的系統資源.在許多機器上建立數千個執行緒都會失敗,我不建議您首先嚐試它。建立數千個非同步IO任務是完全可行的。

當您有多個IO繫結任務時,非同步IO會閃爍,否則任務將通過阻止IO密集等待時間來控制,例如:

  • 網路IO,無論您的程式是伺服器端還是客戶端
  • 無伺服器設計,例如點對點,多使用者網路,如組聊天室
  • 讀/寫操作,在這種操作中,您想要模仿“發射後不管”的風格,但不必擔心鎖定正在讀寫的內容

不使用await的最大原因是await只支援定義特定方法集的特定物件集。如果要對某個DBMS執行非同步讀取操作,則不僅需要查詢該DBMS的Python包,這個包還必須支援python的async / await語法。包含同步呼叫的協程會阻止其他協程和任務執行。關使用async / await的庫的列表,請參閱本教程末尾的列表

Async IO It Is, but Which One?

本教程重點介紹非同步IO,async / await語法,以及使用asyncio進行事件迴圈管理和指定任務。

asyncio當然不是唯一的非同步IO庫。 Nathaniel J. Smith的觀察說了很多:

[在]幾年後,asyncio可能會發現自己淪落為精明的開發人員避免使用的stdlib庫之一,比如urllib2。……實際上,我所說的是,asyncio是其自身成功的犧牲品:在設計時,它採用了可能的最好方法; 但從那以後,受asyncio啟發的工作 - 比如async / await的加入 - 已經改變了局面,讓我們可以做得更好,現在asyncio受到其早期承諾的束縛。via:(來源)【https://vorpus.org/blog/some-thoughts-on-asynchronous-api-design-in-a-post-asyncawait-world/】

儘管使用不同的api和方法,大名鼎鼎的curiotrio能做asyncio做的事情。就個人而言,我認為如果你正在構建一箇中等規模,簡單的程式,只需使用asyncio就足夠了,而且易於理解,可以避免在Python的標準庫之外新增另一個大的依賴項。

但無論如何,看看curio和trio,你可能會發現他們用一種更直觀的方式完成了同樣的事情。此處介紹的許多與包不相關的概念也應該滲透到備用非同步IO包中。

其他零碎

在接下來的幾節中,您將看到asyncio和async/wait的一些雜項部分,這部分到目前為止還沒有完全融入教程,但是對於構建和理解一個完整的程式仍然很重要。

其他頂級asyncio 函式

除了asyncio.run()之外,您還看到了一些其他的包級函式,如asyncio.create_task()和asyncio.gather()

您可以使用create task()來排程協調程式物件的執行,後面跟著asyncio.run()

   >>> import asyncio

>>> async def coro(seq) -> list:
...     """'IO' wait time is proportional to the max element."""
...     await asyncio.sleep(max(seq))
...     return list(reversed(seq))
...
>>> async def main():
...     # This is a bit redundant in the case of one task
...     # We could use `await coro([3, 2, 1])` on its own
...     t = asyncio.create_task(coro([3, 2, 1]))  # Python 3.7+
...     await t
...     print(f't: type {type(t)}')
...     print(f't done: {t.done()}')
...
>>> t = asyncio.run(main())
t: type <class '_asyncio.Task'>
t done: True

這種模式有一個微妙之處:如果你沒有在main()中await t,它可能在main()本身發出訊號表明它已完成之前完成。因為在沒有await t 的情況下asynio.run(main())呼叫loop.run_until_complete(main()),事件迴圈只關心main()是否完成了,而不是main()中建立的任務是否已經完成。沒有await t,迴圈的其他事件可能在它們完成之前會被取消。如果需要獲取當前待處理任務的列表,可以使用asyncio.Task.all_tasks()。

注意:asyncio.create_task()是在Python 3.7中引入的。在Python 3.6或更低版本中,使用asyncio.ensure_future()代替create_task()。

另外,還有asyncio.gather()。雖然它沒有做任何非常特殊的事情,但是gather()的目的是將一組協程(future)整齊地放到一個單一的future。因此,它返回一個單獨的future物件,如果await asyncio.gather()並指定多個任務或協同程式,則表示您正在等待這些物件全部完成。(這與前面示例中的queue.join()有些相似。)gather()的結果將是跨輸入的結果列表:

>>> import time
>>> async def main():
...     t = asyncio.create_task(coro([3, 2, 1]))
...     t2 = asyncio.create_task(coro([10, 5, 0]))  # Python 3.7+
...     print('Start:', time.strftime('%X'))
...     a = await asyncio.gather(t, t2)
...     print('End:', time.strftime('%X'))  # Should be 10 seconds
...     print(f'Both tasks done: {all((t.done(), t2.done()))}')
...     return a
...
>>> a = asyncio.run(main())
Start: 16:20:11
End: 16:20:21
Both tasks done: True
>>> a
[[1, 2, 3], [0, 5, 10]]

你可能已經注意到gather()等待您傳遞它的Futures或協程的整個結果集。或者,您可以按完成順序迴圈遍歷asyncio.as_completed()以完成任務。該函式返回一個迭代器,在完成任務時生成任務。下面coro([3,2,1])的結果將在coro([10,5,0])完成之前可用,而gather()的情況並非如此:

>>> async def main():
...     t = asyncio.create_task(coro([3, 2, 1]))
...     t2 = asyncio.create_task(coro([10, 5, 0]))
...     print('Start:', time.strftime('%X'))
...     for res in asyncio.as_completed((t, t2)):
...         compl = await res
...         print(f'res: {compl} completed at {time.strftime("%X")}')
...     print('End:', time.strftime('%X'))
...     print(f'Both tasks done: {all((t.done(), t2.done()))}')
...
>>> a = asyncio.run(main())
Start: 09:49:07
res: [1, 2, 3] completed at 09:49:10
res: [0, 5, 10] completed at 09:49:17
End: 09:49:17
Both tasks done: True

最後,你可能還可以看到asyncio.ensure_future()。你應該很少需要它,因為它是一個較低階別的管道API,並且很大程度上被後來引入的create_task()取代。

await的優先順序

雖然它們的行為有些相似,但await關鍵字的優先順序明顯高於yield。這意味著,由於它的繫結更緊密,在很多情況下,您需要在yield from語句中使用括號,而在類似的await語句中則不需要。有關更多資訊,請參見PEP 492中的await表示式示例

總結

你現在已經準備好使用async / await和它構建的庫了。 以下是你已經學到的的內容概述:

  • 非同步IO作為一種與語言無關的模型,通過讓協程彼此間進行間接通訊來實現併發
  • Python中用於標記和定義協程的新關鍵字async、await的一些細節。
  • 提供用於執行和管理協程的API的Python包asyncio

附加資源

Python版本細節

Python中的非同步IO發展迅速,很難跟蹤什麼時候發生了什麼。下面列出了與asyncio相關的Python小版本更改和介紹:

  • 3.3: yield from表示式允許生成器委派
  • 3.4:asyncio以臨時API狀態引入Python標準庫
  • 3.5:async和await成為Python語法的一部分,用於表示和等待協程。它們還沒有成為保留關鍵字(您仍然可以定義名為async和await的函式或變數)。
  • 3.6:引入非同步生成器和非同步理解/鏈、推導。asyncio的API被宣告為穩定的,而不是臨時的。
  • 3.7:async和await成為保留關鍵字(它們不能用作識別符號。)。它們用於替換asyncio.coroutine()裝飾器。asyncio.run()被引入asyncio包,其中包括許多其他功能

如果您想要安全(並且能夠使用asyncio.run()),請使用Python 3.7或更高版本來獲取完整的功能集。

相關文章

以下是其他資源的精選列表:

Python文件的 What’s New 部分更詳細地解釋了語言變化背後的動機:

來自David Beazley的:

YouTube 視訊:

相關PEPs

PEP 建立時間
PEP 342 – 通過增強型生成器的協程 2005-05
PEP 380 – 委託給子生成器的語法 2009-02
PEP 3153 – 非同步IO支援 2011-05
PEP 3156 – 非同步IO支援重新啟動:“asyncio”模組 2012-12
PEP 492 – async和await語法的協程 2015-04
PEP 525 – 非同步生成器 2016-07
PEP 530 – Asynchronous Comprehensions 2016-09

使用async/await的庫

來自 aio-libs:

  • aiohttp: 非同步HTTP客戶端/伺服器框架
  • aioredis: 非同步IO Redis支援
  • aiopg: 非同步IO PostgreSQL 支援
  • aiomcache: 非同步IO memcached 客戶端
  • aiokafka: 非同步IO Kafka 客戶端
  • aiozmq: 非同步IO ZeroMQ 支援
  • aiojobs:用於管理後臺任務的作業排程程式
  • async_lru: 用於非同步IO的簡單LRU快取

來自 magicstack:

  • uvloop:超快的非同步IO事件迴圈
  • asyncpg: (也非常快)非同步IO PostgreSQL支援

來自其他:

  • trio: 更友好的“asyncio”,旨在展示一個更加簡單的設計
  • aiofiles: 非同步 檔案 IO
  • asks: 非同步類requests的http 庫
  • asyncio-redis: 非同步IO Redis 支援
  • aioprocessing: 將multiprocessing模組與asyncio整合在一起
  • umongo: 非同步IO MongoDB 客戶端
  • unsync: Unsynchronize asyncio
  • aiostream:類似'itertools',但非同步

我的感想:

其實讀完這篇文章,我相信有很多人仍舊會有困惑——非同步IO底層到底是怎麼實現的?早些時候我也很困惑,要說多執行緒多程式我們很好理解,因為我們知道常用的現代計算機是根據時間片分時執行程式的。到了非同步IO或者協程這裡竟然會出現一段沒有CPU參與的時間。在我學習Javascript/nodejs的時候就更困惑了,web-base的javascript和backend nodejs都是單執行緒設計的,它的定時器操作怎麼實現的?它的介面非同步操作怎麼實現的?後來讀了《UNIX環境高階程式設計》才有種“恍然大悟”的感覺。在學習程式語言的時候,往往認為語言本身是圖靈完備的,程式語言設定的規則就是整個世界。但實際上,程式語言的圖靈完備僅體現在邏輯和運算上,其他的一些設施底層不是語言本身就能夠完全解釋的。我們,至少是我自己,在學習一個語言工具的時候往往忽略了一個早就知道的現實——現代常規的程式設計,都是面向作業系統的程式設計!無論是多執行緒、多程式還是非同步IO本身都是作業系統提供的功能。多餘web-base的javascript更是面向瀏覽器程式設計。瀏覽器不提供非同步IO相關的功能,Web-base 的javascript本身是沒辦法實現的,作業系統不支援非同步IO,什麼語言也不行~golang的go程也不過是從系統手中接管了生成執行緒之後的再分配管理。正像Linux/Unix程式設計標準是兩個的合體——ANSI C + POSIX,我們學習的語言正對應ANSI C,但多執行緒、多程式、訊號這些東西本身不是語言規範裡面的,他們是POSIX裡的,是作業系統的規範,是作業系統提供的!再進一步,為什麼作業系統能實現?因為硬體支援這樣的實現!

相關文章