Python 藉助 asyncio 實現併發編程

asyncio 基礎

創建協程

使用 async 關鍵字創建 coroutine

async def coroutine_add_one(number: int) -> int:
    return number + 1


def add_one(number: int) -> int:
    return number + 1


function_result = add_one(1)
coroutine_result = coroutine_add_one(1)

print(
    f'Function result is {function_result} and the type is {type(function_result)}')
# => Function result is 2 and the type is <class 'int'>

print(
    f'Coroutine result is {coroutine_result} and the type is {type(coroutine_result)}')
# => Coroutine result is <coroutine object coroutine_add_one at 0x7f9f495f20a0> and the type is <class 'coroutine'>
# => sys:1: RuntimeWarning: coroutine 'coroutine_add_one' was never awaited

創建 coroutine 和創建普通的函數一樣直接,唯一的區別在於使用 async def 而不是 def
當我們直接調用協程 coroutine_add_one 時,傳入的參數並沒有被加 1 然後返回計算結果,我們只是得到了一個 coroutine object
即我們只是創建了一個能夠在之後的某個時間運行的 coroutine 對象,爲了運行它,我們總是需要顯式地將其放入 event loop 中。最簡單的方式就是使用 asyncio.run 函數。

運行 coroutine

import asyncio


async def coroutine_add_one(number: int) -> int:
    return number + 1


result = asyncio.run(coroutine_add_one(1))
print(result)
# => 2

asyncio.run 是 asyncio 應用程序的入口。

使用 await 關鍵字暫停執行
asyncio 的真正用處,在於能夠在一個長時間運行的操作過程中,暫停執行,從而令 event loop 有機會處理其他任務。“暫停”的動作通過 await 關鍵字觸發。await 後面通常緊跟着一個對 coroutine (更嚴謹地說,一個 awaitable 對象)的調用。

import asyncio


async def add_one(number: int) -> int:
    return number + 1


async def main() -> None:
    one_plus_one = await add_one(1)
    two_plus_one = await add_one(2)
    print(one_plus_one)
    # => 2
    print(two_plus_one)
    # => 3


asyncio.run(main())

首先 await 對協程 add_one(1) 的調用,此時父協程(即 main())被暫停,add_one(1) 執行並獲取結果(2),main() 協程恢復執行,將結果賦值給 one_plus_one;同樣地,對協程 add_one(2)await 也會導致 main() 被暫停和恢復。

sleep

前面的例子只是爲了介紹協程的基本語法,並沒有涉及任何 long-running 操作,因而也沒有享受到 asyncio 在併發方面的作用。我們可以藉助 asyncio.sleep 函數模擬 web API 請求或者數據庫查詢等長時間運行的操作,asyncio.sleep 能夠令某個協程“睡眠”指定的時間(秒)。
asyncio.sleep 本身就是一個協程,因而當我們在某個協程中 await asyncio.sleep 時,其他部分代碼就得到了執行的機會。

sleep 實現 delay 函數

# util.py
import asyncio


async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

運行兩個協程

import asyncio
from util import delay


async def add_one(number: int) -> int:
    return number + 1


async def hello_world_message() -> str:
    await delay(1)
    return 'Hello Wrold!'


async def main() -> None:
    message = await hello_world_message()
    one_plus_one = await add_one(1)
    print(one_plus_one)
    print(message)
    # => sleeping for 1 second(s)
    # => finished sleeping for 1 second(s)
    # => 2
    # => Hello Wrold!


asyncio.run(main())

運行上面的代碼,先是等待 1 秒鐘,之後纔是兩個函數調用的結果被打印出來。我們本來希望看到的是,兩個協程併發地執行,add_one(1) 的結果直接被輸出,並不需要等待 hello_world_message() 中的 sleep 結束。
實際上 await 會暫停其所在的協程(這裏是 main),並且不會執行當前協程中的任何其他代碼,直到 await 表達式獲得一個結果。hello_world_message 需要 1 秒鐘才能返回結果,因而 main 協程也會被暫停 1 秒鐘。排在它後面的 add_one(1) 在暫停結束後執行並返回結果。

上面的代碼和同步、順序執行的代碼沒有表現出任何區別。爲了實現併發,我們需要引入一個新的概念 task

tasks

Task 是對協程的一種包裝,能夠將一個協程調度至 event loop 並爭取儘快執行。這種調度是以一種非阻塞的方式發生的,即 task 被創建後會立即返回,不必等待其運行結束,從而我們能夠有機會執行其他代碼。

併發地執行多個 task

import asyncio
from util import delay


async def hello_every_second():
    for i in range(2):
        await asyncio.sleep(1)
        print("I'm running other code while I'm waiting!")


async def main():
    first_delay = asyncio.create_task(delay(3))
    second_delay = asyncio.create_task(delay(3))
    await hello_every_second()
    await first_delay
    await second_delay


asyncio.run(main())
# => sleeping for 3 second(s)
# => sleeping for 3 second(s)
# => I'm running other code while I'm waiting!
# => I'm running other code while I'm waiting!
# => finished sleeping for 3 second(s)
# => finished sleeping for 3 second(s)

上述代碼創建了 2 個 task,每個都需要 3 秒鐘才能執行完畢。兩次對 create_task 的調用都會立即返回。由於 task 調度的原則是儘快執行,當後面的 await 代碼刷新了一次 event loop 之後,前面創建的 2 個 task 會立即被執行(非阻塞)。
兩個 delay task 在 sleep 過程中,應用是閒置的,我們得以有機會運行其他代碼。協程 hello_every_second 每隔 1 秒輸出一條消息。整個應用總的運行時間大約是 3 秒,即大約等於耗時最長的異步任務的時間,而不是像順序執行的程序那樣,等於多個任務運行時間的總和。

協程和任務的陷阱

將一些長時間運行的任務併發的執行,能夠帶來很大程度上的性能提升。因而我們會傾向於在應用的任何地方使用協程和 task。事實上,僅僅將函數用 async 修飾,將其封裝進 task,並不總是帶來性能上的提升。甚至有些情況下還會降低程序的效率。
最主要的情形有兩種,一個是在不借助多進程的情況下,嘗試在 task 或協程中運行 CPU-bound 代碼;另一種是在不借助多線程的情況下調用阻塞式 I/O-bound API

CPU 密集型任務

有時候我們需要一些函數執行 CPU 密集型的任務,比如對一個很大的字典執行循環或者數學計算。爲了提升效率,我們會想着將它們放置在單獨的 task 中運行。然而現實是,asyncio 使用單線程併發模型,我們依然會受到單個線程和 GIL 的限制

計算協程運行時間

# util.py
import asyncio
import functools
import time
from typing import Callable, Any


def async_timed():
    def wrapper(func: Callable) -> Callable:
        @functools.wraps(func)
        async def wrapped(*args, **kwargs) -> Any:
            print(f'Starting {func} with {args} {kwargs}')
            start = time.time()
            try:
                return await func(*args, **kwargs)
            finally:
                end = time.time()
                total = end - start
                print(f'finished {func} in {total:.4f} second(s)')
        return wrapped
    return wrapper


@async_timed()
async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

運行 CPU-bound 代碼

import asyncio
from util import delay, async_timed


@async_timed()
async def cpu_bound_work() -> int:
    counter = 0
    for i in range(100000000):
        counter = counter + 1
    return counter


@async_timed()
async def main():
    task_one = asyncio.create_task(cpu_bound_work())
    task_two = asyncio.create_task(cpu_bound_work())
    delay_task = asyncio.create_task(delay(4))
    await task_one
    await task_two
    await delay_task


asyncio.run(main())
# => Starting <function main at 0x7f2d6b85bc70> with () {}
# => Starting <function cpu_bound_work at 0x7f2d6c2bba30> with () {}
# => finished <function cpu_bound_work at 0x7f2d6c2bba30> in 2.7423 second(s)
# => Starting <function cpu_bound_work at 0x7f2d6c2bba30> with () {}
# => finished <function cpu_bound_work at 0x7f2d6c2bba30> in 2.7430 second(s)
# => Starting <function delay at 0x7f2d6b85a0e0> with (4,) {}
# => sleeping for 4 second(s)
# => finished sleeping for 4 second(s)
# => finished <function delay at 0x7f2d6b85a0e0> in 4.0048 second(s)
# => finished <function main at 0x7f2d6b85bc70> in 9.4903 second(s)

上述代碼創建了 3 個 task,但實際執行時依然是順序的而非併發的,耗費的時間並沒有變少。兩個 CPU-bound task 是依次執行的,甚至 delay_task 也並沒有與其他兩個任務呈現併發性。原因在於我們先創建了兩個 CPU-bound 任務,這兩個任務會阻塞 event loop,阻止其調度執行任何其他任務。
因此,總的運行時間等於兩個 CPU-bound 任務執行完畢的時間加上 delay 任務運行的 4 秒。即 asyncio 並沒有爲 CPU-bound 的任務帶來任何性能上的提升。
假如我們需要在執行 CPU-bound 任務的同時仍使用 async 語法,就必須藉助多進程,告訴 asyncio 在 process pool 中執行任務。

阻塞式 API

我們也會傾向於使用現有的庫執行 I/O-bound 操作,再將其封裝進協程。然而,這會引起與 CPU-bound 操作同樣的問題。因爲這些 API 會阻塞 main 線程。
當我們在協程內部調用一個阻塞的 API,我們會阻塞 event loop 線程本身,線程被阻塞請求佔據,導致 event loop 無法調度任何其他協程和任務。阻塞式 API 請求包括 requests 庫和 time.sleep 等。通常來說,任何執行 I/O 操作且不是協程的函數,以及執行 CPU 密集型任務的函數,都可以認爲是阻塞的

協程內部調用阻塞式 API

import asyncio
import requests
from util import async_timed


@async_timed()
async def get_example_status() -> int:
    return requests.get('http://www.example.com').status_code


@async_timed()
async def main():
    task_1 = asyncio.create_task(get_example_status())
    task_2 = asyncio.create_task(get_example_status())
    task_3 = asyncio.create_task(get_example_status())
    await task_1
    await task_2
    await task_3


asyncio.run(main())
# => Starting <function main at 0x7f4335080790> with () {}
# => Starting <function get_example_status at 0x7f4335186170> with () {}
# => finished <function get_example_status at 0x7f4335186170> in 0.5144 second(s)
# => Starting <function get_example_status at 0x7f4335186170> with () {}
# => finished <function get_example_status at 0x7f4335186170> in 0.5163 second(s)
# => Starting <function get_example_status at 0x7f4335186170> with () {}
# => finished <function get_example_status at 0x7f4335186170> in 0.5177 second(s)
# => finished <function main at 0x7f4335080790> in 1.5488 second(s)

main 協程運行的總時間基本上等於所有 task 運行的時間之和。即我們並沒有獲取到任何併發上的收益。原因在於 requests 庫是阻塞的,任何調用都會阻塞當前線程,而 asyncio 只有一個線程,在阻塞調用結束之前,線程中的 event loop 沒有機會以異步的形式運行任何任務。
當你使用的庫並沒有返回協程,你並沒有在自己的協程中使用 await 關鍵字,很大可能你就是在進行阻塞的函數調用。當前我們使用的大多數 API 都是阻塞的,並不支持與 asyncio 開箱即用。
要想體驗到 asyncio 帶來的異步和併發特性,就必須使用原生支持協程和非阻塞 socket 的庫,比如 aiohttp。或者你堅持使用 requests 庫,同時又需要 async 語法,就必須顯式地告訴 asyncio 使用多線程的方式,通過 thread pool executor 執行阻塞調用。

藉助支持協程的庫 aiohttp 實現併發

import asyncio
from aiohttp import ClientSession
from util import async_timed


@async_timed()
async def get_example_status() -> int:
    session = ClientSession()
    resp = await session.get('http://example.com')
    await session.close()
    return resp.status


@async_timed()
async def main():
    task_1 = asyncio.create_task(get_example_status())
    task_2 = asyncio.create_task(get_example_status())
    task_3 = asyncio.create_task(get_example_status())
    await task_1
    await task_2
    await task_3


asyncio.run(main())
# => Starting <function main at 0x7fd9f90b6a70> with () {}
# => Starting <function get_example_status at 0x7fd9f90b63b0> with () {}
# => Starting <function get_example_status at 0x7fd9f90b63b0> with () {}
# => Starting <function get_example_status at 0x7fd9f90b63b0> with () {}
# => finished <function get_example_status at 0x7fd9f90b63b0> in 0.5191 second(s)
# => finished <function get_example_status at 0x7fd9f90b63b0> in 0.5191 second(s)
# => finished <function get_example_status at 0x7fd9f90b63b0> in 0.5191 second(s)
# => finished <function main at 0x7fd9f90b6a70> in 0.5196 second(s)

可以看到所有 task 執行的總時間,基本上只比一個 task 運行的時間多一點點。此時的程序是併發執行的。

取消任務

取消任務

每個 task 對象都有一個 cancel 方法可以幫助我們隨時終止該任務。當我們 await 取消的任務時,會報出 CancelledError 異常。
比如我們調度執行某個任務,又不希望該任務運行的時間超過 5 秒:

import asyncio
from asyncio import CancelledError
from util import delay


async def main():
    long_task = asyncio.create_task(delay(10))

    seconds_elapsed = 0

    while not long_task.done():
        print('Task not finished, checking again in a second.')
        await asyncio.sleep(1)
        seconds_elapsed = seconds_elapsed + 1
        if seconds_elapsed == 5:
            long_task.cancel()

    try:
        await long_task
    except CancelledError:
        print('Our task was cancelled')


asyncio.run(main())
# => Task not finished, checking again in a second.
# => Starting <function delay at 0x7fdb383ae0e0> with (10,) {}
# => sleeping for 10 second(s)
# => Task not finished, checking again in a second.
# => Task not finished, checking again in a second.
# => Task not finished, checking again in a second.
# => Task not finished, checking again in a second.
# => Task not finished, checking again in a second.
# => finished <function delay at 0x7fdb383ae0e0> in 5.0079 second(s)
# => Our task was cancelled

需要注意的是,CancelledError 只會在 await 語句處拋出,調用 cancel 方法並不會神奇地強行關閉正在運行的任務,只有你剛好遇到 await 時任務纔會被終止,不然就等待下一個 await

使用 wait_for 設置超時時間
每隔一段時間手動進行檢查,以確定是否取消某個任務,並不算一種簡單的處理方式。asyncio 提供了一個 wait_for 函數,它接收一個協程或者任務,以及超時的秒數作爲參數,返回一個協程對象。
若任務運行超時,一個 TimeoutException 就會被拋出,任務自動被終止。

import asyncio
from util import delay


async def main():
    delay_task = asyncio.create_task(delay(2))
    try:
        result = await asyncio.wait_for(delay_task, timeout=1)
        print(result)
    except asyncio.exceptions.TimeoutError:
        print('Got a timeout')
        print(f'Was the task cancelled? {delay_task.cancelled()}')

asyncio.run(main())
# => Starting <function delay at 0x7f71e18160e0> with (2,) {}
# => sleeping for 2 second(s)
# => finished <function delay at 0x7f71e18160e0> in 1.0016 second(s)
# => Got a timeout
# => Was the task cancelled? True

asyncio.shield
在另外一些情況下,我們有可能並不希望直接取消某個超時的任務,而是當任務運行時間過長時,提醒用戶這個情況,但是並不執行任何 cancel 操作。
shield 可以幫助我們實現這樣的功能。

from util import delay


async def main():
    task = asyncio.create_task(delay(10))

    try:
        result = await asyncio.wait_for(asyncio.shield(task), 5)
        print(result)
    except asyncio.exceptions.TimeoutError:
        print("Task took longer than five seconds, it will finish soon!")
        result = await task
        print(result)


asyncio.run(main())
# => Starting <function delay at 0x7ff344d120e0> with (10,) {}
# => sleeping for 10 second(s)
# => Task took longer than five seconds, it will finish soon!
# => finished sleeping for 10 second(s)
# => finished <function delay at 0x7ff344d120e0> in 10.0063 second(s)
# => 10

參考資料

Python Concurrency with asyncio

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章