Python: 進階系列之五:併發編程:異步IO(asyncio) 協程(coroutine)與任務(task)的使用

1. 協程(coroutine)的概念

根據Wikipedia, “協程是非搶先多任務的一般子例程,通過允許多個入口點用於在某些位置掛起和恢復執行的計算機程序組件”。這是一種相當技術的說法,簡單來說就是函數的內部可以中斷,轉而去執行其他的函數,並且可以保留前一函數的狀態,等在適當的時候再返回來接着執行前一函數,看起來同時像在做多件事情。人也是可以同時做多件事的,如果把協程比作一個人的話,他想去泡茶,在等待水燒開的同時可以去洗茶壺、可以寫代碼,一旦水開了,我就可以回來繼續泡茶了。相當於JavaScript中的Promise.

    協程的特點在於是始終只有一個線程執行

如果想利用多核CPU,最好是使用多進程+協程。

2. 併發(concurrency)編程的三種方式及比較

  • 多進程(multiprocessing)
  • 線程(threading)
  • 協程(coroutine)

多線程和多進程對IO的調度主要取決於系統,而協程的方式,調度來自用戶。與線程相比,協程有着極高的執行效率,沒有線程切換的開銷,協程中控制共享資源不加鎖,也不存在同時寫變量的衝突。

Python由於衆所周知的GIL的原因,導致其線程無法發揮多核的並行計算能力(當然,後來有了multiprocessing,可以實現多進程並行),顯得比較雞肋。既然在GIL之下,同一時刻只能有一個線程在運行,那麼對於CPU密集的程序來說,線程之間的切換開銷就成了拖累,而以I/O爲瓶頸的程序正是協程所擅長的: 多任務併發(非並行),每個任務在合適的時候掛起(發起I/O)和恢復(I/O結束)  。

即然協程這麼牛逼,本文就只講它了

3. 協程,一個簡單的實現

協程通過 async/await 語法進行聲明,如下

import asyncio
from datetime import datetime

async def main():
    print('hello', datetime.now())
    await asyncio.sleep(1)
    print('world',datetime.now())

# 注意這裏:如果你是在pycharm或vscode裏面運行代碼的話,請使用如下代碼
# asyncio.run(main())  #它相當於一個同步方法的一個入口函數,用到的非常多

# 如果你像我一樣在jupyter裏面運行代碼,可以直接使用如下代碼,因爲jupyter(IPython)已經是在一個事件循環裏運行了。
# 官網的說法:This function cannot be called when another asyncio event loop is running in the same thread.You can now use async/await at the top level in the IPython terminal and in the notebook, it should — in most of the cases — “just work”. Update IPython to version 7+, IPykernel to version 5+, and you’re off to the races.
await main() 

運行結果如下:

如果直接運行main()方法,它會打印出main()返回得是一個協程對象(coroutine object)

main() # <coroutine object main at 0x0000019B892CB2C8>

async 和await 不必要在一個方法中成對出現(在C#中,它們必須成對出現),也就是說只要在方法的前面加上async 關鍵字,它就是一個協程對象(coroutine object),相當於一個Promise對象,執行它的時候不會立即返回執行的結果。

python3.5以上的版本,可以使用async/await來定義協程的關鍵字,如果你瀏覽過以下關鍵字或函數,請忽略它,因爲它們都過時了或者是低層級的實現,如果你沒聽說過,那更好,沒有歷史包袱。除非你想使用舊版本的python,或者使用更底層的方法完成複雜的功能,本文是基於目前最新的python 3.7版本。

  • @asyncio.coroutine
  • yield from
  • loop.ensure_future()

4.  可等待對象

能使用await語句的都是可等待對象,python中有三種:

  • 任務(Task):一個協程對象就是一個原生可以掛起的函數,任務則是對協程進一步封裝,其中包含任務的各種狀態。不建議手動實例化Task
  • 協程(Coroutine) : 協程對象,指一個使用async關鍵字定義的函數,它的調用不會立即執行函數,而是會返回一個協程對象。協程對象需要註冊到事件循環,由事件循環調用。
  • Future:是一個特殊的低層級的可等待對象,通常情況,沒必要創建Future對象。

5. 併發運行任務gether()

使用gather併發運行任務,gether的方法如下:

    awaitable asyncio.gather(*aws, loop=None, return_exceptions=False)

  • 所有的可等等待對象會被自動打包成Task對象
  • 如果return_exceptions = False(默認),  引發的首個異常會立即傳播給等待 gather() 的任務, 也就是說,會立即拋錯,未執行的Task不會繼續執行。
  • 如果return_exceptions = True, 異常會和成功的結果一樣處理,並聚合至結果列表.
  • 如果gather()被取消,所有未完成的任務也會被取消
import asyncio
import threading


async def greet(name, delay):
    i = 1
    thread_name = threading.currentThread().name
    await asyncio.sleep(delay)
    print(f'Hello, {name}, i:{i}, thread name:{thread_name}, current time:{datetime.now()}')
    i += 1
    return name


async def main():
    thread_name = threading.currentThread().name
    print(f'start:: thread name:{thread_name}, current time:{datetime.now()}')

    task3 = asyncio.create_task(greet('CCC',3)) # 手動將協程greet('CCC',3)打包成Task對象

    result = await asyncio.gather(
        greet('AAA', 1), # 自動被打包成Task對象
        greet('BBB', 2), # 自動被打包成Task對象
        task3,  
    )

    print(result, type(result))

    print(f'end:: thread name:{thread_name}, current time:{datetime.now()}')

await main()

結果如下,可以看到,線程始終只有一個,名字叫MainThread,i 始終爲1,所以不存在同時寫變量的衝突,總時間只花費了3秒(由最長的task的執行時間決定),如果使用同步的方式來寫的話,時間需要花費6秒(1+2+3)。

 如果return_exceptions = True, 異常會和成功的結果一樣處理,並聚合至結果列表.

async def greet(name, delay):
    i = 1
    thread_name = threading.currentThread().name
    await asyncio.sleep(delay)
    print(
        f'Hello, {name}, i:{i}, thread name:{thread_name}, current time:{datetime.now()}')
    i += 1

    if name == 'BBB':
        1/0  # 這裏讓它除0,拋錯

    return name


async def main():
    thread_name = threading.currentThread().name
    print(f'start:: thread name:{thread_name}, current time:{datetime.now()}')

    task3 = asyncio.create_task(greet('CCC', 3))

    result = await asyncio.gather(
        greet('AAA', 1),
        greet('BBB', 2),
        task3,
        return_exceptions=True
    )  # 這裏 return_exceptions=True, 異常會和成功的結果一樣處理,並聚合至結果列表

    print(result, type(result))

    print(f'end:: thread name:{thread_name}, current time:{datetime.now()}')

await main()

 結果如下:

6. 取消任務

如果一個任務由於執行的時間過長,還沒有返回結果,我們可以手動將它取消

async def greet(name, delay):
    try:
        await asyncio.sleep(delay)
        print(f'Hello, {name}, current time:{datetime.now()}')

        return name
    except asyncio.CancelledError as error:
        print('CancelledError 被捕獲了')
        raise #繼續向上拋出

async def main():
    print(f'start:: current time:{datetime.now()}')

    task = asyncio.create_task(greet('AAA', 5))  # 創建一個任務,需要5秒才能執行完
    await asyncio.sleep(2)
    task.cancel()  # 在第二秒時就cancel它

    await asyncio.sleep(1)
    print('是否被cancel了:', task.cancelled())  # True
    print('是否結束了:', task.done())  # True
        
    # print('結果是啥:', task.result())  # 因爲task異常了,直接執行它會拋出異常
    # print('異常是啥:', task.exception())  # 會拋出task的異常

    print(f'end:: current time:{datetime.now()}')

await main()

 結果顯示如下:

上面的except中,如果將rasie這一行註釋掉,即不繼續向上拋出錯誤,會怎麼樣呢?結果如下:

也就是說greet()方法可以通過try/except來控制是否取消Task, 也就是說Task.cancel()不能保證Task被取消。

7.  超時處理

async def greet(name, delay):
    try:
        await asyncio.sleep(delay)
        print(f'Hello, {name}, current time:{datetime.now()}')

        return name
    except asyncio.TimeoutError as error:
        print('TimeoutError 永遠捕獲不了你')
        raise  # 既然捕獲不了你,拋出也沒用
    except asyncio.CancelledError as error:
        print('CancelledError 居然在這裏可以捕獲你,人生處處是驚喜呀')
        raise # 即使不向上拋,main函數中的TimeoutError依然會被捕獲


async def main():
    print(f'start:: current time:{datetime.now()}')

    task = asyncio.create_task(greet('AAA', 5))  # 創建一個任務,需要5秒才能執行完

    try:
        await asyncio.wait_for(task, 2) # 兩秒等不到你,我就不等了

    except asyncio.TimeoutError as error:
        print('TimeoutError 被捕獲了')

    print('是否被cancel了:', task.cancelled())  # True,可以看出因爲超時,task會被cancel
    print('是否結束了:', task.done())  # True

    # print('結果是啥:', task.result())  # 因爲task異常了,直接執行它會拋出異常
    # print('異常是啥:', task.exception())  # 會拋出task的異常

    print(f'end:: current time:{datetime.now()}')

await main()

結果如下:

8. 併發運行任務的另一種實現asyncio.wait()

coroutine asyncio.wait(aws*loop=Nonetimeout=Nonereturn_when=ALL_COMPLETED)

return_when 指定此函數應在何時返回。它必須爲以下常數之一:

常數

描述

FIRST_COMPLETED

函數將在任意可等待對象結束或取消時返回。

FIRST_EXCEPTION

函數將在任意可等待對象因引發異常而結束時返回。當沒有引發任何異常時它就相當於 ALL_COMPLETED

ALL_COMPLETED

函數將在所有可等待對象結束或取消時返回。

async def greet(name):
    result = await asyncio.sleep(1, f'Hello,{name}')
    return result

async def main():
    task = asyncio.create_task(greet('AAA'))
    done, pending = await asyncio.wait({task, greet('BBB')})

    # 運行會報錯
    # if task in pending:
    #     print('這段代碼將會運行1')

    if task in done:
        print('這段代碼將會運行2')

await main()

 9. 併發運行任務的比較:asyncio.gather()與asyncio.wait()

  • 兩種都能實現併發
  • asyncio.gather():是一種高層級的用法,它自動幫我們收集好了返回的結果,我們通常使用它
  • asyncio.wait():是一種低層級的用法,你可以對task作更多的控制,手動收集返回的結果

參考鏈接

 

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