[python] asyncio庫常見問題與實踐案例

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

本文詳細介紹了在使用asyncio庫編寫非同步程式時常見的錯誤和問題,並進一步透過實踐案例進行分析和討論,以便在專案中更有效地應用asyncio庫。有關asyncio庫的詳細介紹,可參考:Python 非同步程式設計庫 asyncio 使用指北

目錄
  • 1 asyncio程式的常見錯誤
    • 1.1 試圖直接呼叫並執行協程
    • 1.2 主協程過早退出
    • 1.3 錯誤使用asyncio的低階API
    • 1.4 程式出現競爭條件或死鎖問題
      • 1.4.1 競爭條件問題
      • 1.4.2 死鎖問題
  • 2 asyncio程式的常見問題
    • 2.1 任務的等待、停止、結果獲取
      • 2.1.1 如何等待任務
      • 2.1.2 何時停止任務
      • 2.1.3 如何獲取任務的返回值
    • 2.2 如何在後臺執行和等待任務
      • 2.2.1 如何在後臺執行任務
      • 2.2.2 如何等待所有後臺任務
    • 2.3 任務的延遲後執行和後續執行
      • 2.3.1 任務的延遲後執行
      • 2.3.2 任務的後續執行
    • 2.4 如何顯示執行任務的進度
      • 2.4.1 基於回撥函式的任務進度顯示
      • 2.4.2 基於tqdm庫的任務進度顯示
    • 2.5 如何在asyncio中執行阻塞I/O或CPU密集型函式
      • 2.5.1 使用 asyncio.to_thread()
      • 2.5.2 使用 loop.run_in_executor()
    • 2.6 Python協程:作業系統原生支援嗎
  • 3 應用例項
    • 3.1 在基於執行緒的程式中呼叫asyncio程式碼
    • 3.2 基於asyncio實現多核非同步處理
    • 3.3 圖片下載器
    • 3.4 生產者消費者模型
  • 4 參考

1 asyncio程式的常見錯誤

本節展示了在使用asyncio模組時,開發人員常遇到的一些常見錯誤示例。以下是四個最常見的非同步程式設計錯誤:

  1. 直接呼叫並執行協程。
  2. 主協程過早退出。
  3. 錯誤使用asyncio的低階API。
  4. 程式出現競爭條件或死鎖問題。

1.1 試圖直接呼叫並執行協程

協程通常透過async def定義,如下所示:

# 自定義協程
async def custom_coro():
    print('hi there')

若直接像函式一樣呼叫該協程,通常不會執行預期的操作,而是建立一個協程物件。這種呼叫方式不會觸發協程的執行:

# 錯誤:像函式一樣呼叫協程
custom_coro()  # 這只是建立了一個協程物件,並不會執行

此時,返回的是一個協程物件,而不是立即執行協程主體,這忽略協程必須在事件迴圈中執行。如果協程未被執行,系統將發出以下執行時警告:

sys:1: RuntimeWarning: coroutine 'custom_coro' was never awaited

要正確執行協程,需要在asyncio事件迴圈中等待該物件。例如,使用asyncio.run()啟動事件迴圈來執行協程:

# 正確:透過 asyncio.run() 執行協程
import asyncio

asyncio.run(custom_coro()) 

另一種執行協程方法是透過await表示式在現有協程中掛起並排程其他協程。例如,定義一個新的協程,在其中呼叫 custom_coro()

# 正確:在協程中使用 await 排程另一個協程
async def main():
    await custom_coro() 

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

1.2 主協程過早退出

在非同步程式設計中,任務的執行可能無法按預期及時完成。透過asyncio.create_task()可以並行執行多個協程,但如果主協程提前退出,這些任務可能會被強制中止。為確保所有任務能夠在主協程退出前完成,主協程應在無其他活動時顯式等待剩餘任務的完成。可以使用asyncio.all_tasks()來獲取當前事件迴圈中的所有任務,並在移除主協程本身後,透過asyncio.wait()等待其他任務的執行結果。如果不移除當前協程,asyncio.wait會等待所有任務完成,包括當前協程,從而導致程式不退出(死鎖)。示例如下:

import asyncio

async def task_1():
    print("任務 1 開始")
    await asyncio.sleep(2)
    print("任務 1 完成")

async def task_2():
    print("任務 2 開始")
    await asyncio.sleep(1)
    print("任務 2 完成")

async def main():
    # 建立多個任務
    task1 = asyncio.create_task(task_1())
    task2 = asyncio.create_task(task_2())
    
    # 獲取所有正在執行的任務的集合
    all_tasks = asyncio.all_tasks()
    
    # 獲取當前任務(即主協程)
    current_task = asyncio.current_task()
    
    # 從所有任務列表中刪除當前任務
    all_tasks.remove(current_task)
    
    # 暫停直到所有任務完成
    await asyncio.wait(all_tasks)

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

程式碼執行結果為:

任務 1 開始
任務 2 開始
任務 2 完成
任務 1 完成

1.3 錯誤使用asyncio的低階API

asyncio提供了兩類API:一類是面向應用程式開發者的高階API,另一類是面向框架開發者的低階API。低階API主要為高階API提供底層支援,如事件迴圈、傳輸協議等內部結構。在大多數情況下,推薦優先使用高階API,特別是在學習階段。只有在需要實現特定功能時,才應考慮使用低階API。儘管學習低階API具有一定的價值,但不應在剛開始時就使用。建議先透過高階API熟悉非同步程式設計的基本概念,進行應用開發,掌握核心知識後,再深入探討技術細節。例如:

import asyncio

# 高階API:推薦的用法
async def hello_world():
    print("你好,世界!")

# 使用 asyncio.run 來啟動事件迴圈
def run_hello_world():
    asyncio.run(hello_world())

# 低階API:不推薦直接使用
async def low_level_example():
    loop = asyncio.get_event_loop()  # 獲取當前事件迴圈
    task = loop.create_task(hello_world())  # 建立任務
    await task  # 顯式等待任務完成

# 執行高階 API 示例
print("使用 asyncio.run 執行:")
run_hello_world()

# 執行低階 API 示例
print("\n使用低階 API 執行:")
asyncio.run(low_level_example())

1.4 程式出現競爭條件或死鎖問題

競爭條件和死鎖是併發程式設計中常見的錯誤。競爭條件發生在多個任務同時訪問相同資源時,缺乏適當的控制可能導致資料錯誤或丟失。死鎖則是指不同任務互相等待對方釋放資源,最終導致所有任務無法繼續執行。

許多Python開發者認為,使用asyncio協程可以避免這些問題,因為在任何時刻,事件迴圈中只有一個協程在執行。然而,協程在執行過程中可能會暫停和恢復,並且可能會訪問共享資源。如果對這些資源沒有適當的保護,就可能會引發競爭條件。此外,在協程同步資源時處理不當,也有可能導致死鎖。因此,在編寫asyncio程式時,確保協程的安全性至關重要。

1.4.1 競爭條件問題

以下示例程式碼模擬了兩個非同步任務並行增加共享變數counter,每個任務迴圈10000次對counter進行遞增操作。透過awaitasyncio.sleep(0)來模擬上下文切換,確保兩個任務能夠交替執行。然而,由於未使用同步機制(如鎖),會導致競態條件。因此,最終的counter值可能小於預期的20000,而不是20000,因為兩個任務可能在讀取和更新counter的值時發生衝突,導致多個協程可能重複更新相同的資料:

import asyncio

# 共享資源
counter = 0

async def increment():
    global counter
    for _ in range(10000):
        temp = counter
        temp += 1
        await asyncio.sleep(0)  # 讓出控制權,模擬上下文切換
        counter = temp

async def main():
    tasks = [increment(), increment()]
    await asyncio.gather(*tasks)
    print("最終計數器的值:", counter)  

# 執行 asyncio 程式
asyncio.run(main())

程式碼執行結果為:

最終計數器的值: 10000

為了解決這個問題,可以使用 asyncio.Lock 來同步對共享資源 counter 的訪問。然而,由於asyncio.Lockasyncio.run之間的事件迴圈可能不匹配,通常會在某些環境中(如特定的 IDE 或指令碼執行環境)出現問題。原因在於asyncio.run 建立並管理一個新的事件迴圈,而鎖 (asyncio.Lock) 可能會被不同的事件迴圈使用,從而導致不一致。為避免這種情況,可以顯式建立並使用一個事件迴圈,如下所示:

import asyncio

# 共享資源
counter = 0
# 建立鎖
lock = asyncio.Lock()

async def increment():
    global counter
    for _ in range(10000):
        async with lock:  # 確保在修改 counter 時,只有一個任務可以訪問
            temp = counter
            temp += 1
            await asyncio.sleep(0)  # 讓出控制權,模擬上下文切換
            counter = temp

async def main():
    tasks = [increment(), increment()]
    await asyncio.gather(*tasks)
    print("最終計數器的值:", counter)

# 顯式建立事件迴圈並執行
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

程式碼執行結果為:

最終計數器的值: 20000

1.4.2 死鎖問題

死鎖介紹

死鎖(Deadlock)是併發程式設計中的一種常見問題,它發生在多個任務之間的資源爭用中,導致所有任務都陷入無法繼續執行的僵局。即使在Python中使用asyncio協程框架,資源競爭和同步問題也可能導致死鎖的發生,尤其是在協程需要同步資源(如鎖)時。如果同步機制設計不當,容易引發死鎖。

死鎖的特徵如下:

  • 迴圈等待:多個任務之間相互等待對方釋放資源,從而形成一個迴圈等待的關係。例如,任務1等待任務2釋放資源,而任務2又在等待任務1釋放資源,形成閉環。
  • 不可搶佔:每個任務持有的資源(如鎖)不能被其他任務強制搶佔。只有在任務主動釋放資源時,其他任務才能獲取該資源。
  • 持有資源且等待:任務持有某些資源(如鎖),同時又在等待其他資源的釋放。由於任務在持有資源的情況下無法繼續執行,導致系統中的任務無法前進。

以下程式碼中的死鎖是典型的迴圈等待問題,所有相關任務陷入相互等待的死迴圈,無法繼續執行:

import asyncio

# 建立兩個共享鎖
lock1 = asyncio.Lock()
lock2 = asyncio.Lock()

async def task1():
    print("任務1:嘗試獲取鎖1")
    await lock1.acquire()  # 獲取鎖1
    print("任務1:已獲取鎖1,嘗試獲取鎖2")
    await asyncio.sleep(1)  # 模擬一些操作
    await lock2.acquire()  # 獲取鎖2
    print("任務1:已獲取鎖2")
    
    # 釋放鎖
    lock1.release()
    lock2.release()

async def task2():
    print("任務2:嘗試獲取鎖2")
    await lock2.acquire()  # 獲取鎖2
    print("任務2:已獲取鎖2,嘗試獲取鎖1")
    await asyncio.sleep(1)  # 模擬一些操作
    await lock1.acquire()  # 獲取鎖1
    print("任務2:已獲取鎖1")
    
    # 釋放鎖
    lock1.release()
    lock2.release()

async def main():
    # 啟動兩個任務
    await asyncio.gather(task1(), task2())

# 建立事件迴圈並執行
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

程式碼執行結果如下,由於兩個任務都被掛起,程式無法退出,且永遠不會列印出"任務1:已獲取鎖2"或"任務2:已獲取鎖1":

任務1:嘗試獲取鎖1
任務1:已獲取鎖1,嘗試獲取鎖2
任務2:嘗試獲取鎖2
任務2:已獲取鎖2,嘗試獲取鎖1
...

asyncio中死鎖的避免

在使用asyncio時,為了避免死鎖,可以採取以下幾種方法:

  1. 鎖的順序管理:確保所有任務按照相同的順序獲取鎖,以防止發生相互等待的情況。
  2. 嘗試獲取鎖:使用asyncio.Lockacquire方法並設定超時時間,避免任務長時間處於等待鎖的狀態。
  3. 使用async with:透過async with語句來管理鎖,這樣可以確保在任務完成後自動釋放鎖,避免因忘記釋放鎖而引發問題。

根據這一思路,前面死鎖的案例解決示例程式碼如下:

import asyncio

# 建立兩個共享鎖
lock1 = asyncio.Lock()
lock2 = asyncio.Lock()

async def task1():
    print("任務1:嘗試獲取鎖1")
    async with lock1:  # 使用async with獲取鎖,自動釋放
        print("任務1:已獲取鎖1,嘗試獲取鎖2")
        await asyncio.sleep(1)  # 模擬一些操作
        
        print("任務1:嘗試獲取鎖2")
        async with lock2:  # 使用async with獲取鎖,自動釋放
            print("任務1:已獲取鎖2")

async def task2():
    print("任務2:嘗試獲取鎖1")
    async with lock1:  # 使用async with獲取鎖,自動釋放
        print("任務2:已獲取鎖1,嘗試獲取鎖2")
        await asyncio.sleep(1)  # 模擬一些操作
        
        print("任務2:嘗試獲取鎖2")
        async with lock2:  # 使用async with獲取鎖,自動釋放
            print("任務2:已獲取鎖2")

async def main():
    # 啟動兩個任務
    await asyncio.gather(task1(), task2())

# 建立事件迴圈並執行
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

程式碼執行結果如下,可以看到兩個任務避免了死鎖:

任務1:嘗試獲取鎖1
任務1:已獲取鎖1,嘗試獲取鎖2
任務2:嘗試獲取鎖1
任務1:嘗試獲取鎖2
任務1:已獲取鎖2
任務2:已獲取鎖1,嘗試獲取鎖2
任務2:嘗試獲取鎖2
任務2:已獲取鎖2

2 asyncio程式的常見問題

在使用asyncio編寫非同步程式時,開發者可能會遇到一系列常見問題,這些問題涉及到任務的管理、執行流程、效能最佳化等多個方面。以下是一些常見的問題和挑戰:

  1. 任務的等待、停止、結果獲取
  2. 如何在後臺執行和等待任務
  3. 任務的延遲後執行和後續執行
  4. 如何顯示執行任務的進度
  5. 如何在asyncio中執行阻塞I/O或CPU密集型函式
  6. Python協程:作業系統原生支援嗎

2.1 任務的等待、停止、結果獲取

2.1.1 如何等待任務

可以透過直接等待asyncio.Task物件來等待任務的完成:

# 等待任務完成
await task

也同時建立並等待任務完成。例如:

# 建立並等待任務完成
await asyncio.create_task(custom_coro())

與協程不同,任務可以多次等待而不會引發錯誤。以下是一個演示如何多次等待同一任務的示例,在此例中,await task兩次都能成功執行,因為task已經完成並儲存了返回值:

import asyncio

async def other_coro():
    await asyncio.sleep(1)
    return "任務完成"

async def main():
    # 將協程包裝在任務中並安排其執行
    task = asyncio.create_task(other_coro())
    
    # 第一次等待任務並獲取返回值
    value1 = await task
    print(value1)
    
    # 再次等待任務(任務已經完成)
    value2 = await task
    print(value2)

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

2.1.2 何時停止任務

可以透過asyncio.Task物件的cancel()方法取消任務。若任務被成功取消,cancel()方法返回True,否則返回False。例如:

# 取消任務
was_cancelled = task.cancel()

2.1.3 如何獲取任務的返回值

在Python中建立一個asyncio任務後,有兩種方法可以從 asyncio.Task 中檢索返回值:

  1. 等待任務(使用 await)。
  2. 呼叫 result() 方法。

基於await函式,等待任務時,呼叫者會掛起,直到任務完成並返回結果。如果任務已完成,返回值會立即提供。以下程式碼展示瞭如何等待任務並獲取其返回值:

import asyncio

async def other_coro():
    await asyncio.sleep(1)
    return "任務完成"

async def main():
    # 將協程包裝在任務中並安排其執行
    task = asyncio.create_task(other_coro())
    
    # 等待任務完成並獲取返回值
    value = await task
    print(value)

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

也可以透過呼叫 asyncio.Task 物件的 result() 方法獲取任務的返回值。此時要求任務已完成。如果任務未完成,呼叫 result() 會引發 InvalidStateError 異常。如果任務被取消,則會引發 CancelledError 異常。以下是一個使用 result() 方法的例子:

import asyncio

async def other_coro():
    await asyncio.sleep(1)
    return "任務完成"

async def main():
    task = asyncio.create_task(other_coro())
    
    # 等待任務完成
    await task
    
    try:
        # 獲取任務的返回值
        value = task.result()
        print(value)
    except asyncio.InvalidStateError:
        print("任務尚未完成")
    except asyncio.CancelledError:
        print("任務已取消")

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

2.2 如何在後臺執行和等待任務

2.2.1 如何在後臺執行任務

透過 asyncio.create_task()可以將協程封裝為Task物件,並在後臺執行。建立的任務物件會立即返回,且不會阻塞呼叫者的執行。為了確保任務能夠開始執行,可以使用 await asyncio.sleep(0) 暫停片刻。之所以使用 await asyncio.sleep(0),是因為新建立的任務並不會立刻開始執行。事件迴圈負責管理多個任務,它會根據排程策略決定哪個任務優先執行。透過 await asyncio.sleep(0) 暫時讓出執行權,使得事件迴圈有機會排程並執行剛剛建立的任務。這樣,await asyncio.sleep(0) 確保了任務在建立後能儘早開始執行,同時不會阻塞主協程的其他操作。示例程式碼如下:

import asyncio

async def other_coroutine():
    print("開始執行 other_coroutine")
    await asyncio.sleep(2)
    print("other_coroutine 執行完畢")

async def main():
    # 建立並排程任務
    task = asyncio.create_task(other_coroutine())
    
    # 暫停片刻以確保任務開始執行
    await asyncio.sleep(0)
    
    print("主協程正在執行")
    
    # 等待任務完成
    await task
    print("任務執行完畢")

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

此外,後臺任務可以在程式執行時執行,不會妨礙主程式的結束。如果主程式沒有其他待執行的任務,而後臺任務仍在進行中,那麼需要確保程式在後臺任務完成後才會完全退出。

2.2.2 如何等待所有後臺任務

在使用asyncio時,可能需要等待多個獨立的任務完成。比如,當多個任務同時執行時,有時想要等待所有任務完成,但又不想一直阻塞當前正在執行的任務。為了實現這個功能,可以透過以下步驟:

  1. 獲取所有當前任務:使用asyncio.all_tasks()可以獲取到當前事件迴圈中的所有任務。
  2. 排除當前任務:透過asyncio.current_task()獲取當前正在執行的任務,並將其從任務集合中移除。這樣可以避免等待當前任務自己。
  3. 等待所有剩餘任務完成:使用asyncio.wait()來等待所有任務完成,直到它們都執行完畢。

示例程式碼如下:

import asyncio

async def example_coroutine(name):
    # 這是一個模擬任務的協程,睡眠 1 秒鐘
    await asyncio.sleep(1)
    print(f"任務 {name} 完成。")

async def main():
    # 建立多個協程任務
    tasks = [asyncio.create_task(example_coroutine(name = str(i))) for i in range(5)]
    
    # 獲取所有正在執行的任務
    all_tasks = asyncio.all_tasks()
    
    # 獲取當前正在執行的任務(即 main 協程)
    current_task = asyncio.current_task()
    
    # 從任務集合中移除當前任務
    all_tasks.remove(current_task)
    
    # 等待所有其他任務完成
    await asyncio.wait(all_tasks)

# 啟動事件迴圈並執行主協程
asyncio.run(main())

2.3 任務的延遲後執行和後續執行

2.3.1 任務的延遲後執行

想要實現任務的延遲後執行,可以透過開發一個自定義的包裝協程,使其在延遲指定時間後執行目標協程。該包裝協程接受兩個引數:目標協程和延遲時間(單位為秒)。它會先休眠指定的延遲時間,然後執行傳入的目標協程。

以下程式碼展示瞭如何透過自定義包裝協程 delay,在指定的延遲時間後執行目標協程。delay 協程透過 asyncio.sleep() 實現延時,隨後再執行傳入的目標協程。可以在不同場景中使用該方法,如直接掛起協程或將任務安排為獨立執行:

import asyncio

# 延遲幾秒後啟動另一個協程的包裝協程
async def delay(coro, seconds):
    """
    延遲指定時間(秒)後執行目標協程。

    引數:
    coro: 要執行的目標協程
    seconds: 延遲時間,單位為秒
    """
    # 暫停指定時間(以秒為單位)
    await asyncio.sleep(seconds)
    # 執行目標協程
    await coro

# 示例目標協程
async def my_coroutine():
    print("目標協程開始執行")
    # 模擬一些工作
    await asyncio.sleep(2)
    print("目標協程執行完成")

# 使用包裝協程時,可以建立協程物件並直接等待,或將其作為任務獨立執行

# 1. 呼叫者可以掛起並排程延遲後的協程
async def main():
    print("延遲10秒後執行目標協程:")
    await delay(my_coroutine(), 10)
    print("目標協程已經完成執行")

# 2. 或者呼叫者可以安排延遲協程獨立執行
async def schedule_task():
    print("將目標協程安排為獨立任務,延遲10秒後執行")
    task = asyncio.create_task(delay(my_coroutine(), 10))
    await task  # 等待任務完成
    print("任務已完成")

# 執行示例
if __name__ == "__main__":
    asyncio.run(main())  # 執行主協程

    # 或者執行獨立任務的排程
    # asyncio.run(schedule_task())

2.3.2 任務的後續執行

在asyncio中,觸發後續任務的方式主要有三種:

  1. 透過已完成的任務本身排程後續任務
  2. 透過任務發起方排程後續任務
  3. 使用回撥函式自動排程後續任務

逐一分析這三種方式:

1. 透過已完成的任務本身排程後續任務

已完成的任務可以觸發後續任務的排程,通常依賴於某些狀態檢查來決定是否應該發起後續任務。任務排程可以透過asyncio.create_task()來完成。示例程式碼展示了執行指定任務後直接排程後續任務:

import asyncio

async def task():
    print("任務開始執行。")
    await asyncio.sleep(2)  # 模擬任務執行
    print("任務執行完成。")
    await followup_task()  # 在任務完成後直接排程後續任務

async def followup_task():
    print("正在執行後續任務。")
    await asyncio.sleep(2)  # 模擬後續任務執行
    print("後續任務執行完成。")

# 啟動事件迴圈,執行任務
async def main():
    await task()

asyncio.run(main())

2. 透過任務發起方排程後續任務

任務發起方可以根據實際需要決定是否繼續啟動後續任務。在啟動第一個任務時,可以保留 asyncio.Task 物件,透過檢查任務的結果或狀態,來判斷是否啟動後續任務。任務發起方還可以選擇等待後續任務完成,也可以選擇不等待。示例程式碼如下:

import asyncio

async def task():
    # 模擬一個任務
    await asyncio.sleep(1)
    return True  # 假設任務成功完成,返回True

async def followup_task():
    # 模擬後續任務
    await asyncio.sleep(1)
    print("後續任務執行")

async def main():
    # 發起並等待第一個任務
    task_1 = asyncio.create_task(task())
    
    # 等待第一個任務完成
    result = await task_1
    
    # 檢查任務結果
    if result:
        # 發起後續任務
        await followup_task()

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

3. 使用回撥函式自動排程後續任務

在任務發起時,可以為其註冊一個回撥函式。該回撥函式會在任務完成後自動執行。回撥函式接收一個 asyncio.Task 物件作為引數,但它不會等待後續任務的執行。因為回撥函式通常是普通的Python函式,無法進行非同步操作。示例程式碼:

import asyncio

# 定義回撥函式
def callback(task):
    # 安排並啟動後續任務
    # 注意:這裡不能直接使用 await,需透過 create_task 排程非同步任務
    asyncio.create_task(followup())

# 定義第一個非同步任務
async def work():
    print("工作任務正在執行...")
    await asyncio.sleep(2)  # 模擬一些非同步操作
    print("工作任務完成!")

# 定義後續非同步任務
async def followup():
    print("後續任務正在執行...")
    await asyncio.sleep(1)  # 模擬一些非同步操作
    print("後續任務完成!")

# 建立事件迴圈並執行任務
async def main():
    # 發起任務並註冊回撥函式
    task = asyncio.create_task(work())
    task.add_done_callback(callback)

    # 等待任務完成
    await task
    # 確保後續任務完成
    await asyncio.sleep(1)  # 等待回撥任務完成的時間

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

2.4 如何顯示執行任務的進度

2.4.1 基於回撥函式的任務進度顯示

每個任務的回撥函式可用於顯示進度。asyncio.Task 物件支援註冊回撥函式,這些函式會在任務完成時被呼叫,無論是正常完成還是以異常結束。回撥函式是普通函式而非協程,且接受與其關聯的 asyncio.Task 物件作為引數。透過為所有任務註冊相同的回撥函式,可以統一報告任務進度:

import asyncio

# 回撥函式,用於顯示任務完成的進度,區分任務
def progress(task):
    task_name = task.get_name()  # 獲取任務的名稱
    print(f"任務 {task_name} 完成。")  

async def example_task(n, task_name):
    """模擬一個非同步任務,表示處理n秒的任務,並設定任務名稱"""
    await asyncio.sleep(n)
    return task_name

async def main():
    # 定義多個非同步任務並新增回撥函式
    tasks = []
    for i in range(1, 6):
        task_name = f"Task-{i}"  # 為每個任務分配一個唯一名稱
        task = asyncio.create_task(example_task(i, task_name))  # 建立任務,模擬不同的執行時間
        task.set_name(task_name)  # 設定任務名稱
        # 為任務新增回撥函式,回撥函式會在相應任務執行完畢時被呼叫
        task.add_done_callback(progress)  
        tasks.append(task)

    # 等待所有任務完成
    await asyncio.gather(*tasks)

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

2.4.2 基於tqdm庫的任務進度顯示

使用tqdm庫顯示任務總體進度

以下程式碼演示瞭如何結合tqdm庫和asyncio庫,來展示非同步任務的總體執行進度:

import asyncio
from tqdm.asyncio import tqdm

async def example_task(n, task_name):
    """模擬一個非同步任務,表示處理 n 秒的任務,並設定任務名稱"""
    await asyncio.sleep(n)  # 模擬任務處理時間
    return task_name  # 返回任務名稱

async def main():
    # 定義多個非同步任務並使用 tqdm 顯示進度
    tasks = []
    total_tasks = 5  # 總任務數
    task_durations = [1, 2, 3, 4, 5]  # 每個任務的持續時間(秒)

    # 使用 tqdm 建立進度條,`total` 為任務的數量
    progress_bar = tqdm(total=total_tasks, desc="已完成任務數", ncols=100)

    # 建立任務
    for i, n in enumerate(task_durations):
        task_name = f"Task-{i+1}"  # 為每個任務分配一個唯一名稱
        task = asyncio.create_task(example_task(n, task_name))  # 建立任務,模擬不同的執行時間
        tasks.append(task)

    # 等待任務完成並更新進度條
    for task in asyncio.as_completed(tasks):
        await task  # 等待每個任務完成
        progress_bar.update(1)  # 每完成一個任務,更新進度條

    progress_bar.close()  # 關閉進度條

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

使用tqdm庫為多個任務設定單獨進度條

以下示例程式碼演示瞭如何使用asyncio並行執行多個非同步任務,同時透過tqdm庫為每個任務單獨顯示進度條:

import asyncio
from tqdm.asyncio import tqdm 

async def example_task(n, task_name, progress_bar):
    """模擬一個非同步任務,表示處理 n 秒的任務,並設定任務名稱"""
    for _ in range(n):  # 每秒更新一次進度
        await asyncio.sleep(1)  # 模擬任務處理時間
        progress_bar.update(1)  # 更新當前任務的進度
    return task_name  # 返回任務名稱

async def main():
    # 定義多個非同步任務並使用 tqdm 顯示進度
    tasks = []
    total_tasks = 5  # 總任務數
    task_durations = [1, 2, 3, 4, 5]  # 每個任務的持續時間(秒)

    # 建立進度條併為每個任務單獨設定
    progress_bars = []
    for i, n in enumerate(task_durations):
        task_name = f"Task-{i+1}"  # 為每個任務分配一個唯一名稱
        progress_bar = tqdm(total=n, desc=task_name, ncols=100, position=i)  # 建立任務對應的進度條
        progress_bars.append(progress_bar)
        task = asyncio.create_task(example_task(n, task_name, progress_bar))  # 建立任務
        tasks.append(task)

    # 等待任務完成
    await asyncio.gather(*tasks)  # 使用 asyncio.gather 同時等待所有任務完成

    # 關閉所有進度條
    for progress_bar in progress_bars:
        progress_bar.close()

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

2.5 如何在asyncio中執行阻塞I/O或CPU密集型函式

在程式設計中,“阻塞呼叫”指的是某些操作(例如讀取檔案、等待網路請求或執行資料庫查詢等)需要一定時間才能完成。在執行這些操作時,程式會暫停,無法繼續處理其他任務,這就是“阻塞”。另外,CPU密集型操作也可能會導致程式阻塞。因此,為了在非同步環境中仍然能夠處理阻塞呼叫,asyncio模組提供了兩種方法來在非同步程式中執行阻塞呼叫:

  • asyncio.to_thread() :此方法簡化了執行緒管理流程,特別適合處理大多數I/O密集型任務。它允許將阻塞呼叫委派給一個執行緒,從而避免阻塞主事件迴圈。
  • loop.run_in_executor() :此方法提供了更高的靈活性,支援使用自定義的執行器,比如執行緒池或程序池。這適用於需要精細控制執行環境的場景。

這兩種方法均可有效地將阻塞呼叫轉為非同步任務,以下逐一分析這兩種方式:

2.5.1 使用 asyncio.to_thread()

asyncio.to_thread() 是一個高階 API,適用於大多數應用場景。它會將指定的函式和引數提交到一個獨立的執行緒中執行,並返回一個可等待的協程。這樣,阻塞操作就可以在後臺執行緒池中執行,而不會阻塞事件迴圈。需要注意的是,任務並不會立即執行,而是會等待事件迴圈空閒時再開始執行。由於 asyncio.to_thread() 會在後臺建立一個 ThreadPoolExecutor 來處理阻塞任務,因此它特別適合 I/O 密集型的操作。示例程式碼如下:

import asyncio
import time

def blocking_task(task_id):
    # 模擬一個耗時的阻塞操作
    time.sleep(2)
    return f"任務 {task_id} 完成"

# 同步執行多個任務
def sync_main():
    start_time = time.time()
    
    # 順序執行多個阻塞任務
    results = [blocking_task(i) for i in range(5)]
    
    end_time = time.time()
    
    for result in results:
        print(result)
    
    print(f"同步任務執行時間: {end_time - start_time:.4f} 秒")

# 非同步執行多個阻塞任務
async def async_main():
    start_time = time.time()
    
    # 使用 asyncio.to_thread 來併發執行多個阻塞任務
    tasks = [asyncio.to_thread(blocking_task, i) for i in range(5)]
    results = await asyncio.gather(*tasks)
    
    end_time = time.time()
    
    for result in results:
        print(result)
    
    print(f"非同步任務執行時間: {end_time - start_time:.4f} 秒")

# 執行同步任務
print("同步執行開始:")
sync_main()

# 執行非同步任務
print("\n非同步執行開始:")
asyncio.run(async_main())

以上程式碼展示了同步執行阻塞任務與非同步執行阻塞任務的對比。透過使用asyncio.to_thread(),I/O 密集型操作的處理被委託給獨立的執行緒池,從而避免了阻塞事件迴圈,顯著提升了非同步任務的效率:

  • 同步執行:在 sync_main() 中,多個阻塞任務按順序逐一執行,每個任務需等待前一個任務完成後才能開始,整體執行時間為所有任務總時間(即 5 * 2 秒)。
  • 非同步執行:在 async_main() 中,多個阻塞任務併發執行。儘管每個任務仍然是阻塞的,但它們在後臺執行緒中並行處理,因此總執行時間僅為單個任務的執行時間(即約 2 秒)。

程式碼執行結果如下:

同步執行開始:
任務 0 完成
任務 1 完成
任務 2 完成
任務 3 完成
任務 4 完成
同步任務執行時間: 10.0317 秒

非同步執行開始:
任務 0 完成
任務 1 完成
任務 2 完成
任務 3 完成
任務 4 完成
非同步任務執行時間: 2.0089 秒

2.5.2 使用 loop.run_in_executor()

loop.run_in_executor()asyncio提供的低階API,需先獲取事件迴圈(例如,使用asyncio.get_running_loop())。該函式允許指定執行器(預設是ThreadPoolExecutor)以及要執行的函式。

asyncio.to_thread()相比,run_in_executor()提供了更大的靈活性,支援使用自定義執行器,而不僅限於執行緒池。此外,呼叫該函式後,任務會立即開始執行,無需等待返回的可等待物件來觸發任務的啟動。

示例程式碼如下:

import asyncio
import time

# 定義一個需要執行的阻塞任務
def task():
    print("任務開始")
    time.sleep(2)
    print("任務結束")


# 在單獨的執行緒中執行函式
async def main():
    # 獲取事件迴圈
    loop = asyncio.get_running_loop()
    # 使用run_in_executor來將task函式非同步執行線上程池中
    # None 表示使用預設的執行緒池執行器
    await loop.run_in_executor(None, task)

# 執行主任務
asyncio.run(main())

如果希望使用程序池,可以建立一個自定義的執行器並傳遞給 run_in_executor()。在這種情況下,呼叫者需要負責管理執行器的生命週期,使用完後要手動關閉。示例程式碼如下:

import asyncio
from concurrent.futures import ProcessPoolExecutor
import time

# 定義一個耗時的任務
def task(name):
    print(f"任務 {name} 開始")
    time.sleep(2)  # 模擬一個阻塞的操作
    print(f"任務 {name} 完成")
    return f"來自 {name} 的結果"

# 使用自定義的執行器來執行任務
async def main():
    # 建立一個程序池
    with ProcessPoolExecutor() as executor:
        # 獲取當前的事件迴圈
        loop = asyncio.get_running_loop()

        # 使用 run_in_executor 來在程序池中執行任務
        results = await asyncio.gather(
            loop.run_in_executor(executor, task, "A"),
            loop.run_in_executor(executor, task, "B"),
            loop.run_in_executor(executor, task, "C")
        )

        # 列印所有任務的結果
        for result in results:
            print(result)

# 啟動 asyncio 事件迴圈並執行 main
if __name__ == "__main__":
    asyncio.run(main())

2.6 Python協程:作業系統原生支援嗎

非同步程式設計和協程並不總是解決程式中所有併發問題的最佳方案。Python 中的協程是由軟體管理的,它們透過asyncio事件迴圈來執行和排程。與作業系統提供的執行緒和程序不同,協程並不由作業系統直接支援,而是透過Python的軟體框架來實現的。在這個意義上,Python中的協程並不是“原生”的。它們並不像執行緒或程序那樣具有獨立的執行上下文,反而是在同一個執行緒內透過協作式排程來切換任務。

此外,Python的GIL(全域性直譯器鎖)用來保護直譯器內部的狀態,防止多個執行緒同時訪問和修改直譯器的資料。而asyncio的事件迴圈是單執行緒執行的,這意味著所有的協程都在同一個執行緒裡執行。由於協程本身是透過事件迴圈排程的,而不是透過多執行緒或多程序並行執行,因此,儘管Python中的多執行緒模型受到GIL的限制,協程在處理 I/O 密集型任務時能夠有效避免GIL的影響,從而提高併發效能。這也是為什麼在處理大量I/O操作時,使用asyncio和協程能夠帶來較好的效能表現。

然而,協程並不適用於所有型別的併發任務。例如,對於計算密集型任務,使用執行緒或程序模型可能更為合適,因為協程並不會突破GIL的限制,計算密集型任務依然會在單個CPU核心上序列執行。因此,在選擇是否使用協程時,需要根據任務的特性做出權衡。

3 應用例項

3.1 在基於執行緒的程式中呼叫asyncio程式碼

直接呼叫同步I/O程式碼

以下程式碼實現了一個簡單的Tkinter應用,點選按鈕後,程式會發起一個同步HTTP請求(GET 請求)。在每60毫秒的重新整理週期中,程式會根據當前狀態更新顯示的文字。然而,當點選按鈕時,request_remote方法中的 requests.get會發起一個同步請求,這會阻塞主執行緒,從而導致介面卡頓或無響應。如下程式碼,App.QUERYING_STATE狀態相關資訊不會顯示出來:

import tkinter as tk
import requests


class App(tk.Tk):
    INIT_STATE = 0         # 初始化狀態
    QUERYING_STATE = 1     # 請求中狀態
    RESULT_STATE = 2       # 請求結果狀態

    def __init__(self):
        super().__init__()
        self.status_code = 0           # HTTP請求返回的狀態碼
        self._refresh_ms = 60          # 重新整理間隔時間(毫秒)
        self.state = App.INIT_STATE    # 初始狀態
        self._button = None            # 按鈕
        self._label = None             # 標籤
        self.render_elements()         # 渲染介面元素
        self.after(self._refresh_ms, self.refresh)  # 設定定時重新整理,定時呼叫refresh方法

    def render_elements(self):
        """ 設定介面佈局,渲染UI元素 """
        self.geometry("400x200")  # 設定視窗大小
        self._button = tk.Button(self, text="請求狀態碼", command=self.request_remote)  # 建立按鈕,點選時呼叫request_remote方法
        self._label = tk.Label(self, text="")  # 建立標籤,初始為空

        self._button.pack()  # 將按鈕新增到視窗中
        self._label.pack()   # 將標籤新增到視窗中

    def request_remote(self):
        """ 發起同步HTTP請求 """
        self.state = App.QUERYING_STATE  # 設定狀態為請求中
        response = requests.get("https://www.example.com")  # 發起GET請求,獲取響應
        self.status_code = response.status_code  # 獲取響應返回的狀態碼
        self.state = App.RESULT_STATE  # 設定狀態為結果狀態,表示請求已完成

    def refresh(self):
        """ 每60毫秒重新整理一次UI內容 """
        self.update_label()  # 更新標籤內容
        self.after(self._refresh_ms, self.refresh)  # 設定下次重新整理時間(每60毫秒重新整理一次)

    def update_label(self):
        """ 根據應用狀態更新標籤內容 """
        if self.state == App.INIT_STATE:
            self._label.config(text="這裡將顯示狀態碼。")  # 初始狀態下提示文字
        elif self.state == App.QUERYING_STATE:
            self._label.config(text="正在查詢遠端...")  # 請求中狀態時顯示提示文字
        elif self.state == App.RESULT_STATE:
            self._label.config(text=f"返回的狀態碼是: {self.status_code}")  # 請求結果狀態時顯示返回的狀態碼

    def start(self):
        self.mainloop()  # 啟動Tkinter事件迴圈,進入GUI介面

def main():
    app = App()  # 建立應用例項
    app.start()  # 啟動應用

if __name__ == "__main__":
    main()  

I/O請求的非同步呼叫

可以將requests包替換為aiohttp包,實現I/O請求的非同步呼叫。aiohttp和requests都是Python中常用的HTTP客戶端庫,但requests適用於同步場景,簡單易用,aiohttp則適用於非同步併發的場景,能夠處理大量並行請求。具體區別如下:

  1. 同步vs非同步:
  • requests是一個同步庫,意味著每次傳送請求時,程式會等待響應回來後才繼續執行。適用於一些簡單的、序列的HTTP請求場景。
  • aiohttp是一個非同步庫,基於Python的asyncio模組,能夠在傳送HTTP請求時非阻塞地繼續執行其他任務。適用於需要大量併發請求或長時間等待的非同步場景。
  1. 效能:
  • requests由於是同步的,處理大量請求時容易出現效能瓶頸,因為每個請求必須等待前一個請求完成。
  • aiohttp透過非同步I/O處理,可以在等待響應時同時發起其他請求,極大提高了併發效能,尤其在處理大量HTTP請求時。
  1. 用法:
  • requests用法簡單,適合初學者和一般同步的任務。
  • aiohttp需要使用async和await,適合需要併發或非同步操作的任務。

在上述示例程式碼中,為了替代requests模組的同步請求,可以建立一個繼承自App類的AppAsync類,並利用aiohttp和asyncio庫實現非同步請求。透過async_request方法非同步發起HTTP請求:

import aiohttp
import asyncio

class AppAsync(App):
    async def async_request(self):
        """ 
        非同步發起HTTP請求,使用aiohttp庫來實現I/O請求的非同步呼叫。
        """
        async with aiohttp.ClientSession() as session:  # 建立一個aiohttp會話物件
            async with session.get("https://www.example.com") as response:  # 發起GET請求
                self.status_code = response.status  # 獲取響應狀態碼
                self.state = App.RESULT_STATE  # 更新應用狀態

    def __int__(self):
        super().__init__()

    def request_remote(self):
        """ 使用asyncio.run來呼叫非同步請求程式碼 """
        self.state = self.QUERYING_STATE  # 設定狀態為請求中
        asyncio.run(self.async_request())  # 非同步發起HTTP請求

def main():
    app = AppAsync()  # 建立應用例項
    app.start()  # 啟動應用

if __name__ == "__main__":
    main()  # 執行主程式

然而AppAsync類中的asyncio.run(self.async_request())會阻塞Tkinter的主執行緒,因為asyncio.run()會一直執行,直到非同步任務完成。同時Tkinter自身有一個事件迴圈(mainloop()),與asyncio需要的事件迴圈衝突。如果在Tkinter內建立新事件迴圈,可能會導致Tkinter關閉或中斷後出現問題。

將asyncio與執行緒結合

為了解決asyncio事件迴圈阻塞的問題,可以使用一個單獨的守護執行緒,並在守護執行緒中執行事件迴圈,這樣asyncio的事件迴圈就不會阻塞主執行緒。重寫AppAsync類示例如下:

import aiohttp
import asyncio
import threading

class AppAsync(App):
    def __init__(self):
        super().__init__()
        self._loop_thread = threading.Thread(target=self.run_asyncio_loop, daemon=True)
        self._loop_thread.start()  # 啟動事件迴圈執行緒

    async def async_request(self):
        """ 
        非同步發起HTTP請求,使用aiohttp庫來實現I/O請求的非同步呼叫。
        """
        async with aiohttp.ClientSession() as session:  # 建立一個aiohttp會話物件
            async with session.get("https://www.example.com") as response:  # 發起GET請求
                self.status_code = response.status  # 獲取響應狀態碼
                self.state = App.RESULT_STATE  # 更新應用狀態

    def request_remote(self):
        """ 使用非同步請求,在事件迴圈中執行 """
        self.state = App.QUERYING_STATE  # 設定狀態為請求中
        asyncio.run_coroutine_threadsafe(self.async_request(), self._loop)  # 呼叫非同步請求並與當前事件迴圈進行互動

    def run_asyncio_loop(self):
        """ 執行asyncio事件迴圈 """
        self._loop = asyncio.new_event_loop()  # 建立新的事件迴圈
        asyncio.set_event_loop(self._loop)  # 設定當前執行緒的事件迴圈
        self._loop.run_forever()  # 啟動事件迴圈

def main():
    app = AppAsync()  # 建立應用例項
    app.start()  # 啟動應用

if __name__ == "__main__":
    main()  # 執行主程式

示例程式碼執行時,App.QUERYING_STATE狀態相關資訊會顯示出來,AppAsync類主要的改動點如下:

  1. AppAsync類的建構函式:
    • 增加了一個新的執行緒來執行asyncio事件迴圈,避免在Tkinter執行緒中阻塞。
    • 使用threading.Thread啟動一個守護執行緒,執行run_asyncio_loop方法,確保事件迴圈在後臺執行。
    • 在建立執行緒時設定為守護執行緒。這樣即使主執行緒退出,守護執行緒也會自動結束。
  2. run_asyncio_loop方法:
    • 在一個單獨的執行緒中啟動新的asyncio事件迴圈。
    • 使用asyncio.set_event_loop設定當前執行緒的事件迴圈,並呼叫loop.run_forever()來保持事件迴圈持續執行。
  3. request_remote方法:
    • 使用asyncio.run_coroutine_threadsafe將非同步請求任務提交給後臺事件迴圈執行,用於在非主執行緒中安全地執行協程。

3.2 基於asyncio實現多核非同步處理

單核非同步處理

asyncio的併發機制是基於協作式多工(協程),它不會並行地使用多個CPU核心來加速計算,所有的任務都是在單個核心上輪流執行的。以下程式碼模擬了1000個爬蟲任務,並使用單核非同步來執行:

import random
import asyncio
import time

# 模擬爬蟲任務,執行時會有隨機的延遲
async def fake_crawlers():
    # 隨機生成一個0.2到1.0秒之間的延遲,保留兩位小數
    io_delay = round(random.uniform(0.2, 1.0), 2)
    await asyncio.sleep(io_delay)

    result = 0
    # 隨機生成100,000到500,000之間的數字,用於模擬計算密集型任務
    # 這段程式碼耗時大約0.2秒到0.5秒之間
    for i in range(random.randint(100000, 500000)):
        result += i
    return result

# 主程式入口,負責建立並執行多個爬蟲任務
async def main():
    # time.monotonic()是用於測量時間間隔的可靠方法,它不受系統時間更改的影響
    start = time.monotonic()
    tasks = [asyncio.create_task(fake_crawlers()) for i in range(1000)]  # 模擬建立1000個任務

    await asyncio.gather(*tasks)  # 等待所有任務完成
    # 輸出所有任務完成的時間
    print(f"所有任務已完成,耗時 {time.monotonic() - start:.2f} 秒")
    
# 啟動程式
asyncio.run(main())

程式碼執行結果如下:

所有任務已完成,耗時 8.51 秒

多核非同步處理

要實現多核非同步處理,可以將非同步程式設計和多程序池結合起來使用。具體來說,主程式會把任務分成多個批次,每個批次由不同的程序來處理。每個程序內部,多個任務又是透過非同步方式並行執行的。這樣一來,計算密集型的任務可以透過多程序並行處理,而每個程序內部的I/O操作則可以透過asyncio來非同步管理,從而大幅提高整體效率。示例程式碼如下,程式碼將1000個任務分佈到10個子程序中並行執行,每個子程序執行100個模擬的爬蟲任務:

import random
import asyncio  #
import time  
from concurrent.futures import ProcessPoolExecutor  

# 模擬爬蟲任務,執行時會有隨機的延遲
async def fake_crawlers():
    # 隨機生成一個0.2到1.0秒之間的延遲,保留兩位小數
    io_delay = round(random.uniform(0.2, 1.0), 2)
    await asyncio.sleep(io_delay)

    result = 0
    # 隨機生成100,000到500,000之間的數字,用於模擬阻塞任務
    # 這段程式碼耗時大約0.2秒到0.5秒之間
    for i in range(random.randint(100000, 500000)):
        result += i
    return result

# 併發查詢任務,透過起始和結束索引分配任務
async def query_concurrently(begin_idx: int, end_idx: int):
    """ 啟動併發任務,透過起始和結束序列號 """
    tasks = []  
    # 根據給定的索引範圍(從 begin_idx 到 end_idx),建立併發任務
    for _ in range(begin_idx, end_idx, 1):
        tasks.append(asyncio.create_task(fake_crawlers()))  
    # 等待所有任務完成,並返回每個任務的結果
    results = await asyncio.gather(*tasks)
    return results 

# 批次任務執行函式,使用子程序池並行執行任務
def run_batch_tasks(batch_idx: int, step: int):
    """ 在子程序中執行批次任務 """
    # 計算當前批次任務的起始和結束索引
    begin = batch_idx * step + 1  # 當前批次任務的起始索引
    end = begin + step  # 當前批次任務的結束索引

    # 使用 asyncio.run() 啟動非同步任務並獲取結果
    results = [result for result in asyncio.run(query_concurrently(begin, end))]
    return results  

# 主函式,分批次將任務分配到子程序中執行
async def main():
    """ 將任務分批次分配到子程序中執行 """
    start = time.monotonic()  

    loop = asyncio.get_running_loop()  # 獲取當前執行的事件迴圈
    # 建立程序池執行器,用於將任務分配到多個子程序中執行
    with ProcessPoolExecutor() as executor:
        # 啟動多個批次任務,並行執行。每個批次執行 100個任務,共啟動10個批次
        tasks = [loop.run_in_executor(executor, run_batch_tasks, batch_idx, 100)
                 for batch_idx in range(10)] 

    # 等待所有子程序任務完成,並將結果彙總
    results = [result for sub_list in await asyncio.gather(*tasks) for result in sub_list]

    # 輸出所有任務完成的時間
    print(f"所有任務已完成,耗時 {time.monotonic() - start:.2f} 秒")

# 程式入口
if __name__ == "__main__":
    asyncio.run(main())  

程式碼執行結果如下:

所有任務已完成,耗時 1.83 秒

3.3 圖片下載器

若經常需要從網際網路下載檔案,可以使用aiohttp庫來實現任務的自動化。下面提供了一個簡單的指令碼,用於從指定URL下載檔案:

建立本地圖片伺服器

為了提供圖片下載連結,以下程式碼展示瞭如何使用FastAPI框架建立一個簡單的Web應用程式,用於上傳、管理和訪問圖片:

import os
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
import uvicorn

app = FastAPI()

# 配置圖片儲存目錄
UPLOAD_DIR = "./uploaded_images"
if not os.path.exists(UPLOAD_DIR):
    os.makedirs(UPLOAD_DIR)

# 將圖片目錄掛載為靜態檔案目錄
app.mount("/images", StaticFiles(directory=UPLOAD_DIR), name="images")

# 上傳圖片介面
@app.post("/upload/")
async def upload_image(file: UploadFile = File(...)):
    try:
        # 定義圖片儲存路徑
        file_path = os.path.join(UPLOAD_DIR, file.filename)
        
        # 儲存圖片到本地
        with open(file_path, "wb") as f:
            f.write(file.file.read())

        # 返回圖片的訪問 URL
        image_url = f"http://127.0.0.1:8000/images/{file.filename}"
        return {"image_url": image_url}
    
    except Exception as e:
        return {"error": str(e)}

# 獲取所有上傳圖片的連結
@app.get("/images_list/")
async def list_images():
    try:
        # 獲取目錄下的所有檔案
        files = os.listdir(UPLOAD_DIR)
        image_urls = [f"http://127.0.0.1:8000/images/{file}" for file in files if os.path.isfile(os.path.join(UPLOAD_DIR, file))]
        return {"image_urls": image_urls}
    except Exception as e:
        return {"error": str(e)}

# 獲取單個圖片
@app.get("/image/{image_name}")
async def get_image(image_name: str):
    try:
        file_path = os.path.join(UPLOAD_DIR, image_name)
        if os.path.exists(file_path):
            return FileResponse(file_path)
        else:
            return {"error": "Image not found"}
    except Exception as e:
        return {"error": str(e)}

# 啟動 FastAPI 伺服器
if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

該程式碼實現了一個圖片上傳和訪問服務,包含以下三個主要介面:

  • 伺服器啟動後,會監聽本地地址127.0.0.1的8000埠。
  • 客戶端可以透過以下方式與伺服器進行互動:
    • 訪問http://127.0.0.1:8000/upload/上傳圖片,並獲取返回的圖片 URL。
    • 訪問http://127.0.0.1:8000/images_list/檢視所有已上傳圖片的 URL。
    • 訪問http://127.0.0.1:8000/images/{image_name} 來檢視特定圖片。

注意,所有上傳和儲存的圖片都會儲存在本地的uploaded_images資料夾中。

圖片下載

以下程式碼利用了aiohttp、asyncio和aiofiles庫,透過非同步方式從API獲取圖片URL列表,並將圖片下載到指定目錄。藉助這些庫的結合,程式碼能夠高效地處理HTTP請求、檔案下載和檔案操作,同時確保主程式的執行不被阻塞:

import aiohttp  # 匯入 aiohttp 庫,用於非同步 HTTP 請求
import asyncio  # 匯入 asyncio 庫,用於管理非同步任務
import aiofiles  # 匯入 aiofiles 庫,用於非同步檔案操作
import os 

# 獲取圖片 URL 列表的非同步函式
async def get_image_urls(api_url):
    try:
        # 使用 aiohttp 啟動一個非同步 HTTP 會話
        async with aiohttp.ClientSession() as session:
            # 非同步傳送 GET 請求以獲取 API 返回的資料
            async with session.get(api_url) as response:
                # 如果響應狀態碼是 200 (請求成功)
                if response.status == 200:
                    # 將響應內容解析為 JSON 格式
                    data = await response.json()
                    # 從 JSON 資料中提取圖片 URL 列表,若沒有則返回空列表
                    return data.get("image_urls", [])
                else:
                    # 如果請求失敗,列印錯誤資訊
                    print(f"從 {api_url} 獲取圖片列表失敗。狀態碼: {response.status}")
                    return []
    except Exception as e:
        # 如果發生任何異常,列印錯誤資訊
        print(f"獲取圖片列表時發生錯誤: {e}")
        return []

# 下載檔案的非同步函式
async def download_file(url, save_directory):
    try:
        # 使用 aiohttp 啟動非同步 HTTP 會話
        async with aiohttp.ClientSession() as session:
            # 非同步傳送 GET 請求以獲取檔案內容
            async with session.get(url) as response:
                # 如果響應狀態碼是 200 (請求成功)
                if response.status == 200:
                    # 確保儲存檔案的目錄存在,若不存在則建立
                    os.makedirs(save_directory, exist_ok=True)
                    
                    # 從 URL 中提取檔名
                    filename = os.path.join(save_directory, url.split('/')[-1])
                    # 非同步開啟檔案以進行寫入操作
                    async with aiofiles.open(filename, 'wb') as file:
                        # 讀取響應內容
                        content = await response.read()
                        # 將內容寫入本地檔案
                        await file.write(content)
                    print(f"已下載 {filename}")
                else:
                    # 如果下載失敗,列印錯誤資訊
                    print(f"下載 {url} 失敗。狀態碼: {response.status}")
    except Exception as e:
        # 如果發生任何異常,列印錯誤資訊
        print(f"下載 {url} 時發生錯誤: {e}")

# 根據獲取的圖片 URL 列表進行下載的非同步函式
async def download_images(api_url, save_directory):
    # 呼叫 get_image_urls 函式獲取圖片 URL 列表
    image_urls = await get_image_urls(api_url)
    
    # 如果沒有獲取到圖片 URL,則列印提示並返回
    if not image_urls:
        print("沒有找到需要下載的圖片。")
        return
    
    # 為每個圖片 URL 建立一個下載任務
    tasks = [download_file(url, save_directory) for url in image_urls]
    
    # 使用 asyncio.gather 並行執行所有下載任務
    await asyncio.gather(*tasks)

# 啟動事件迴圈,開始下載圖片
if __name__ == "__main__":
    # API 地址,提供圖片 URL 列表
    api_url = "http://127.0.0.1:8000/images_list/"
    # 指定儲存下載圖片的目錄
    save_directory = "downloads"  

    # 獲取事件迴圈並執行下載任務
    loop = asyncio.get_event_loop()
    loop.run_until_complete(download_images(api_url, save_directory))

3.4 生產者消費者模型

生產者-消費者模型(Producer-Consumer Model)是一種經典的併發程式設計模式,旨在解決多個任務之間生產和消費的協調問題,從而確保資源得到合理利用並保證資料按順序處理。該模型透過生產者和消費者兩個角色,模擬共享資源的生產和消費過程。以下程式碼實現了一個基本的生產者-消費者模型,採用了asyncio進行非同步任務處理:

import asyncio
from asyncio import Queue
from typing import List

# 生產者函式,負責將物品新增到佇列
async def produce_items(queue: Queue, items: List[int], producer_name: str):
    for item in items:
        await queue.put(item)  # 將物品放入佇列
        print(f"{producer_name} 新增物品:{item}")
        await asyncio.sleep(0.5)  # 模擬生產過程中的等待時間
    print(f"{producer_name} 完成所有物品的生產")

# 消費者函式,負責從佇列中取出並處理物品
async def consume_items(queue: Queue, consumer_name: str):
    while True:
        item = await queue.get()  # 阻塞直到獲取到一個物品
        if item is None:  # 使用None作為結束訊號
            queue.task_done()  # 標記任務完成
            break  # 退出迴圈
        print(f"{consumer_name} 處理物品:{item}")
        await asyncio.sleep(1)  # 模擬處理物品的時間
        queue.task_done()  # 標記任務完成

# 主函式,負責啟動多個生產者和消費者任務
async def main():
    queue = Queue()  # 建立一個佇列
    items_to_produce = ['A','B','C','D']  # 需要生產的物品列表
    
    # 建立產者任務(例如3個生產者)
    producer_tasks = [
        asyncio.create_task(produce_items(queue, items_to_produce, f"生產者_{i}"))
        for i in range(3)  
    ]
    
    # 建立消費者任務(例如2個消費者)
    consumer_tasks = [
        asyncio.create_task(consume_items(queue, f"消費者_{i}"))
        for i in range(2)
    ]
    
    # 等待所有生產者任務完成
    await asyncio.gather(*producer_tasks)
    
    # 生產者完成後,傳送 None 給消費者,通知它們退出
    for _ in consumer_tasks:
        await queue.put(None)
    
    # 等待佇列中的所有任務處理完成
    await queue.join()
    
    # 等待所有消費者任務完成
    await asyncio.gather(*consumer_tasks)

if __name__ == '__main__':
    # 執行主函式
    asyncio.run(main())

4 參考

  • Python非同步程式設計庫asyncio使用指北
  • Python Asyncio: The Complete Guide
  • Combining Traditional Thread-Based Code and Asyncio in Python
  • Harnessing Multi-Core Power with Asyncio in Python
  • 21 Simple Python Scripts That Will Automate Your Daily Tasks
  • Asyncio:一個非同步的 Python 併發程式設計庫!

相關文章