Ccmmutty logo
Commutty IT
0 pv8 min read

非同期処理をちゃんと理解したい

https://cdn.magicode.io/media/notebox/808e82ff-a8c7-405f-b062-ecf70f260b43.jpeg
Pythonのプログラムを書いたりWEB開発をしたりする中で非同期処理を目にする場面は結構あり、自分もなんとなく使っている。
しかし、改めて非同期処理とは何か?と聞かれるとはっきり答えられないことに気がついた。
まさにこんな感じである。
Q. 同期処理ってなに?
A. プログラムを前から順に処理しながら1つ1つ完了を待って進むことです(完璧)
Q. じゃあ非同期処理ってなに?
A. なんか、1個の処理の終了を待たずに他の処理を並行してやらせることっぽいです
Q. どうしてawaitを使う関数はasyncを付けて定義しないといけないの?
A. なんか、async付けないと怒られるから
Q. そもそもawaitってなに?
A. なんか、awaitを付ければその処理の終了を待って次に進むことができるみたいです
Q. 終了を待って進むならそれって同期処理じゃないの?
A. ???????
これではいけませんね。

非同期処理について

非同期処理に関する基本的な用語をまとめながら、非同期処理とは何をやっているのかというイメージを整理する。

イベントループ

非同期処理の中核となる仕組みが イベントループである。
イベントループは、非同期タスクを管理・実行するための実行基盤であり主に次のことを繰り返している。
  1. 実行できるタスクがあるか確認
  2. 実行可能なタスクを1つ動かす
  3. I/O待ちになったタスクを一時停止する
  4. 待ちが解除されたタスクを再開する
この処理を止まらずに回し続けている(loop)ためイベントループと呼ばれる。
イベントループは基本的にシングルスレッドで動作し、タスクを直列に切り替えながら実行している。
ここで重要なのは、
  • 同時に複数の処理を行う 並列処理ではない
  • あるタスクの待ち時間中に別のタスクを進める 並行処理 である
という点である。
非同期処理は並行処理であって並列処理ではない
これらの考え方自体は Python に限らず、JavaScript など他の言語でも基本的に同様である。

非同期タスク

asyncを付けて定義された関数を実行すると、それが1つの非同期タスクとなる。
  • 同じ async関数を複数回実行してもそれぞれは別タスク
  • 異なるasync関数を実行しても呼び出しごとに別タスク
つまり、「実行1回 = 1タスク」 という扱いになる。
次の例では、async関数のtask()はA, B, C, Dの順に開始されるが、各タスクの処理時間に応じて終了タイミングは開始順とは異なる。
import asyncio
import random


async def task(name):
    # 停止時間をランダムに設定
    wait_time = random.randint(1, 10)
    print(f'{name}: start / waite {wait_time} sec')
    await asyncio.sleep(wait_time)
    print(f'{name}: end')


async def main():
    await asyncio.gather(
        task('A'),
        task('B'),
        task('C'),
        task('D'),
    )

asyncio.run(main())
A: start / waite 7 sec
B: start / waite 5 sec
C: start / waite 2 sec
D: start / waite 4 sec
C: end
D: end
B: end
A: end
処理時間の短いタスクから順に終了しているが、これは「優先されている」というより「awaitによる待ちが解除されたタスクから再開されている」という挙動である。

async / await

awaitをつけた処理は、その処理の完了を待つ。
ただし、その待ち時間中は他のタスクに実行を譲る
awaitはイベントループに制御を返却する命令であり、
  • プログラム全体(スレッド)は止まらない
  • 実行中の非同期タスク自身は一時停止する
という挙動になる。
しかし、同期関数は途中で停止・再開することはできないため、awaitを使う場合は必ずasyncを付けて非同期関数として定義する必要がある。
def xxx():
    await yyy()   # NG
非同期関数は
  • 実行状態を保存できる
  • 途中で中断できる
  • 後で再開できる
という性質を持つものである。
そのため、「非同期処理はawait を付けた処理の終了を待ってから次に進む」という理解はそのタスク単体で見れば正しいが、プログラム全体として見れば誤りである。

コルーチン関数

asyncをつけて定義した非同期関数のことを正式にはコルーチン関数(coroutine function)と言い、以下の特徴がある。
  • 実行可能な処理であるが自分では勝手に実行されない
  • 実行にはイベントループ+awaitが必要
つまり、「後でawaitされたら実行される予定の処理」を定義しているだけである。
例えば、次のようにコルーチン関数work()awaitなしで呼び出した場合、work()の中身は1行も実行されない。
async def work(name, sec):
    print(f'{name}: start')
    await asyncio.sleep(sec)
    print(f'{name}: end')
    return name


def main():
    a = work('A', 3)
    b = work('B', 1)
    c = work('C', 2)
    print('results:', (a, b, c))


asyncio.run(main())
results: (<coroutine object work at 0x1021db3e0>, <coroutine object work at 0x1021db530>, <coroutine object work at 0x1021dbca0>)
RuntimeWarning: coroutine 'work' was never awaited
出力されているのはコルーチンオブジェクトそのもの(コルーチンオブジェクトを作っただけ)であり、その実行結果が返されているわけではない。
合わせて出ている警告は、「コルーチンがawaitされないまま破棄された」という旨である。

asyncioとは

ここまでのサンプルプログラムの中でさりげなくasyncioというものを使っていた。
asyncioはPythonの標準ライブラリで、イベントループを提供するための仕組みである。

asyncio.run()

イベントループを起動し、指定した非同期関数を最後まで実行する。
通常のPython スクリプトでは、非同期処理の入口として1回だけ使う。
# async関数として定義されているmain()を実行する
asyncio.run(main())

asyncio.gather()

複数の非同期タスクをまとめて実行し、全ての完了を待つ。
gather()の戻り値はタスクを渡した順で返る点に注意する。
# work()は前述のサンプルで定義したものと同じ
async def main():
    results = await asyncio.gather(
        work('A', 3),
        work('B', 1),
        work('C', 2),
    )
    print('results:', results)


asyncio.run(main())
A: start
B: start
C: start
B: end
C: end
A: end
results: ['A', 'B', 'C']
ちなみに、gather()を使わずに順番にawaitするだけだと平行には実行されない(ただの同期処理と同じ)。
async def main():
    d = await work('D', 3)
    e = await work('E', 1)
    f = await work('F', 2)
    print('results:', (d, e, f))
D: start
D: end
E: start
E: end
F: start
F: end
results: ('D', 'E', 'F')

asyncio.sleep()

ここまでの例ではasyncio.sleep()によって「時間のかかる処理」を模していた。
しかし、Pythonには似たような関数としてtime.sleep()というのもある。
どちらも指定の時間だけ処理を一時停止するもので、一見する同じに見えるが下記のような違いがある。
time.sleep(): プログラム全体(スレッド)を停止する
asyncio.sleep():その非同期タスクだけを停止する
したがって、非同期関数の中でtime.sleep()を使うとイベントループ全体が止まってしまい、非同期処理の意味がなくなってしまうので注意。

PythonのWEBフレームワークにおけるasyncio

ここまで見てきた内容で非同期処理の基本的な理解は十分そうではある。
いや、しかし!実体験としてWEB開発においてasync/awaitを使った非同期処理はよく見かけるものの、asyncio.run()asyncio.gather()を意識したことはない、というかasyncioなんて見たことないぞ!と思うに至った。
実はPythonのWebフレームワークがasyncioの管理を隠蔽しているためであり、開発者はこれらを意識して記述する必要がなくなっている。
非同期処理について見てきたついでに、Pythonの3大(?)WEBフレームワークであるFlask, Django, FastAPIで非同期処理に関する仕様を簡単に整理する。
なお、あまり深掘りはしないがこの後出てくるWSGIとASGIについて簡単に説明しておく。
WSGI (Web Server Gateway Interface)
  • 同期処理前提のWebアプリケーションインターフェース
  • 代表的なものとしてGunicornなど
ASGI (Asynchronous Server Gateway Interface)
  • 非同期処理前提のWebアプリケーションインターフェース
  • 代表的なものとしてUvicorn、Daphneなど
Webアプリケーションインターフェースというのは、WEBアプリケーションとWEBサーバを連携させるために必要なもの。
「イベントループを前提にしているかどうか」が大きな違いとなる。

Flask

FlaskはWSGIベースのフレームワークであり、asyncioのイベントループは存在しない。
構文としてはasync/awaitを書くことはできるものの、イベントループがないため実際には 同期的に実行される。

Django

Djangoは元々WSGI・同期フレームワークだが、Django 3.0 以降でASGI対応になった。 ASGI サーバで起動した場合は、イベントループはサーバ側で起動・管理されるので開発者がasyncio.run()を書く必要はない。
ただし、内部には同期コードも多く非同期処理は部分対応という位置づけである。

FastAPI

FastAPIはASGI前提で設計されているので、asyncioのイベントループはサーバ側で提供される。
したがって、開発者はasync関数を書くだけでありasyncio.run()を書く必要はない。
非同期 I/O を前提とした API では最も自然に使える。

3フレームワークまとめ

項目FlaskDjangoFastAPI
ベースWSGIWSGI / ASGIASGI
イベントループなしサーバが提供サーバが提供
async/await形式上部分対応対応
asyncio.run()不要不要不要
同期処理主役主役補助
非同期処理不向き条件付き最適

Discussion

コメントにはログインが必要です。