Python中非同步模式

banq發表於2024-03-05

Python 的 asyncio 庫專為高效非同步程式設計而設計:

import asyncio

async def mock_api_request(i):
    print(f<font>"API request started {i}")
    await asyncio.sleep(1)  # 這可能是一個 API 呼叫,或其他一些 IO 繫結任務
    print(f
"API request completed {i}")

async def run():
    for i in range(1_000_000):
        await mock_api_request(i)

asyncio.run(run())

透過使用 async def 定義函式並使用 await 和 asyncio.sleep(1),我們建立了可以暫停和恢復的 coroutines,允許程式執行其他任務,而不是空閒等待。

這種方法尤其適用於 IO 繫結任務,如 API 呼叫,在這種情況下,事件迴圈可以併發管理多個任務,而不會阻塞。

我們呼叫 asyncio.run(run()),將我們的主函式啟動到事件迴圈中。這正是 asyncio 的優勢所在,它可以無縫地協調眾多 coroutines 的執行。

讓我們嘗試一下 asyncio 中的常見模式。讓我們建立一堆作業,將它們放在事件迴圈中,等待它們完成。

記憶體飢餓事件迴圈

# ... mock_api_request

async def run():
    tasks = []
    for i in range(1_000_000):
        tasks.append(asyncio.create_task(mock_api_request(i)))
    await asyncio.wait(tasks)

# ... everything else

  1. 我們從 mock_api_request 例程中建立一個任務。
  2. 我們將所有任務新增到列表任務中。(如果有一百萬個專案,可能會耗盡記憶體)
  3. 如果記憶體沒有耗盡,Python 將在呼叫 await asyncio.wait(tasks) 時開始工作。
  4. 每個任務都會進入事件迴圈。事件迴圈抓取一個任務並開始工作。
  5. Python 將同步執行任務,直到看到 await 關鍵字。
  6. 一旦看到 await 關鍵字,事件迴圈就會放棄,並切換到佇列中的下一個任務。
  7. 重複第 5 步和第 6 步,偶爾檢查等待的任務是否完成。
  8. 當等待的函式完成 IO 工作後,它將繼續執行,直到下一個等待或函式結束。
  9. 這樣一直持續到列表中的每個專案都完成為止。

酷,這就是事件迴圈的大致作用。

那麼,我們如何加快同步-非同步程式的執行速度呢?

Batching

# ... mock_api_request

async def run():
    tasks = []
    batch_size = 100
    for i in range(1_000_000):
        tasks.append(asyncio.create_task(mock_api_request(i)))
        if len(tasks) >= batch_size:
            await asyncio.wait(tasks)
            tasks = []
    
    if tasks:  # 如果還有剩餘任務,請等待它們完成
        await asyncio.wait(tasks)

# ... everything else

至少在記憶體使用方面,這要好得多。不過,速度不一定比memory-inefficient的版本快。

你會注意到,在分批執行時,每次等待 asyncio.wait(tasks),你都必須等最後一個任務完成後才能開始下一批。這可不行。

生產者/消費者模式
在下一次迭代中,我們將引入 asyncio.Queue()。

# ... mock_api_request

async def producer(queue: asyncio.Queue):
    for i in range(1_000_000):
        await queue.put(i)

async def consumer(queue: asyncio.Queue):
    while True:
        item = await queue.get()
        await mock_api_request(item)

async def run():
    queue = asyncio.Queue(maxsize=100)
    producer_task = asyncio.create_task(producer(queue))
    consumer_task = asyncio.create_task(consumer(queue))
    await asyncio.wait([producer_task, consumer_task])


當我們執行這個程式時,我們會注意到事情或多或少又恢復了同步。現在的情況是,我們透過生產者將所有專案放入佇列,但消費者每次只抓取佇列中的一個專案。

讓我們把生產者/消費者模式與之前的批處理版本結合起來。

多個消費者
讓我們再次修改執行函式。

async def run():
    queue = asyncio.Queue(maxsize=100)
    number_of_consumers = 10
    producer_task = asyncio.create_task(producer(queue))
    consumers = []
    for _ in range(number_of_consumers):
        consumer_task = asyncio.create_task(consumer(queue))
        consumers.append(consumer_task)
    await asyncio.wait([producer_task, *consumers])

現在我們建立一個列表,並將其分配給變數消費者。然後,我們建立 10 個 asyncio 任務,等待生產者和所有 10 個消費者完成工作。現在,只要你執行它,消費者就會隨時從佇列中拉出,你就能獲得源源不斷的工作!

當生產者完成工作,佇列為空時,消費者就會掛起,等待永遠不會到來的任務。程序永遠不會停止。如果你的生產者連線的是一個永遠不會結束的源,那麼這種情況可能還不錯。為此,我們需要某種方法來告訴消費者退出。

使用 `None` 傳送訊號

async def consumer(queue: asyncio.Queue):
    while True:
        item = await queue.get()
        if item is None:
            break
        await mock_api_request(item)


我們在消費者中新增了一個條件,用於檢查佇列中是否傳遞了 None。我們可以將此作為退出的訊號,因為通常我們可以假設專案不應該是 None。

我們還應該更新生產者以傳送此訊號。

async def producer(queue: asyncio.Queue, number_of_consumers: int):
    for i in range(1_000_000):
        await queue.put(i)
    for _ in range(number_of_consumers):
        await queue.put(None)

請注意我們是如何在生產者的函式簽名中新增 number_of_consumers 的。我們需要在佇列中放入與 Worker 數量相同的 None,因為每個 None 都需要自己的訊號。只要記住在執行方法中更新生產者任務即可。

producer_task = asyncio.create_task(producer(queue, number_of_consumers))

有時,"None  "是一個有效值,或者您需要更復雜的訊號。Asyncio 可以滿足您的需求。

Asyncio 事件
讓我們新增一個 asyncio.Event() 變數,用作我們的訊號。

async def run():
    queue = asyncio.Queue(maxsize=100)
    stop_event = asyncio.Event()
    producer_task = asyncio.create_task(producer(queue, stop_event))
    consumers = []
    number_of_consumers = 10
    for _ in range(number_of_consumers):
        consumer_task = asyncio.create_task(consumer(queue, stop_event))
        consumers.append(consumer_task)
    await asyncio.wait([producer_task, *consumers])

我們還需要更新消費者和生產者。

async def producer(queue: asyncio.Queue, stop_event: asyncio.Event):
    for i in range(1_000_000):
        await queue.put(i)
    stop_event.set()


async def consumer(queue: asyncio.Queue, stop_event: asyncio.Event):
    while True:
        if queue.empty() and stop_event.is_set():
            break
        item = await queue.get()
        await mock_api_request(item)

如果您使用 number_of_consumers 在佇列中放入了大量 None 值,則需要從生產者的函式簽名中刪除該值。

在消費者中,重要的是 if stop event.is_set() 和 queue.empty():。這將確保在實際退出之前首先完成所有任務。這可能會根據你的需要而有所不同。例如,您可能需要一個單獨的 asyncio.Event 用於在不耗盡佇列的情況下終止函式。

總結
現在您應該掌握了在 Python 中最佳化和提高 IO 繫結呼叫吞吐量的最佳化路徑和各種模式。

您還可以做一些事情來提高速度。例如,您可以改變消費者的數量。您還可以將 "佇列 "部分解除安裝到專用佇列(如 RabbitMQ 或 NATS),這樣您就可以擴充套件和/或分離生產者和消費者。

 

相關文章