[python] Python非同步程式設計庫asyncio使用指北

落痕的寒假發表於2024-11-22

Python的asyncio模組提供了基於協程(coroutines)的非同步程式設計(asynchronous programming)模型。作為一種高效的程式設計正規化,非同步程式設計允許多個輕量級任務併發執行,且相比傳統的多執行緒模型,具有更低的記憶體消耗。因此,asyncio在需要高併發處理的場景中,尤其是在Web開發、網路請求、API呼叫和套接字程式設計等領域,得到了廣泛應用。本文將詳細介紹如何在Python中使用asyncio進行非同步程式設計。

本文在學習asyncio的過程中參考了以下文獻:

  • asyncio — Asynchronous I/O
  • Python Asyncio: The Complete Guide
  • 深度解密 asyncio

目錄
  • 1 非同步程式設計介紹
    • 1.1 什麼是非同步任務
    • 1.2 Python中的非同步程式設計
      • 1.2.1 非阻塞I/O與非同步程式設計
      • 1.2.2 asyncio模組介紹
    • 1.3 Python併發單元的選擇與比較
  • 2 asyncio的使用
    • 2.1 協程的使用
    • 2.2 asyncio任務的使用
      • 2.2.1 asyncio任務建立和執行
      • 2.2.2 asyncio任務狀態
      • 2.2.3 asyncio任務獲取
      • 2.2.4 asyncio任務等待
      • 2.2.5 asyncio任務保護
      • 2.2.6 asyncio中執行阻塞任務
    • 2.3 非同步程式設計模型
      • 2.3.1 非同步迭代器
      • 2.3.2 非同步生成器
      • 2.3.3 非同步推導式
      • 2.3.4 非同步上下文管理器
    • 2.4 asyncio中的非阻塞流
      • 2.4.1 非阻塞流介紹
      • 2.4.2 使用asyncio檢查HTTP狀態
      • 2.4.3 asyncio中的流使用示例
  • 3 參考

1 非同步程式設計介紹

非同步程式設計是一種非阻塞的程式設計正規化。在這種正規化中,請求和函式呼叫會在未來某個時刻以某種方式在後臺執行。非阻塞意味著當一個請求被髮出時,程式不會停下來等待該請求的結果,而是會繼續執行後續的操作。當請求的結果準備好時,程式會在適當的時機處理該結果,而不會影響程式其他部分的執行。因此,呼叫者可以繼續執行其他任務,並在結果準備好或需要時,稍後處理已發出的呼叫結果。

1.1 什麼是非同步任務

非同步操作指的是在程式執行時,有些任務不會立即完成,而是安排在未來某個時刻執行。與同步操作不同,後者要求任務在當前步驟中完成。

非同步函式呼叫(Asynchronous Function Call)是實現非同步操作的一種方式。這種方式允許程式在等待某些任務完成時,繼續執行其他工作,從而避免程式被卡住,提升效率。

通常,非同步函式呼叫會返回一個被稱為“未來”(Future)的物件(控制代碼)。這個物件可以看作是一個指向非同步操作結果的識別符號。程式可以透過它來檢視任務的進度,或者等到任務完成時獲取最終結果。這樣,程式就可以在等待任務完成時做其他事情,而不是一直停下來等。

結合非同步函式呼叫和Future,就得到了非同步任務(Asynchronous Task)的概念。非同步任務不僅僅是呼叫一個函式,它還包括瞭如任務取消、錯誤處理等更多的內容。這樣,程式就可以更加靈活和高效地管理多個任務,提高併發性和整體效能。

簡單來說,以下是這幾個概念的總結:

  • 非同步函式呼叫:指觸發一個函式執行的請求,它會在未來某個時刻開始執行,而不會阻止程式繼續做其他事情。
  • Future:是非同步函式呼叫的一個識別符號,允許呼叫者檢查任務的狀態,並在任務完成時獲取結果。
  • 非同步任務:指代一個包含非同步函式呼叫和結果(Future)的集合,用於管理和跟蹤整個非同步操作的過程。

1.2 Python中的非同步程式設計

1.2.1 非阻塞I/O與非同步程式設計

輸入input/輸出output,簡稱I/O,指的是從資源中讀取或寫入資料。以下是I/O操作的一些典型應用場景:

  • 硬碟驅動器:對檔案進行讀取、寫入、追加、重新命名、刪除等操作。
  • 外圍裝置:滑鼠、鍵盤、螢幕、印表機、序列裝置、攝像頭等。
  • 網際網路:下載和上傳檔案、獲取網頁、查詢RSS等。
  • 資料庫:執行選擇、更新、刪除等SQL查詢。
  • 電子郵件:傳送郵件、接收郵件、查詢收件箱等。

相較於中央處理器(CPU)執行的計算任務,I/O操作通常具有較低的效率。在程式設計中,I/O請求通常以同步方式實現,即發起I/O請求的執行緒在資料傳輸完成之前會被掛起,等待操作完成。這種模式被稱為阻塞式I/O(Blocking I/O)。在此模式下,作業系統能夠識別執行緒的阻塞狀態,並執行上下文切換,以便排程其他可執行的執行緒,從而最佳化CPU資源的利用率。儘管如此,阻塞式I/O操作會導致發起請求的執行緒或程序在I/O操作完成前無法繼續執行。雖然這種設計不會對整個系統的執行造成影響,但它確實會在I/O操作期間暫時阻塞發起請求的執行緒或程序,影響其響應性和併發處理能力。

作為對阻塞I/O的替代方案,非阻塞I/O提供了更高效的選擇。與阻塞I/O類似,非阻塞I/O同樣需要底層作業系統的支援,但現代作業系統普遍提供了某種形式的非阻塞I/O功能。透過非阻塞I/O,應用程式可以以非同步方式發起讀寫請求,作業系統將負責處理這些請求,並在資料準備好時通知應用程式。

非同步程式設計(Asynchronous Programming)是一種專門用於處理非阻塞I/O操作的程式設計方式。與傳統的阻塞I/O不同,非阻塞I/O使得系統在發出讀寫請求後不會等待操作完成,而是可以同時處理其他請求。操作結果或資料會在準備好時返回,或者在需要時提供給程式。因此,非阻塞I/O是實現非同步程式設計的核心技術,通常這兩者被統稱為非同步I/O。

1.2.2 asyncio模組介紹

在Python中,非同步程式設計泛指非阻塞的請求處理方式,即發起請求後不暫停程式執行,而是繼續處理其他任務。Python支援多種非同步程式設計技術,其中部分與併發性緊密相關。為了支援非同步程式設計,Python3.4版本首次引入了asyncio(asynchronous I/O的縮寫)模組,為非同步程式設計提供了基礎設施。隨後,在Python 3.5版本中,引入了async/await語法。其中:

  • asyncio模組旨在支援非同步程式設計,並提供了底層和高階API。高階API提供了執行非同步任務、處理回撥、執行I/O操作等工具。而底層API則為高階API提供了支撐,包括事件迴圈的內部機制、傳輸協議和策略等。

  • async/await語法的引入是為了更好地支援協程,這是asyncio模組中實現併發的核心。因為協程提供了一種輕量級的併發方式,可以讓單個執行緒在多個任務之間高效切換,從而實現併發執行,而無需使用傳統的執行緒或程序。

協程是一種特殊的函式,它能夠在執行過程中的多個點被暫停和恢復,實現協作式的多工處理。與傳統的子程式或函式相比,協程提供了更靈活的控制流,允許在多個點進行進入、退出和恢復執行。

目前,asyncio模組是Python非同步程式設計的常用工具,它結合async/await語法和非阻塞I/O操作,為開發者提供了一個全面的非同步程式設計框架。那麼為什麼要在Python程式使用非同步程式設計:

  • 提升併發效能:透過使用協程,asyncio使得程式能夠以單執行緒的方式高效地處理大量併發任務,避免了傳統多執行緒程式設計中的複雜性和資源消耗。
  • 簡化非同步程式設計:asyncio提供了一套簡潔的非同步程式設計範例,使得編寫和維護非同步程式碼變得更加容易,同時也提高了程式碼的可讀性和可維護性。
  • 最佳化I/O操作:asyncio支援非阻塞I/O操作,這意味著程式在等待I/O操作(如檔案讀寫、網路通訊等)時,不會阻塞主執行緒,從而可以同時執行其他任務,顯著提高了I/O操作的效率。

1.3 Python併發單元的選擇與比較

執行緒、程序、協程

在現代程式設計中,有效地處理併發是提高程式效能和響應能力的關鍵。Python提供了多種併發單元,包括執行緒、程序和協程,每種都有其特定的用途和優勢。以下是對這三種併發單元的詳細介紹:

  1. 執行緒(Threads)

執行緒是一種併發單元,Python中由threading模組提供,並得到作業系統的支援。執行緒適合處理阻塞I/O任務,例如從檔案、套接字和裝置中進行讀寫操作。然而,由於全域性直譯器鎖(GIL)的存在,Python中的執行緒在執行CPU密集型任務時效率不高。

  1. 程序(Processes)

程序也是由作業系統支援的併發單元,Python中由multiprocessing模組提供。程序適合執行CPU密集型任務,尤其是那些不需要大量程序間通訊的計算任務。與執行緒相比,程序可以繞過全域性直譯器鎖,因此在處理CPU密集型任務時更為高效。

  1. 協程(Coroutines)

協程是Python語言和執行時(標準直譯器)提供的併發單元,Python中由asyncio模組進一步支援。相較於執行緒,程式中可以擁有更多的協程同時執行。協程適用於非阻塞I/O操作,如與子程序和套接字的互動。此外,雖然阻塞I/O和CPU密集型任務並非協程的直接應用場景,但可以透過在後臺使用執行緒和程序以模擬非阻塞的方式執行這些任務。

關於執行緒、程序、協程的詳細介紹見:程序、執行緒、協程

https://semfionetworks.com/blog/multi-threading-vs-multi-processing-programming-in-python/

在Python中使用協程的利弊

在Python中,使用協程相比於執行緒和程序有以下幾個主要好處:

  • 輕量級:協程的建立和切換比執行緒和程序更高效,消耗的資源更少。
  • 避免上下文切換開銷:協程切換由程式控制,不依賴作業系統排程,減少了CPU時間消耗。
  • 共享記憶體:協程在同一執行緒內執行,資料共享更簡單,不需要鎖和複雜的同步機制。
  • 簡潔的程式碼結構:協程使非同步程式碼能夠以同步的方式書寫,程式碼更易理解和維護。
  • 高併發處理:協程非常適合處理大量I/O密集型任務,能夠高效利用單個執行緒實現併發。

當然在Python中使用協程也存在一些缺點:

  • 程式設計複雜性:協程要求開發者理解非同步程式設計模式,這增加了程式設計的複雜性,尤其是在編寫、測試和除錯非同步程式碼時。
  • 庫支援有限:並非所有Python庫都支援非同步操作,這可能限制了協程在某些場景下的應用。
  • 除錯難度:非同步程式碼的除錯比同步程式碼更困難,因為傳統的除錯工具可能無法有效地跟蹤協程的執行流程。
  • 錯誤處理:非同步程式碼中的錯誤處理比同步程式碼更為複雜,因為需要處理協程掛起和恢復時的狀態。
  • 執行機制:根據Python底層設計,協程在執行時是協作式的,一次只能執行一個協程。這種機制類似於全域性直譯器鎖下的執行緒執行。

2 asyncio的使用

2.1 協程的使用

瞭解協程的建立和執行是學習asyncio庫的基礎,因為asyncio正是透過協程實現非同步程式設計的。因此,在學習asyncio之前,先掌握協程的基本概念非常重要。

協程是非同步程式設計中實現併發的核心,它是一種特殊的函式,能夠在執行過程中暫停並稍後恢復。與傳統的函式不同,傳統函式只能在一個固定的入口和出口點執行,而協程則允許在多個地方掛起、恢復或退出。這種特性使得協程在執行時可以暫停並等待其他任務完成,比如等待其他協程的執行結果、外部資源的返回(例如網路連線或資料處理),然後再繼續執行。正是因為協程具備這種暫停和恢復的能力,它們能夠同時執行多個任務,而且能夠精確控制任務何時暫停和何時恢復。

協程的定義

在Python中,協程可以透過使用async def關鍵字來定義。這種定義方式允許協程接受引數,並在執行完畢後返回一個值,類似於常規函式的行為:

# 定義一個協程
async def custom_coroutine():
    # 協程體,可以包含非同步操作
    pass

使用async def宣告的協程被稱為“協程函式”,這是一種特殊的函式,其返回值是一個協程物件。協程函式在其內部使用await(用於等待另一個協程完成)、async for(用於非同步迭代)和async with(用於非同步上下文管理器)等關鍵字來處理非同步操作。如下所示:

# 定義一個非同步協程
async def custom_coroutine():
    # 等待另一個協程執行
    # await表示式將暫停當前協程的執行,並將控制權轉交給被等待的協程,以便其能夠執行
    await asyncio.sleep(1)

協程的建立

在定義了協程之後,可以建立具體的協程例項:

# 例項化協程
coroutine_instance = custom_coroutine()

需要注意的是,呼叫協程函式本身並不會導致任何使用者定義的程式碼被執行,其作用僅限於建立並返回一個coroutine物件。coroutine物件是Python中的一種特殊物件型別,它提供瞭如send()close()等方法,用於控制協程的執行流程和生命週期管理。

可以透過建立協程例項並呼叫type()函式來報告其型別來演示這一點:

import asyncio
# 定義協程
async def custom_coroutine():
    print("執行自定義協程")
    # 等待另一個協程
    await asyncio.sleep(1)

# 建立協程
coro = custom_coroutine()
# 檢查協程的型別
print(type(coro))

程式碼執行結果為:

<class 'coroutine'>
sys:1: RuntimeWarning: coroutine 'custom_coroutine' was never awaited

該警告是因為在Python中,當定義一個協程函式並呼叫它時,返回的是一個協程物件,而不是立即執行。這個協程物件需要透過await關鍵字或事件迴圈來執行。程式碼中的print(type(coro))正確地列印出了協程物件的型別。但是,由於協程沒有被await,所以會有一個RuntimeWarning警告。

協程的執行

協程可以被定義和建立,但只有在事件迴圈中才能執行。事件迴圈是非同步應用程式的核心,負責排程非同步任務和回撥,處理網路 I/O 操作。它還負責協調協程之間的協作和多工處理。

事件迴圈的工作方式類似於一個不斷執行的“排程器”,它會檢查哪些任務已經準備好執行,並按順序執行這些任務。如果某個任務需要等待,例如等待網路響應或檔案讀取,事件迴圈會暫時掛起這個任務,並把控制權交給其他可以繼續執行的任務。這樣,程式就可以在等待的同時,處理其他任務,從而避免了阻塞操作。

通常,透過呼叫 asyncio.run() 函式啟動事件迴圈。該函式會啟動事件迴圈並接收一個協程作為引數,等待協程執行完成並返回結果。程式碼示例如下:

import asyncio

# 定義協程
async def custom_coroutine():
    print("執行自定義協程")
    # 等待另一個協程
    await asyncio.sleep(1)

# 建立協程
coro = custom_coroutine()

# 執行協程
asyncio.run(coro)

# 檢查協程的型別
print(type(coro))

程式碼執行結果為:

執行自定義協程
<class 'coroutine'>

2.2 asyncio任務的使用

在asyncio框架中,任務(Task)是協程的封裝,它將協程交給事件迴圈排程執行。任務通常由協程建立,並在事件迴圈中執行,但它獨立於協程本身。建立任務時,不需要等待其完成,可以繼續執行其他操作。任務物件代表一個將在事件迴圈中非同步執行的操作,藉助任務管理,可以更方便地處理非同步程式設計中的複雜場景。

協程是非同步操作的基本單元,任務則負責管理和排程這些協程。任務不僅支援同時執行多個協程,還能處理結果、取消任務和捕獲異常等。如果只關注協程,而忽視任務,就無法有效排程多個非同步操作,也難以管理任務的生命週期。因此,理解任務對於掌握非同步程式設計至關重要。

任務的生命週期可以從多個階段來描述。首先,任務是由協程(coroutine)建立的,並被安排在事件迴圈(event loop)中獨立執行。隨著時間的推移,任務會進入執行狀態。在執行過程中,任務可能會因為等待其他協程或任務完成而被掛起(suspended)。任務有可能在正常情況下完成並返回結果,或者由於某些異常而失敗。如果有其他協程介入,任務也可能會被取消(canceled)。一旦任務完成,它將無法再被重新執行。因此,任務的生命週期可以總結為以下幾個階段:

  1. 建立(Created):任務被建立,但尚未開始執行。
  2. 排程(Scheduled):任務被安排到事件迴圈中,準備開始執行。
  3. 取消(Canceled):任務在執行之前或執行過程中被取消。
  4. 執行(Running):任務開始執行,進入活躍狀態。
  5. 掛起(Suspended):任務在執行過程中被掛起,等待其他操作完成。
  6. 結果(Result):任務成功完成並返回結果。
  7. 異常(Exception):任務執行過程中遇到錯誤或異常,導致失敗。
  8. 完成(Done):任務無論是否成功,都已結束,無法再執行。

https://superfastpython.com/asyncio-task-life-cycle/

2.2.1 asyncio任務建立和執行

任務的建立

任務是透過協程例項建立的,因此只能在協程內部建立和排程。可以使用 asyncio.create_task() 函式來建立任務,該函式會返回一個 asyncio.Task 例項:

import asyncio
# 定義協程
async def custom_coroutine():
    # 等待另一個協程
    await asyncio.sleep(1)

# 建立協程
coro = custom_coroutine()

# 從協程建立任務
# name引數為設定任務的名稱
task = asyncio.create_task(coro, name='task')
# 也可以用函式設定任務名稱
task.set_name('MyTask')

此外,asyncio.ensure_future函式也可以用來建立和安排任務,它會確保返回一個Future或Task例項:

# 建立並安排任務
task = asyncio.ensure_future(custom_coroutine())

當然也可以直接透過事件迴圈來建立任務,可以使用事件迴圈物件的create_task方法。示例如下:

# 獲取當前事件迴圈
loop = asyncio.get_event_loop()
# 建立並安排任務
task = loop.create_task(custom_coroutine())

任務的執行

在建立任務後,儘管可以使用create_task函式將協程安排為獨立任務,但任務未必會立即執行。任務的執行依賴於事件迴圈的排程,它會在其他所有協程執行完成後才會開始。例如,在一個asyncio程式中,若某個協程建立並安排了任務,任務只有在該協程掛起後才有可能開始執行。具體來說,任務的執行通常會等到協程進入休眠、等待其他協程或任務時,才會被事件迴圈排程執行:

import asyncio

# 定義一個簡單的非同步函式
async def my_coroutine(name):
    print(f"任務 {name} 開始執行")
    await asyncio.sleep(1)  # 模擬任務的延時
    print(f"任務 {name} 完成")

# 獲取事件迴圈
loop = asyncio.get_event_loop()

print("任務建立之前")

# 建立任務並加入事件迴圈
task = loop.create_task(my_coroutine("A"))
print("任務建立之後,任務已加入事件迴圈")

# 執行事件迴圈,直到任務完成
loop.run_until_complete(task)

# 關閉事件迴圈
loop.close()

print("事件迴圈已關閉")

程式碼執行結果為:

任務建立之前
任務建立之後,任務已加入事件迴圈
任務 A 開始執行
任務 A 完成
事件迴圈已關閉

多工的執行

gather函式可以同時啟動多個協程(任務)併發執行,同時將它們儲存在一個集合中進行管理。它還支援等待所有協程執行完成,並且可以提供取消操作的功能。

以下是具體的程式碼示例:

# 併發執行多個協程,並返回結果
# 如果協程沒有返回值,則返回None
results = asyncio.gather(coro1(), coro2())

或者,使用展開語法:

# 使用展開語法收集多個協程
asyncio.gather(*[coro1(), coro2()])

需要注意的是,直接傳遞一個協程列表是無效的:

# 直接傳遞協程列表是不允許的
asyncio.gather([coro1(), coro2()])

2.2.2 asyncio任務狀態

本節將介紹以下內容:

  • 任務的完成與取消
  • 獲取任務結果
  • 任務異常的處理
  • 任務回撥函式的使用

任務完成與取消

在任務建立完成後,需要重點檢查兩個關鍵狀態:一是任務是否已順利完成;二是任務是否已被正式取消。可以透過done()方法來確認任務是否已完成,透過 cancelled()方法來檢查任務是否已被取消。示例程式碼如下:

import asyncio

# 非同步任務1: 列印任務開始、等待1秒並列印任務完成
async def task_completed():
    print("任務1正在執行")
    await asyncio.sleep(1)  # 模擬非同步操作,暫停1秒
    print("任務1完成")

# 非同步任務2: 列印任務開始、等待2秒並列印任務完成
async def task_cancelled():
    print("任務2正在執行")
    await asyncio.sleep(2)  # 模擬非同步操作,暫停2秒
    print("任務2完成")

# 主非同步函式
async def main():
    # 建立並啟動兩個非同步任務
    task1 = asyncio.create_task(task_completed())  # 建立任務1
    task2 = asyncio.create_task(task_cancelled())  # 建立任務2

    # 等待task1任務完成
    await task1

    # 取消task2任務
    task2.cancel()
    
    # 異常處理: 捕獲任務2被取消的異常
    try:
        await task2  # 嘗試等待task2完成
    except asyncio.CancelledError:
        # 如果task2被取消
        pass

    # 檢查task1是否完成
    if task1.done():
        print("任務1已完成")

    # 檢查task2是否被取消
    if task2.cancelled():
        print("任務2已取消")

# 執行主非同步函式
asyncio.run(main())  # 啟動事件迴圈,執行main函式

程式碼執行結果為:

任務1正在執行
任務2正在執行
任務1完成
任務1已完成
任務2已取消

任務結果獲取

透過呼叫 result() 方法可以獲取任務執行的結果。如果任務中包含的協程函式有返回值,則 result() 方法將返回該值;若協程函式未顯式返回任何值,則預設返回 None

若任務已被取消,在嘗試呼叫 result() 方法時會觸發 CancelledError 異常。因此,建議在呼叫 result() 方法前先檢查任務是否已被取消:

# 檢查任務是否未被取消
if not task.cancelled():
    # 獲取包裝協程的返回值
    value = task.result()
else:
    # 任務已被取消

如果任務尚未完成,在呼叫 result() 方法時會丟擲 InvalidStateError 異常。因此,在呼叫 result() 方法之前,最好先確認任務是否已完成:

# 檢查任務是否已完成
if not task.done():
    await task
# 獲取包裝協程的返回值
value = task.result()

任務異常處理

可以透過 exception() 方法獲取協程未處理的異常資訊。若任務執行過程中發生異常,使用該方法能夠捕獲並返回該異常:

import asyncio

# 定義一個協程,模擬異常
async def faulty_coroutine():
    raise ValueError("協程中發生了錯誤。")

# 主程式
async def main():
    # 建立協程任務
    task = asyncio.create_task(faulty_coroutine())
    
    # 等待任務執行完成,捕獲異常
    try:
        await task
    except Exception as e:
        # 獲取任務中的異常
        exception = task.exception()
        print(f"任務異常: {exception}")

# 執行事件迴圈
asyncio.run(main())

在這個示例中,建立了一個協程faulty_coroutine,該協程在執行時會引發 ValueError異常。透過task.exception()方法捕獲並列印該異常資訊。執行結果將顯示:

任務異常: 協程中發生了錯誤。

任務的回撥函式

透過add_done_callback()方法可以為任務指定一個完成時觸發的回撥函式。這個方法需要傳入一個函式名,該函式將在任務完成時被呼叫。注意,任務完成可以發生在以下幾種情況:包裝的協程正常結束、返回結果、丟擲未捕獲的異常,或者任務被取消。以下是如何定義和註冊一個完成回撥函式的示例:

# 定義完成回撥函式
def handle(task):
    print(task)

# 為任務註冊完成回撥函式
task.add_done_callback(handle)

同樣地,如果需要,可以使用remove_done_callback()方法來刪除或取消之前註冊的回撥函式:

# 取消註冊的回撥函式
task.remove_done_callback(handle)

但是要注意的是回撥函式通常是普通的Python函式,無法進行非同步操作:

import asyncio

# 非同步函式
async def my_coroutine():
    print("開始任務")
    await asyncio.sleep(1)
    print("任務完成")
    return "任務完成"

# 回撥函式
def my_callback(task):
    print("回撥函式被呼叫")
    result = task.result()  # 獲取任務的返回結果
    print(f"任務的返回結果是: {result}")

async def main():
    # 建立任務
    task = asyncio.create_task(my_coroutine())

    # 註冊回撥函式
    task.add_done_callback(my_callback)

    # 等待任務完成
    await task

# 執行主函式
asyncio.run(main())

程式碼執行結果為:

開始任務
任務完成
回撥函式被呼叫
任務的返回結果是: 任務完成

2.2.3 asyncio任務獲取

當前任務獲取

可以使用 asyncio.current_task() 方法來獲取當前正在執行的任務。這個方法會返回一個代表當前任務的 Task 物件。以下示例展示瞭如何在主協程中獲取當前任務:

# 從當前協程中獲取當前任務
import asyncio

# 定義主協程
async def main():
    # 輸出開始訊息
    print('主協程已啟動')
    # 獲取當前任務
    current_task = asyncio.current_task()
    # 列印任務詳情
    print(current_task)

# 執行主協程
asyncio.run(main())

上述程式碼列印結果包含任務的名稱和正在執行的協程資訊:

Task pending name='Task-1' coro=<main() 

所有任務獲取

可以使用 asyncio.all_tasks() 函式來檢索asyncio程式中所有已安排和正在執行(尚未完成)的任務。以下示例首先建立了10個任務,每個任務都封裝並執行相同的協程。隨後,主協程捕獲程式中所有已計劃或正在執行的任務集合,並輸出它們的詳細資訊:

import asyncio

# 10個非同步任務
async def task_coroutine(value):
    # 輸出任務執行資訊
    print(f'任務 {value} 開始執行')
    # 模擬非同步等待,使每個任務休眠1秒
    await asyncio.sleep(1)
    
    # 輸出任務執行資訊
    print(f'任務 {value} 結束執行')

# 主協程定義
async def main():
    # 輸出主協程啟動資訊
    print('主協程已啟動')
    # 建立10個任務
    # 注意。任務的執行依賴於事件迴圈的排程,它會在其他所有協程執行完成後才會開始
    # 例如當前協程建立了任務,但是任務只有在當前協程掛起後才有可能開始執行
    started_tasks = [asyncio.create_task(task_coroutine(i), name=f'任務{i}') for i in range(10)]
    # 使得當前的協程(即 main 協程)掛起0.1秒,從而使得子任務執行
    # 如果沒有這句程式碼,也沒有之後的gather函式,子任務會在main函式將要結束時執行,此時事件迴圈還存在
    await asyncio.sleep(0.1)
    # 獲取所有任務的集合
    all_tasks = asyncio.all_tasks()
    # 輸出所有任務的詳細資訊
    for task in all_tasks:
        print(f'> {task.get_name()}, {task.get_coro()}')
    print("等待任務完成")
    # gather會收集傳入的所有任務,並阻塞當前協程,直到所有任務都執行完畢
    # 沒有gather函式,這樣當前協程會直接結束,導致部分任務未能執行或未完成
    await asyncio.gather(*started_tasks)
    # gather函式類似於以下程式碼
    # 逐個等待每個任務完成
    # for task in started_tasks:
    #     await task  # 等待單個任務完成

# 執行非同步程式
asyncio.run(main())

2.2.4 asyncio任務等待

asyncio.wait函式

asyncio.wait()函式用於等待多個 asyncio.Task 例項(即封裝了協程的任務)完成。它允許配置等待策略,比如等待全部任務、第一個完成或第一個出錯的任務。這些任務實際上是 asyncio.Task 類的例項,它們封裝了協程,使得協程可以被排程並獨立執行,並提供了查詢狀態和獲取結果的介面。

asyncio.wait() 函式接收一組可等待物件,通常為 Task 例項,或者 Task 的列表、字典或集合。該函式會持續等待,直到任務集合中的某些條件得到滿足,預設情況下,這些條件是所有任務都已完成。asyncio.wait() 返回一個包含兩個集合的元組:第一個集合是所有已滿足條件的任務物件,稱為“完成集”("done" set);第二個集合是尚未滿足條件的任務物件,稱為“待處理集”("pending" set)。

例如:

tasks = [asyncio.create_task(task_coro(i)) for i in range(10)]
# 等待所有任務完成
done, pending = await asyncio.wait(tasks)

在上面的示例中,asyncio.wait()被加上了 await,原因在於從技術角度來看,asyncio.wait()是一個返回協程的協程函式。await用於暫停非同步函式的執行,以便呼叫 asyncio.wait,從而等待所有任務完成。

等待條件設定

asyncio.wait()函式中,return_when引數允許指定等待的條件,其預設值為asyncio.ALL_COMPLETED,意味著只有當所有任務都完成時,才會停止等待並返回結果。如果將return_when引數設定為asyncio.FIRST_COMPLETED,將等待直到列表中的第一個任務完成。一旦第一個任務完成並從等待集中移除,將繼續執行當前程式碼,但其餘的任務將繼續執行,不會被取消:

import asyncio
import random

# 模擬一個可能需要不同時間完成的非同步任務
async def async_task(name, duration):
    print(f"任務 {name} 開始,預計耗時 {duration} 秒")
    await asyncio.sleep(duration)
    print(f"任務 {name} 完成")
    return f"結果 {name}"

# 主函式,用於啟動和管理非同步任務
async def main():
    # 建立兩個任務,一個快一個慢
    fast_task = asyncio.create_task(async_task("任務1", random.randint(1, 3)), name='任務1')
    slow_task = asyncio.create_task(async_task("任務2", random.randint(4, 6)), name='任務2')

    # return_when=asyncio.FIRST_COMPLETED表示第一個任務以下程式碼完成後,會繼續後續等待,而不用等待其餘任務
    # done和pending都是集合型別(set),包含完成的任務集合和未完成的集合
    done, pending = await asyncio.wait([fast_task, slow_task], return_when=asyncio.FIRST_COMPLETED)
    
    # 處理完成的任務
    for task in done:
        # 提取結果
        result = task.result()
        print(f"{task.get_name()}的執行結果:{result}")

    print(f"已完成任務數:{len(done)}")
    
    # 等待剩餘的任務完成(如果需要)
    if len(pending) >0:
        await asyncio.wait(pending)

# 執行主函式
asyncio.run(main())

此外,可以透過將 return_when 引數設定為 FIRST_EXCEPTION 來等待第一個因異常失敗的任務。如果沒有任務因異常失敗,done 集合將包含所有已完成的任務,且 wait() 函式僅在所有任務完成後才會返回結果。

任務超時

可以透過timeout引數指定等待任務的最大時間(以秒為單位)。如果超時,則函式將返回一個包含當前滿足條件的任務子集的元組,例如,如果等待所有任務完成,則返回的是已完成的任務子集。示例程式碼如下:

import asyncio
import random

# 在新任務中執行的協程
async def task_coro(arg):
    # 模擬不同的執行時間
    value =  random.uniform(0, 2)  # 保證每個任務的執行時間為0到2秒
    await asyncio.sleep(value)
    print(f'> 任務 {arg} 完成,執行時間 {value:.2f} 秒')

# 主協程
async def main():
    # 建立多個任務
    tasks = [asyncio.create_task(task_coro(i)) for i in range(10)]
    
    # 設定最大等待時間為1秒,超時後返回已完成的任務
    done, pending = await asyncio.wait(tasks, timeout=1)
    
    # 列印結果:已完成的任務和待處理的任務
    print(f'已完成的任務數量: {len(done)}')
    print(f'待處理的任務數量: {len(pending)}')
    
    # 如果超時後有任務未完成,顯示它們的狀態
    if pending:
        print('以下任務未在超時時間內完成:')
        for task in pending:
            print(f'- 任務 {tasks.index(task)}')

# 啟動非同步程式
asyncio.run(main())

單個任務等待

asyncio.wait_for() 函式用於等待協程或任務的完成,並提供超時控制。與 asyncio.wait() 不同,wait_for() 僅等待一個任務,並且在超時之前會檢查該任務是否已完成。如果任務未能在指定的超時時間內完成,函式會丟擲 asyncio.TimeoutError 異常。如果沒有設定超時,函式將一直等待任務完成。

wait_for() 會返回一個協程物件,實際執行時需要透過 await 顯式等待結果,或者將其排程為任務。如果超時,任務將被取消。wait_for() 接受兩個引數:

  1. 第一個引數是待等待的協程或任務。
  2. 第二個引數是超時時間(單位為秒),可以是整數或浮點數。如果設定為 None,表示沒有超時限制。

示例程式碼如下:

from random import random
import asyncio

# 定義非同步函式 task_coro,作為協程任務執行
async def task_coro(arg):
    # 生成一個1到2之間的隨機數
    value = 1 + random()
    # 列印接收到的值
    print(f'>任務接收到 {value}')
    # 非同步等待,模擬耗時操作,等待時間由隨機數決定
    await asyncio.sleep(value)
    # 列印任務完成訊息
    print('>任務完成')

# 定義主協程函式 main
async def main():
    # 建立協程任務並傳入引數1
    task = task_coro(1)
    # 嘗試執行任務並設定超時為0.2秒
    try:
        await asyncio.wait_for(task, timeout=0.2)
    except asyncio.TimeoutError:
        # 超時處理:列印任務取消訊息
        print('放棄等待,任務已取消')
    except Exception:
        # 捕獲其他可能的異常
        pass

# 啟動非同步程式,執行主協程
asyncio.run(main())

2.2.5 asyncio任務保護

非同步任務可透過呼叫 cancel() 方法取消。將任務包裝在 asyncio.shield() 中可防止其被取消。asyncio.shield() 會將協程或可等待物件包裝在一個特殊物件中,吸收所有取消請求。即便外部請求取消,任務仍會繼續執行。此功能在非同步程式設計中尤為重要,特別是當某些任務可取消,而其他關鍵任務需持續執行時。asyncio.shield()接受可等待物件並返回一個 asyncio.Future 物件,可直接等待該物件或傳遞給其他任務:

# 防止任務被取消
shielded = asyncio.shield(task)
# 等待遮蔽任務
await shielded

返回的 Future 物件可以透過呼叫 cancel()方法來取消。如果內部任務仍在執行,取消請求會被視為成功。例如:

# 取消遮蔽任務
was_canceled = shielded.cancel()

至關重要的是,向Future物件發出的取消請求並不會傳遞給其內部任務:

import asyncio

async def coro():
    print("任務開始")
    await asyncio.sleep(3)
    print("任務完成")

async def main():
    # 建立非同步任務
    task = asyncio.create_task(coro())
    
    # 使用shield包裝任務,以建立一個不可取消的任務
    shield = asyncio.shield(task)
    
    # 嘗試取消shield,但這不會影響內部的task
    shield.cancel()
    
    try:
        # 等待任務執行完成
        await task
    except asyncio.CancelledError:
        print("任務被取消")

# 啟動非同步事件迴圈
asyncio.run(main())

程式碼執行結果為:

任務開始
任務完成

如果一個正在被遮蔽的任務被取消了,那麼取消請求將會傳遞給shield物件,導致shield物件也被取消,並且會觸發asyncio.CancelledError 異常。以下是程式碼示例:

import asyncio

async def coro():
    print("任務開始")
    await asyncio.sleep(3)
    print("任務完成")

async def main():
    # 建立非同步任務
    task = asyncio.create_task(coro())
    
    # 使用 shield 包裝任務,以建立一個不可取消的任務
    shield = asyncio.shield(task)
    
    # 取消task
    task.cancel()
    
    try:
        # 等待任務執行完成
        await task
    except asyncio.CancelledError:
        print("任務被取消")

# 啟動非同步事件迴圈
asyncio.run(main())

程式碼執行結果為:

任務被取消

最後,以下示例展示瞭如何建立、排程和保護協程任務,首先,建立一個主協程 main(),作為應用程式的入口點,並建立一個任務協程,確保任務不會被取消。隨後,使用 asyncio.shield() 保護任務,將其傳遞給 cancel_task() 協程,在其中模擬shielded任務取消請求。主協程等待該任務並捕獲 CancelledError 異常。任務在執行一段時間後休眠,最終,任務完成並返回結果,shielded 任務被標記為取消,而內部任務則正常完成:

import asyncio

# 定義一個簡單的非同步任務,模擬處理邏輯
async def simple_task(number):
    await asyncio.sleep(1)
    return number

# 定義一個非同步任務,稍後取消指定任務
async def cancel_task(task):
    await asyncio.sleep(0.2)
    was_cancelled = task.cancel()
    print(f'已取消: {was_cancelled}')

# 主協程,排程其他任務
async def main():
    coro = simple_task(1)
    task = asyncio.create_task(coro)
    shielded = asyncio.shield(task)

    # 建立取消任務的協程
    asyncio.create_task(cancel_task(shielded))
    
    try:
        result = await shielded
        print(f'>獲得: {result}')
    except asyncio.CancelledError:
        print('任務已被取消')

    await asyncio.sleep(1)
    
    print(f'保護任務: {shielded}')
    print(f'任務: {task}')

# 啟動主協程
asyncio.run(main())

2.2.6 asyncio中執行阻塞任務

asyncio專注於非同步程式設計和非阻塞I/O操作。然而,非同步應用中執行阻塞函式呼叫是不可避免的,原因包括:

  • 執行CPU密集型任務,如複雜計算。
  • 處理阻塞I/O任務,如檔案讀寫。
  • 呼叫未與asyncio整合的第三方庫。阻塞呼叫會導致事件迴圈暫停,阻止其他協程執行。

asyncio 模組提供了兩種方法來在 asyncio 程式中執行阻塞呼叫。第一種方法是使用 asyncio.to_thread() 函式,它是一個高階API,專為應用程式開發者設計。asyncio.to_thread()在單獨的執行緒中執行並返回一個協程。這個協程可以被等待或排程作為獨立任務執行。同時asyncio.to_thread() 會在後臺建立一個 ThreadPoolExecutor 來執行阻塞操作,因此它適用於 I/O 密集型任務。

在以下程式碼中,asyncio.to_thread的作用是將一個阻塞的同步任務(即 blocking_task() 函式)封裝成非同步任務,並將其交給一個獨立的執行緒池執行。這樣做的目的是避免阻塞主事件迴圈,從而確保其他非同步任務可以繼續執行。具體而言,blocking_task 是一個同步函式,其中的 time.sleep(2) 會阻塞當前執行緒 2 秒。由於執行緒在這段時間內無法執行其他任務,如果在傳統的 asyncio 環境中直接呼叫阻塞函式,事件迴圈會被暫停,無法繼續排程其他任務。但是asyncio.to_thread 的使用確保了阻塞任務不會影響到主事件迴圈的執行:

import asyncio
import time

# 定義一個阻塞 IO 繫結任務
def blocking_task():
    # 報告任務開始
    print('任務開始')
    # 模擬阻塞操作:休眠2秒
    time.sleep(2)  # 這裡的 time.sleep 是阻塞當前執行緒的操作
    # 報告任務結束
    print('任務完成')

# 主協程
async def main():
    # 報告主協程正在執行並啟動阻塞任務
    print('主協程正在執行阻塞任務')

    # 將阻塞任務封裝成協程並透過asyncio.to_thread函式執行
    # asyncio.to_thread 會將阻塞任務分配給一個獨立的執行緒池來執行
    coro = asyncio.to_thread(blocking_task)

    # 建立一個 asyncio 任務來執行上述協程
    # asyncio.create_task 將協程封裝為 Task 物件,使其能夠在後臺執行
    task = asyncio.create_task(coro)

    # 主協程繼續執行其他事情
    print('主協程正在做其他事情')

    # 使用 await asyncio.sleep(0) 允許任務被排程
    # 這個操作確保協程任務有機會開始執行,因為 main() 協程的執行會在此處暫停
    await asyncio.sleep(0)  # 讓出控制權,確保 task 能被執行

    # 等待任務執行完成
    await task

# 執行非同步程式
# asyncio.run(main()) 負責啟動整個非同步事件迴圈並執行 main() 協程
asyncio.run(main())

另一種方法是使用 loop.run_in_executor() 函式,首先透過 asyncio.get_running_loop() 獲取當前事件迴圈。loop.run_in_executor() 函式接收一個執行器和一個要執行的函式,如果傳入 None 作為執行器引數,則預設使用 ThreadPoolExecutor。該函式返回一個可等待物件,可以選擇等待它,且任務會立即開始執行,因此不需要額外等待或安排返回的可等待物件來啟動阻塞呼叫。示例如下:

# 獲取事件迴圈
loop = asyncio.get_running_loop()
# 在單獨的執行緒中執行函式
await loop.run_in_executor(None, task)

或者,可以建立一個執行器並將其傳遞給loop.run_in_executor()函式,該函式將在執行器中執行非同步呼叫。在這種情況下,呼叫者必須管理執行器,在呼叫者完成後將其關閉:

# 建立程序池
with ProcessPoolExecutor as exe:
    # 獲取事件迴圈
    loop = asyncio.get_running_loop()
    # 在單獨的執行緒中執行函式
    await loop.run_in_executor(exe, task)
    # 程序池自動關閉...

2.3 非同步程式設計模型

2.3.1 非同步迭代器

迭代是Python中的基本操作,asyncio提供了對非同步迭代器的支援。透過定義實現__aiter____anext__方法的物件,能夠在asyncio程式中建立並使用非同步迭代器(Asynchronous Iterators)。

迭代器

迭代器是實現了迭代協議的Python物件。具體而言,__iter__()方法返回迭代器自身,而__next__()方法使迭代器前進並返回下一個元素。當沒有更多資料時,迭代器會引發StopIteration異常。可以透過內建函式next()逐步獲取迭代器中的元素,或者使用for迴圈自動遍歷迭代器:

# 定義一個名為 MyIterator 的迭代器類
class MyIterator:
    # 初始化方法,接收兩個引數:start 和 end,定義迭代的起始值和結束值
    def __init__(self, start, end):
        self.current = start  # 設定當前迭代的位置,初始為 start
        self.end = end        # 設定迭代的結束值

    # 定義迭代器的 __iter__ 方法,返回迭代器自身
    def __iter__(self):
        return self  # 迭代器物件自身是可迭代的

    # 定義迭代器的 __next__ 方法,用於獲取下一個值
    def __next__(self):
        # 如果當前值已經達到或超過結束值,丟擲 StopIteration 異常,表示迭代結束
        if self.current >= self.end:
            raise StopIteration
        self.current += 1  # 將當前值加 1
        return self.current - 1  # 返回當前值,減去 1 是因為在加 1 後,當前值已經遞增過

# 建立 MyIterator 類的一個例項,從 2開始,結束值為 5
my_iter = MyIterator(2, 5)

# 逐步獲取元素,呼叫 next(my_iter) 獲取迭代器中的下一個元素
print(next(my_iter))  # 輸出 2
print(next(my_iter))  # 輸出 3

# 使用 for 迴圈遍歷MyIterator類例項,這裡會自動呼叫 __iter__ 和 __next__ 方法
# MyIterator(0, 3)建立一個新的迭代器例項,迭代的範圍是從 0 到 3(不包含 3)
for number in MyIterator(0, 3):
    print(number)

非同步迭代器

非同步迭代器是實現了__aiter__()__anext__()方法的Python物件。__aiter__()返回迭代器例項,__anext__()返回一個可等待物件,用於執行迭代步驟。非同步迭代器只能在asyncio程式中使用,可以透過async for表示式遍歷,自動呼叫__anext__()並等待其結果。與普通的for迴圈不同,async for適用於處理非同步操作,如網路請求或檔案讀取。

要建立非同步迭代器,只需定義實現這兩個方法的類。__anext__()必須返回一個可等待物件,並使用async def定義。迭代結束時,__anext__()應丟擲StopAsyncIteration異常。由於非同步迭代器依賴asyncio事件迴圈,迭代過程中的每個物件都在協程中執行並等待:

# 定義一個非同步迭代器
class AsyncIterator():
    # 建構函式,初始化一些狀態
    def __init__(self):
        self.counter = 0

    # 實現迭代器協議的 __aiter__方法
    def __aiter__(self):
        return self

    # 實現非同步的 __anext__ 方法
    async def __anext__(self):
        # 如果沒有更多專案,丟擲StopAsyncIteration異常
        if self.counter >= 10:
            raise StopAsyncIteration
        # 增加計數器
        self.counter += 1
        # 返回當前計數器值
        return self.counter
# 建立迭代器
it = AsyncIterator()

透過使用async for表示式在迴圈中遍歷非同步迭代器,該表示式將自動等待迴圈的每次迭代:

import asyncio
it = AsyncIterator()

async def main():
    # 遍歷非同步迭代器
    async for result in AsyncIterator():
        print(result)

# 啟動非同步任務
asyncio.run(main())

如果使用的是Python 3.10或更高版本,可以使用anext內建函式遍歷迭代器的一步,就像使用next函式的經典迭代器一樣:

import asyncio
# 獲取迭代器一步的等待
awaitable = anext(it)
# 執行迭代器的一步並得到結果
result = await awaitable

2.3.2 非同步生成器

生成器是Python的基本組成部分,指的是包含至少一個yield表示式的函式。與常規函式不同,生成器函式可以在執行過程中暫停,並在後續恢復執行,這種特性與協程相似。實際上,Python中的協程是生成器的擴充套件。透過asyncio庫,能夠實現非同步生成器,而非同步生成器(Asynchronous Generators)則是基於協程中yield表示式的應用。

生成器

生成器是一個Python函式,它透過 yield 表示式逐步返回值。每當生成器遇到 yield 時,它會返回一個值並暫停執行。下一次呼叫生成器時,它會從暫停的位置繼續執行,直到再次遇到 yield。雖然生成器可以透過內建的 next() 函式逐步執行,但通常更常見的做法是使用迭代器,如 for 迴圈或列表推導式,來遍歷生成器並獲取所有返回的值:

# 定義一個簡單的生成器函式
def count_up_to(max):
    count = 1
    while count <= max:
        yield count  # 暫停並返回當前值
        count += 1

# 使用 next() 函式逐步執行生成器
counter = count_up_to(5)
print(next(counter))  # 輸出 1
print(next(counter))  # 輸出 2
print(next(counter))  # 輸出 3

# 使用 for 迴圈遍歷生成器
for num in count_up_to(5):
    print(num)  # 輸出 1, 2, 3, 4, 5

# 也可以使用列表推導式將生成器的結果轉為列表
numbers = [num for num in count_up_to(5)]
print(numbers)  # 輸出 [1, 2, 3, 4, 5]

非同步生成器

非同步生成器是使用 yield 表示式的特殊協程,與普通生成器不同,它能在執行時暫停並等待其他協程或任務完成。非同步生成器函式建立一個非同步迭代器,無法透過 next() 遍歷,而需使用 anext() 獲取下一個值。這使得非同步生成器支援 async for 語法,每次迭代時都會暫停,等待任務完成後繼續執行。簡單來說,非同步生成器在每次迭代時像一個等待中的任務,async for 會排程並等待其結果。

非同步生成器透過定義一個包含至少一個 yield 表示式的協程實現,且該函式需使用 async def 語法。由於它本質上是一個協程,每個迭代返回的是一個等待物件,這些物件在 asyncio 事件迴圈中被排程執行,可以在生成器中等待這些物件:

# 定義一個非同步生成器
async def async_generator():
    for i in range(10):
        # 暫停並睡眠一會兒
        await asyncio.sleep(1)
        # 向呼叫者產生一個值
        yield i
        
# 建立迭代器
gen = async_generator()

可以使用 async for 表示式在迴圈中遍歷非同步生成器,該表示式會自動等待每次迭代的結果:

# 非同步執行的入口點
import asyncio
async def main():
    # 非同步迭代生成器並列印結果
    async for result in async_generator():
        print(result)
    
    # 使用列表推導式收集所有結果
    results = [item async for item in async_generator()]
    print(results)

asyncio.run(main())

如果使用的是 Python 3.10 或更高版本,可以使用 anext() 內建函式遍歷迭代器的一步,就像使用 next() 函式的經典迭代器一樣:

import asyncio
# 獲取生成器一步的等待值
awaitable = anext(gen)
# 執行生成器的一步並得到結果
result = await awaitable

2.3.3 非同步推導式

非同步推導式(Asynchronous Comprehensions)是經典推導式的非同步版本,專門用於處理非同步迭代和非同步操作。asyncio支援兩種型別的非同步推導式,分別是async for推導式和await推導式。

推導式

推導式是一種透過簡潔的語法構建資料集合(如列表、字典和集合)的方法。它允許在單行程式碼中結合 for 迴圈和條件語句,快速建立並填充資料結構。推導式通常由三部分組成:

  1. 輸出表示式:表示生成的元素。
  2. for迴圈:用於遍歷可迭代物件。
  3. 條件表示式(可選):用於過濾元素,僅保留符合條件的部分。

列表推導式可以透過 for 表示式從一個新列表中生成元素。例如:

# 使用列表推導式建立列表
result = [a * 2 for a in range(100)]

此外,推導式還可以用於建立字典和集合。例如:

# 使用推導式建立字典
result = {a: i for a, i in zip(['a', 'b', 'c'], range(3))}
# 使用推導式建立集合
result = {a for a in [1, 2, 3, 2, 3, 1, 5, 4]}

async for非同步推導式

非同步推導式可透過帶非同步迭代的async for建立列表、集合或字典。該表示式按需生成協程或任務,獲取其結果並存入目標容器。需要注意,async for僅能在協程或任務中使用。非同步迭代器返回可等待物件,async for用於遍歷並獲取每個物件的結果,在內部async for自動等待並排程協程。非同步生成器實現非同步迭代器方法,亦可用於非同步推導式。程式碼示例如下:

import asyncio

# 非同步函式,模擬非同步操作
async def get_data():
    await asyncio.sleep(1)  # 模擬非同步等待
    return [1, 2, 3, 4, 5]

# 非同步推導式建立列表
async def async_list_comprehension():
    async_data = await get_data()
    async_list = [x * 2 for x in async_data]
    return async_list

# 非同步推導式建立集合
async def async_set_comprehension():
    async_data = await get_data()
    async_set = {x * 2 for x in async_data}
    return async_set

# 非同步推導式建立字典
async def async_dict_comprehension():
    async_data = await get_data()
    async_dict = {x: x * 2 for x in async_data}
    return async_dict

# 執行非同步推導式
async def main():
    list_result = await async_list_comprehension()
    set_result = await async_set_comprehension()
    dict_result = await async_dict_comprehension()
    
    print("Async List:", list_result)
    print("Async Set:", set_result)
    print("Async Dict:", dict_result)

# 啟動事件迴圈
asyncio.run(main())

await非同步推導式

await 表示式不僅適用於常規非同步操作,還可用於列表、集合或字典推導式,稱為 await 推導。無論在非同步還是同步程式碼中,建議統一使用 await 推導或列表推導。與非同步推導式類似,await 推導僅能在非同步協程或任務中使用。該機制透過掛起並等待一系列可等待物件,構建資料結構(如列表)。在當前協程中,這些可等待物件按順序執行:

import asyncio
async def fetch_data(item):
    # 模擬非同步資料獲取操作
    await asyncio.sleep(1)  # 模擬非同步等待
    return f"Data for {item}"

async def main():
    # 建立一個包含可等待物件的列表
    awaitables = [fetch_data(item) for item in range(5)]
    
    # 使用 await 推導式構建列表
    results = [await x for x in awaitables]
    
    # 列印結果
    print(results)

# 執行主函式
asyncio.run(main())

2.3.4 非同步上下文管理器

上下文管理器是Python中的一種結構,它提供了類似try-finally的環境,具有統一的介面和簡潔的語法,通常透過with語句來使用。上下文管理器常用於資源管理,確保資源在使用後能夠被正確關閉或釋放,無論使用過程是否成功,或是否因異常而導致失敗。asyncio庫支援開發非同步上下文管理器。在asyncio程式中,非同步上下文管理器(asynchronous ContextManagers)可以透過定義一個實現了__aenter__()__aexit__()方法的協程物件來建立和使用。

上下文管理器

上下文管理器是 Python 中定義了 __enter____exit__ 方法的物件,它們分別負責在 with 語句塊開始和結束時執行特定的操作。這一機制使得在進入和退出特定程式碼塊時,能夠自動管理資源的生命週期,如檔案、套接字或執行緒池的開啟與關閉。透過上下文管理器,可以有效地管理資源,提升程式碼的安全性與可讀性。

透過 with 語句使用上下文管理器,可在程式碼塊執行前後自動完成資源的準備與清理工作。上下文管理器物件通常在 with 語句中建立,並自動觸發 __enter__ 方法。無論程式碼塊是正常結束還是因異常退出,__exit__ 方法都會被自動呼叫。

例如:

# 使用上下文管理器
with ContextManager() as manager:
    # 在此處執行程式碼塊
# 自動管理資源關閉
# 這與 try-finally 結構類似

或者,也可以手動建立物件並呼叫這些方法:

# 建立物件
manager = ContextManager()
try:
    manager.__enter__()
    # 執行程式碼塊
finally:
    manager.__exit__()

非同步上下文管理器

非同步上下文管理器指的是能夠在其 __aenter____aexit__ 方法中掛起執行的上下文管理器。這兩個方法被定義為協程,並由呼叫者進行等待。透過 async with 表示式,可以實現這一功能。因此,非同步上下文管理器通常用於asyncio程式中,尤其是在協程呼叫時。async with 表示式是對傳統 with 表示式的擴充套件,專門用於非同步上下文管理器,允許在協程中進行非同步操作,其使用方式與同步的 with 表示式相似,但能夠處理非同步任務。下面是一個定義非同步上下文管理器的例子:

import asyncio
# 定義非同步上下文管理器
class AsyncContextManager:
    # 進入非同步上下文管理器
    async def __aenter__(self):
        # 報告訊息
        print('> entering the context manager')
        # 模擬非同步操作,暫時阻塞
        await asyncio.sleep(0.5)

    # 退出非同步上下文管理器
    async def __aexit__(self, exc_type, exc, tb):
        # 報告訊息
        print('> exiting the context manager')
        # 模擬非同步操作,暫時阻塞
        await asyncio.sleep(0.5)

在使用非同步上下文管理器時,透過 async with 表示式來呼叫它。這不僅會自動等待進入和退出協程,還會確保在執行過程中暫停當前協程,直到相關的非同步操作完成:

# 使用非同步上下文管理器
async with AsyncContextManager() as manager:
    # 在此塊中執行一些非同步任務
    # ...

以下示例展示了在 asyncio 程式中非同步上下文管理器的常見使用模式,首先會建立 main() 協程,並將其作為 asyncio 程式的入口點。main() 協程執行時,建立了一個 AsyncContextManager類的例項,並在 async with 表示式中使用它:

import asyncio

# 定義非同步上下文管理器
class AsyncContextManager:
    # 進入非同步上下文管理器
    async def __aenter__(self):
        # 報告訊息
        print('>進入上下文管理器')
        # 暫停一段時間
        await asyncio.sleep(0.5)

    # 退出非同步上下文管理器
    async def __aexit__(self, exc_type, exc, tb):
        # 報告訊息
        print('>退出上下文管理器')
        # 暫停一段時間
        await asyncio.sleep(0.5)

# 定義一個簡單的協程
async def custom_coroutine():
    # 建立並使用非同步上下文管理器
    async with AsyncContextManager() as manager:
        # 輸出當前狀態
        print(f'在上下文管理器內部')

# 啟動非同步程式
asyncio.run(custom_coroutine())

程式碼執行結果為:

>進入上下文管理器
在上下文管理器內部
>退出上下文管理器

2.4 asyncio中的非阻塞流

2.4.1 非阻塞流介紹

asyncio的一個重要特點是能夠在進行網路操作時避免阻塞,這意味著在等待資料的過程中,程式仍然可以繼續執行其他任務。這一功能透過“流”(streams)來實現,流就像一個管道,用於收發資料。藉助流,資料的傳送和接收變得更加簡便,無需依賴複雜的回撥函式或底層實現細節。

具體來說,asyncio中的asyncio streams支援透過網路連線建立“寫”流和“讀”流。在這些流中,可以執行資料寫入和讀取操作,且在等待期間程式不會被某個操作卡住。操作完成後,網路連線即可關閉。儘管在使用流功能時需要自行處理一些網路協議的細節,這種方式仍然能夠支援許多常見的網路協議,例如:

  • 與網站伺服器通訊的HTTP或HTTPS協議。
  • 用於傳送電子郵件的SMTP協議。
  • 用於檔案傳輸的FTP協議。

流不僅可以用於建立伺服器並處理標準協議的請求,還能幫助開發者定製協議,以滿足特定應用需求。接下來,將介紹如何使用非同步流:

開啟連線

可以使用 asyncio.open_connection() 函式開啟 asyncio TCP 客戶端套接字連線,建立網路連線並返回一對(reader、writer)物件。這些返回的物件是 StreamReaderStreamWriter 類的例項,用於與套接字互動。該函式是一個必須等待的協程,一旦套接字連線開啟便返回。

例如:

# 開啟一個連線
reader, writer = await asyncio.open_connection(...)

asyncio.open_connection() 函式需要許多引數來配置套接字連線,其中兩個必需的引數是主機和埠:

  • 主機是一個字串,指定要連線的伺服器,例如域名或 IP 地址。
  • 埠是套接字埠號,例如HTTP伺服器為80,HTTPS 伺服器為 443,SMTP為25 等。

例如,開啟與 HTTP 伺服器的連線:

# 開啟與 http 伺服器的連線
reader, writer = await asyncio.open_connection('www.baidu.com', 80)

如果需要加密套接字連線(如 HTTPS),可以透過設定 ssl=True 實現 SSL 協議支援:

# 開啟與 https 伺服器的連線
reader, writer = await asyncio.open_connection('www.baidu.com', 443, ssl=True)

啟動偵聽服務

要啟動一個非同步的TCP伺服器,可以使用asyncio.start_server()函式。這個函式會建立一個伺服器,它會在指定的地址和埠上監聽來自客戶端的連線請求。這個函式是一個需要等待的協程,當呼叫時,它會返回一個asyncio.Server物件,代表正在執行的伺服器。

下面是如何使用這個函式的一個示例:

# 啟動一個 TCP 伺服器
server = await asyncio.start_server(...)

這個函式需要三個引數:一個處理連線的函式、伺服器的地址和埠號。處理連線的函式是一個使用者自定義的函式,每當有客戶端連線到伺服器時,這個函式就會被呼叫。這個函式會接收一對物件作為引數,這兩個物件分別用於從客戶端讀取資料(StreamReader)和向客戶端傳送資料(StreamWriter)。

地址是指客戶端用來連線伺服器的域名或IP地址,埠號則是伺服器用來接收連線請求的網路埠,不同的服務通常會使用不同的埠,比如FTP服務常用埠21,而HTTP服務常用埠80。

下面是一個具體的使用示例:

# 定義一個處理客戶端連線的函式
async def handler(reader, writer):
    # 在這裡新增處理客戶端請求的邏輯
    pass

# 使用指定的處理器、地址和埠號啟動伺服器
server = await asyncio.start_server(handler, '127.0.0.1', 80)

在這個例子中,handler函式將負責處理每個客戶端的連線,'127.0.0.1'是本地迴環地址,意味著伺服器將只在本地計算機上可用,而80是HTTP服務的標準埠號。

使用StreamWriter寫入資料

資料可以透過asyncio.StreamWriter寫入套接字,套接字是計算機之間透過網路傳輸資料的通訊端點。StreamWriter提供API將位元組資料寫入套接字連線的I/O流,資料會嘗試立即傳送到目標裝置,若無法立即傳送,則儲存在緩衝區。寫入後,最好使用drain方法清空緩衝區:

# 寫入位元組資料
writer.write(byte_data)
# 等待資料傳輸
await writer.drain()

使用StreamReader讀取資料

資料可以透過asyncio.StreamReader從套接字中讀取。讀取的資料是位元組格式,因此在使用之前可能需要進行編碼。所有讀取操作都是必須等待的協程。可以使用read()方法讀取任意數量的位元組,該方法會一直讀取,直到檔案末尾(EOF):

# 讀取位元組資料
byte_data = await reader.read()

也可以透過n引數指定要讀取的位元組數:

# 讀取指定位元組數的資料
byte_data = await reader.read(n=100)

使用readline方法可以讀取單行資料,直到遇到新行字元\n或檔案末尾(EOF),返回的是位元組資料:

# 讀取一行資料
byte_line = await reader.readline()

此外,readexactly()方法用於讀取確切數量的位元組,如果讀取的位元組數不足,則會引發異常。而readuntil()方法會讀取位元組資料,直到遇到指定的位元組字元為止。

關閉連線

可以透過asyncio.StreamWriter來關閉套接字。呼叫close()方法即可關閉套接字,該方法不會阻塞:

#關閉套接字
writer.close()

儘管close()方法不會阻塞,但可以透過wait_close()方法等待套接字完全關閉後再繼續操作:

#關閉套接字
writer.close()
#等待套接字關閉
awaitwriter.wait_closed()

也可以透過is_closing()方法檢查套接字是否已經關閉或正在關閉過程中:

#檢查套接字是否已關閉或正在關閉
ifwriter.is_closing():
#...

2.4.2 使用asyncio檢查HTTP狀態

本節介紹如何使用 asyncio 模組透過開啟流並進行HTTP請求和響應的讀寫操作,整個過程通常包括以下四個步驟:

  1. 開啟連線
  2. 傳送請求
  3. 讀取響應
  4. 關閉連線

專業的非同步HTTP框架可以參考使用:aiohttp

開啟連結

使用 asyncio.open_connection() 函式開啟連線。該函式接受主機名和埠號作為引數,並返回一個 StreamReaderStreamWriter,用於透過套接字進行資料的讀寫。這些功能通常用於在埠 80 上開啟 HTTP 連線。

傳送HTTP請求

在開啟HTTSP連線後,可以向StreamWriter寫入查詢以發出 HTTP 請求。以HTTP版本1.1 請求為例,HTTP請求的格式為純文字,可以請求根路徑“/”,其示例如下:

GET / HTTP/1.1
Host: www.google.com

HTTP協議請求的具體介紹見:HTTP協議請求/響應格式詳解。需要注意的是,每行末尾必須包含回車符和換行符(\r\n),且請求的末尾需有一個空行。若作為 Python 字串表示,格式如下所示:

'GET / HTTP/1.1\r\n'
'Host: www.google.com\r\n'
'\r\n'

在寫入 StreamWriter 之前,必須將該字串編碼為位元組。可以透過呼叫 encode() 方法實現字串編碼,預設的“utf-8”編碼通常適用。例如:

# 將字串編碼為位元組
byte_data = string.encode()

接著,可以使用 StreamWriterwrite() 方法將位元組資料寫入套接字。例如:

# 將查詢寫入套接字
# 等待套接字準備好
await writer.drain()

讀取HTTP響應

發出HTTP請求後,可以讀取響應。此操作可透過套接字的StreamReader實現。使用read()方法可一次讀取一大塊位元組,或者使用readline()方法逐行讀取位元組。由於基於文字的HTTP協議通常每次傳送一行HTML資料,因此readline()方法更加便捷。需要注意的是,readline()是一個協程,呼叫時需要等待其執行完成。示例如下:

#讀取一行響應
line_bytes=awaitreader.readline()

HTTP/1.1響應由標頭和正文兩部分組成,二者透過空行分隔。標頭部分包含關於請求是否成功以及即將傳送的檔案型別的資訊,正文則包含檔案內容,如HTML網頁。HTTP標頭的第一行通常表示請求頁面的HTTP狀態。每一行資料需要從位元組解碼為字串,通常使用decode()方法,預設編碼為"utf_8"。示例如下:

#將位元組解碼為字串
line_data=line_bytes.decode()

關閉HTTP連線

可以透過呼叫close()方法關閉StreamWriter,從而關閉套接字連線。例如:

#關閉連線
writer.close()

此操作不會阻塞,並且可能不會立即關閉套接字。

2.4.3 asyncio中的流使用示例

本節介紹了一個用於檢查網站狀態的示例,程式碼實現了一個非同步HTTP狀態碼獲取工具。透過結合asyncio和urlsplit模組,該工具能夠併發請求多個URL,並獲取這些URL的HTTP狀態行(例如 HTTP/1.1 200 OK)。程式碼流程如下:

  1. 解析URL:使用 urlsplit(url) 解析 URL,提取協議、主機名和路徑等資訊。
  2. 建立連線:根據協議選擇相應埠(80 或 443),並建立非同步網路連線。
  3. 傳送HTTP請求:構建併傳送簡單的HTTP請求報文。
  4. 讀取響應:非同步讀取 HTTP 響應的狀態行並返回。
  5. 處理異常:若請求失敗,捕獲異常並輸出錯誤資訊,返回 None。
  6. 併發執行任務:透過 asyncio.gather() 實現併發執行所有 URL 請求,等待任務完成並返回結果。
  7. 輸出結果:輸出每個URL的HTTP狀態行或錯誤資訊。

示例程式碼如下:

import asyncio
from urllib.parse import urlsplit  # 匯入 urlsplit 函式,用於解析 URL

# 定義一個非同步函式,用於獲取指定 URL 的 HTTP/S 狀態
async def get_status(url):
    # 使用 urlsplit 解析 URL,將其分解為各個部分(例如:scheme, hostname, path)
    url_parsed = urlsplit(url)
    
    try:
        # 根據 URL 的 scheme(協議)判斷是 http 還是 https,選擇相應的埠(80 或 443)
        if url_parsed.scheme == 'https':
            # 如果是 https 協議,連線到 443 埠,並啟用 SSL 加密
            reader, writer = await asyncio.open_connection(url_parsed.hostname, 443, ssl=True)
        else:
            # 如果是 http 協議,連線到 80 埠
            reader, writer = await asyncio.open_connection(url_parsed.hostname, 80)
        
        # 構建 HTTP 請求報文:請求目標是 URL 的 path 部分,使用 HTTP/1.1 協議
        query = f'GET {url_parsed.path} HTTP/1.1\r\nHost: {url_parsed.hostname}\r\n\r\n'
        
        # 將請求報文寫入連線,並使用 StreamWriter 將編碼位元組寫入套接字。
        writer.write(query.encode())
        # 等待資料寫入完成
        await writer.drain()
        
        # 從伺服器讀取一行響應資料(HTTP 狀態行)
        response = await reader.readline()
        # 解碼響應並去除多餘的空白字元
        status = response.decode().strip()
        
        # 返回 HTTP 狀態行(例如:"HTTP/1.1 200 OK")
        return status
    except Exception as e:
        # 如果請求過程中發生任何異常,捕獲並輸出錯誤資訊
        print(f"Error fetching {url}: {e}")
        # 如果發生錯誤,返回 None
        return None
    finally:
        # 確保連線關閉
        writer.close()
        await writer.wait_closed()  # 等待連線完全關閉
        reader.feed_eof()  # 通知 reader 沒有更多資料會到來

# 主協程,執行多個 URL 狀態獲取任務
async def main():
    # 定義一個包含多個 URL 的列表,表示我們需要檢查的目標網站
    sites = [
        'https://www.baidu.com/',
        'https://www.bilibili.com/',
        'https://www.weibo.com/',
        'https://www.douyin.com/',
        'https://www.zhihu.com/',
        'https://www.taobao.com/',
        'https://www.sohu.com/',
        'https://www.tmall.com/',
        'https://www.xinhuanet.com/',
        'https://www.163.com/'
    ]
    
    # 為每個 URL 建立一個獲取狀態的非同步任務
    tasks = [get_status(url) for url in sites]
    
    # 使用 asyncio.gather 併發執行所有任務,返回所有任務的結果(包括異常)
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    # 遍歷 URL 和對應的響應狀態,輸出結果
    for url, status in zip(sites, results):
        # 如果狀態不為空,表示請求成功,輸出 URL 和 HTTP 狀態
        if status is not None:
            print(f'{url:30}:\t{status}')
        else:
            # 如果狀態為 None,表示請求失敗,輸出錯誤資訊
            print(f'{url:30}:\tError')

# 執行主非同步程式
asyncio.run(main())

3 參考

  • asyncio — Asynchronous I/O
  • Python Asyncio: The Complete Guide
  • 深度解密 asyncio
  • 程序、執行緒、協程
  • aiohttp
  • HTTP協議請求/響應格式詳解

相關文章