🚥

Pythonのasync/awaitを理解したい【asyncio】

2024/09/23に公開

用語

概念を理解するために、非同期処理で何が起きているかを把握します。

  • イベントループ:タスクをスケジュールする
  • タスク:コルーチンを実行し、実行結果などを管理する
  • コルーチン:実行や一時停止ができる処理

私たちはやりたいことをコルーチンで書いてタスクにしてイベントループに入れるだけで、イベントループがうまいこと処理してくれる、というイメージです。

ここでいううまいこととは、CPUを使わないのに何秒もかかるタスクがあるときに、代わりに別のタスクにCPUを使わせる、といった具合です。

これらのことをするために、Pythonの標準ライブラリであるasyncioを使用します。

コルーチンを作成する(async)

コルーチンを作成するのは簡単で、defの前にasyncをつけるとコルーチン関数ができます。

# 通常の関数(同期関数)
def main():
    print('hello world')

# コルーチン関数(非同期関数)
async def async_main():
    print('hello async world')

通常の関数は以下の通りで、実行するとすぐに結果が返ってきます。

>>> main()
hello world

コルーチン関数は実行すると、コルーチンオブジェクトを返すようになります。

>>> async_main()
<coroutine object main at 0x123456789>

コルーチンを実行するにはasyncio.runをします。

>>> asyncio.run(async_main())
hello async world

コルーチンの完了を待つ(await)

コルーチンは一時停止ができる処理と書きました。何か別の処理が完了するまで待つ時にはawaitを使います。
1秒かかるhelloというコルーチンをmainで行うとします。その際、helloが完了するまで待つためにawaitをつけます。

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def main():
    await hello() # helloの完了を待つ

これを実行すると以下のようになります。

>>> asyncio.run(main())
I say,
hello

一方で、awaitをつけないと単にコルーチンオブジェクトを生成しているだけなので何も起きません。

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def main():
    hello() # awaitがない

これを実行するとhelloが待たれていないというエラーが出ます。

>>> asyncio.run(main())
RuntimeWarning: coroutine 'hello' was never awaited

最初にコルーチンはタスクとして実行されると書きました。実はコルーチンをawaitすると、内部的にタスクが作成されコルーチンが実行されます。

なお、awaitは一時停止をすることが目的なのでasyncの中でしか書けません。
一方で、asyncio.runは同期関数からコルーチンを実行することが目的なのでasyncの中では書けません。

タスクを作成する

コルーチンは明示的にタスクを作成して実行することもできます。
asyncio.create_taskを使うと、コルーチンオブジェクトタスクオブジェクトにして実行できます。

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def main():
    await asyncio.create_task(hello()) # helloタスクの完了を待つ

これを実行すると以下のようにコルーチンをawaitした時と同じ結果になります。

>>> asyncio.run(main())
I say,
hello

一方で、awaitしなかった場合の挙動は少し異なります。

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def main():
    asyncio.create_task(hello())
>>> asyncio.run(main())
I say,

asyncio.create_taskでは、タスクの実行も行われます。
そのため、print('I say,')は実行されていますがmainhelloの完了を待たないためprint('hello')は実行されません。


複数のコルーチンを制御する

ここまででコルーチンとタスクを紹介しましたが、違いはどこに現れるのでしょうか。

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def goodbye():
    print('you say,')
    await asyncio.sleep(2) # 2秒かかる
    print('goodbye')

async def main():
    await goodbye()
    await hello()

これを実行すると3秒かかり以下のようになります。

>>> asyncio.run(main())
you say,
goodbye
I say,
hello

goodbyeの完了を待ってからhelloの実行をしているためです。

asyncio.create_taskを使った場合

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def goodbye():
    print('you say,')
    await asyncio.sleep(2) # 2秒かかる
    print('goodbye')

async def main():
    goodbye_task = asyncio.create_task(goodbye())
    hello_task = asyncio.create_task(hello())
    await goodbye_task
    await hello_task

これを実行すると2秒かかり以下のようになります。

>>> asyncio.run(main())
you say,
I say,
hello
goodbye

hellogoodbyeはともに実行されており、実行された順にprint('you say,')print('I say,')が実行されます。
print('goodbye')は2秒後に実行されるので、先にprint('hello')が実行されます。

asyncio.gatherを使うことで、複数のコルーチンを並行に実行することができます。
コルーチンもタスクも使用することができます。

async def hello():
    print('I say,')
    await asyncio.sleep(1) # 1秒かかる
    print('hello')

async def goodbye():
    print('you say,')
    await asyncio.sleep(2) # 2秒かかる
    print('goodbye')

async def main():
    await asyncio.gather(goodbye(), hello(), hello()) # タスクも同様

これを実行すると2秒かかり以下のようになります。

>>> asyncio.run(main())
you say,
I say,
I say,
hello
hello
goodbye

参考

https://docs.python.org/ja/3/library/asyncio-api-index.html

Discussion