acyncio

【Python】async/awaitを用いた非同期プログラミングの基本

【Python】asyncawaitを用いた非同期プログラミングの基本

Pythonで、asyncawaitを用いた非同期プログラミングを実装する基本について解説します。

非同期プログラミング

近年では、非同期プログラミングが多くの支持を得ています。Pythonでは、これまでジェネレータをベースにした非同期プログラミングの仕組みがありましたが、Python 3.5で新しくasyncawaitを用いた非同期プログラミング方法が導入されています。また、asyncioモジュールは、イベントループの標準実装など多くの非同期機能を提供しています。

この記事では、asyncawaitを用いて非同期プログラミングを実装する基本について紹介していきます。

具体的な非同期プログラミングの説明の前に、同期処理・非同期処理・マルチスレッド・マルチプロセスといった処理方式の違いについて概要を説明します。また、コルーチンや協調的マルチタスクという考え方についても簡単に紹介します。これらの考え方に理解があり、実装方法を知りたいのみであれば、説明は読み飛ばしていただいて構いません。

同期処理・非同期処理・マルチスレッド・マルチプロセス

非同期プログラミングについて、十分に理解するには「同期処理」「非同期処理」「マルチスレッド」「マルチプロセス」の違いをしっかりと理解する必要があります。以下の図を用いてそれぞれのイメージを明確化しましょう。

以下は、処理Aと処理Bを実行する2つのタスク(タスク1およびタスク2)を実行する場合の図です。線はI/Oを表しており、処理Aと処理Bの間にI/Oによる待ちが発生すると理解してください。

同期処理・非同期処理・マルチスレッド・マルチプロセスの違い

同期処理

同期処理では、1つのCPU内の1スレッドで順次処理を実行します。同期処理の場合、タスク1が完了するまでタスク2は開始されません。タスクが1つずつ処理されるため最も単純な方法で理解しやすいですが、処理効率は低くなります。

非同期処理

非同期処理では、タスク1の処理Aが終了してI/O待ちになったタイミングで、タスク2の処理Aが開始されます。この処理は同一スレッド内で実行され、タスク1はI/O待ちになったタイミングで制御を開放することでタスク2の処理が開始します。

その後、タスク2がI/O待ちで制御を開放した時に、待機していたタスク1の処理Bが実行されます。このように非同期処理では、タスクがオーバーラップしていることから効率よく処理が実行できます。

マルチスレッドやマルチプロセスとは違い、非同期処理はOSではなく、アプリケーションレベルで制御を行います。

マルチスレッド

マルチスレッドでは、同一CPU内の異なるスレッドでタスクが実行されます。図の例ではタスク1はスレッド1で、タスク2はスレッド2で実行されています。

マルチスレッドでは、同一のメモリ空間を共有するため、データの共有などを含めて効率よく処理ができますが、データの安全性に関する注意が必要となります。マルチスレッドの処理は、OSにより制御されます。

マルチプロセス

マルチプロセスでは、異なるCPUを使ってタスクを実行します。図の例では、タスク1はCPU1、タスク2はCPU2で実行されます。

マルチプロセスは、各プロセスが独立したメモリ空間を持つため、他のプロセスの影響を受けずに動作することができます。ただし、マルチプロセスでは、プロセス間のデータ交換が必要になったり、プロセス生成時のオーバーヘッドがかかったりといった特徴もあるため、使用時にはよく検討する必要があります。マルチプロセスの処理は、OSにより制御されます。

Pythonにおけるマルチスレッド、マルチプロセスに関する説明は以下のページにまとめていますので参考にしてください。

コルーチン

コルーチンとは、プログラムの実行中に特定のポイントで停止し、後でそのポイントから実行を再開できる特殊な関数のことを言います。コルーチンは以下のような特徴を持っています。

  1. 実行の中断と再開:途中で実行を停止し、操作が完了した後に再開することができます。
  2. 状態の保持:実行を中断する際に、現在状態を保存し、再開時にその状態から処理を再開できます。
  3. 非同期処理の簡素化:複雑な非同期コードを同期コードのように直感的に記述できます。

このように、コルーチンは非同期処理プログラミングのコードの複雑さを軽減し、効率的な実行フローを実現できるものです。Pythonでは、コルーチンをasyncawaitといったキーワードを用いて実現しています。

なお、以降の説明でタスクとコルーチンという言葉が混乱しやすいかもしれません。コルーチンは、特定のポイントで停止し、後でそのポイントから実行開始できる関数自体です。一方で、タスクはそれらのコルーチンを実行する単位で、イベントループ内でスケジューリングされるものです。イメージとしては、タスクの箱の中にコルーチンの関数が紐づいているようにイメージしてもらうと分かりやすいかと思います。

協調的マルチタスク

非同期プログラミングにおける中心的となる考え方として協調的マルチタスクと呼ばれるものがあります。協調的マルチタスクでは、OSには頼らず、その代わりに各プロセスが待機状態になったら自発的に制御を開放し、他の処理に制御を渡します。

一方で、OSはコンテキストスイッチによりプロセスやスレッドを直接制御することが一般的です。機能の設計が不十分なプロセス、スレッドによりシステム全体が不安定になることを防ぐためです。

アプリケーション開発で、協調的マルチタスクを実現する際には、すべてを1つのプロセス、スレッドの中で実行します。ある1つの関数がイベントループとして複数タスクの実行を制御します。

Pythonの非同期プログラミングでは、acyncioモジュールでタスク生成やイベントループといった協調的マルチタスクを実現する機能が提供されています。後述する例を見ていただくとよりイメージできるかと思います。

async/awaitを用いた非同期プログラミング

Pythonの非同期プログラミングでは、asyncawaitが重要な予約語となっており、これらのキーワードを使ってコルーチンを実現します。また、asyncioモジュールは、イベントループの標準実装など多くの非同期機能を提供しています。

以降では、これらを用いた非同期プログラミングの基本を説明します。

基本的な実装方法

Pythonにおける非同期プログラミングは、asyncawaitを用いて以下のように実装します。以下例は、複数のタスクを同時に実行する例です。

import asyncio
import time


async def worker(task_name):
    print(f"start: {task_name}")
    # 非同期に2秒間スリープ
    await asyncio.sleep(2)
    print(f"end: {task_name}")


async def main():
    # 非同期タスクを作成する
    tasks = [
        asyncio.create_task(worker("task1")),
        asyncio.create_task(worker("task2")),
        asyncio.create_task(worker("task3")),
    ]
    # 全てのタスクが完了するまで待つ
    await asyncio.wait(tasks)


if __name__ == "__main__":
    t = time.time()
    # asyncio.runを使用してメインコルーチンを実行
    asyncio.run(main())
    # 実行時間を表示
    print(f"実行時間: {time.time()-t:.5f} sec")
【実行結果】
start: task1
start: task2
start: task3
end: task1
end: task2
end: task3
実行時間: 2.00953 sec

【モジュールのインポート】

非同期プログラミングのためにasyncioモジュールをインポートします。asyncioモジュールは非同期プログラミングで必要なタスク生成やイベントループといった多くの非同期機能を提供してくれます。また、時間を計測するためにtimeモジュールもインポートしておきます。

【コルーチン(非同期関数)の定義】

上記例では、worker関数が、コルーチン(非同期関数)となっています。コルーチンの定義では、通常の関数定義のdefの前にasyncを付けて「async def worker(task_name):」のように定義します。このworker関数では、途中でasyncio.sleep(2)で2秒間のスリープを入れています。

このスリープは、ファイル入出力やネットワーク通信などのI/O処理で制御が解放されるタイミングを表しているものだと思ってもらうと分かりやすいと思います。

このように非同期処理の制御を開放する関数を実行する場合には、awaitを付けて「await asyncio.sleep(2)」のように実行します。awaitを付けることで、非同期に実行されることを示します。

【タスクの生成】

main関数もコルーチンとして定義しており、main関数の中で非同期タスクを生成しています。非同期タスクの生成には、asyncio.create_taskを使用します。asyncio.create_taskの引数に実行するコルーチン自体を渡すことでタスクを生成できます。

タスクを含んだリストをasyncio.waitに渡すことで、リスト内のタスクを非同期に実行します。この時にもawaitを付けていることに注意してください。これは、全てのタスクが完了するまで待つことを意味してます。

【イベントループの実行】

プログラム実行のイベントループは、asyncio.runを使用します。asyncio.runmainコルーチンを実行することで、main内で定義した複数タスクが非同期に実行されていきます。

worker関数は2秒の待機時間があるため、同期実行した場合には通常の同期処理では約6秒の時間がかかりますが、実行結果を見ると分かるように非同期で実行されることで、約2秒で処理が完了していることが分かります。

非同期プログラミングは、I/O操作(ファイルの読み書き、ネットワーク通信等)が多いプログラムなどに特に有効です。非同期プログラミングを適切に利用することで、プログラムのパフォーマンスと利用者の利便性向上に役立ちます。

asyncio.gather関数を用いた結果取得

上記の例では、asyncio.waitを用いて非同期に実行しました。個々のタスクが返却する結果を取得することができません。複数の非同期タスクを同時に実行し、すべてのタスクが完了するのを待って結果を取得したい場合は、asyncio.gather関数を使用します。

import asyncio
import time


async def worker(task_name):
    print(f"start: {task_name}")
    # 非同期に2秒間スリープ
    await asyncio.sleep(2)
    print(f"end: {task_name}")

    return task_name


async def main():
    # 非同期タスクを作成する
    tasks = [
        asyncio.create_task(worker("task1")),
        asyncio.create_task(worker("task2")),
        asyncio.create_task(worker("task3")),
    ]
    # 非同期に実行完了を待ち、結果をリストで取得
    results = await asyncio.gather(*tasks)
    # 結果表示
    print("===== 結果")
    for result in results:
        print(result)


if __name__ == "__main__":
    t = time.time()
    # asyncio.runを使用してメインコルーチンを実行
    asyncio.run(main())
    # 実行時間を表示
    print(f"実行時間: {time.time()-t:.5f} sec")
【実行結果】
start: task1
start: task2
start: task3
end: task1
end: task2
end: task3
===== 結果
task1
task2
task3
実行時間: 2.01051 sec

上記例は、先ほどまでのプログラムとほとんど同じですが、コルーチンであるworker関数はタスク名を返却しています。

このように値を返却するコルーチンのタスクを非同期に実行する場合には「results = await asyncio.gather(*tasks)」のようにasyncio.gaher関数を使用します。awaitキーワードをつけることを忘れないようにしましょう。また、asyncio.gatherはタスクのリストを任意の数の位置引数として受け取ることができるため、リストを渡す場合には「*task」というようにリストをアンパックして渡します。

得られるリストは、各タスクの返却値のリストとなっています。結果リストは、実行完了順ではなく、与えたタスクの順で結果が並びますので、各タスクの結果を追跡しやすいです。

イベントループ実装方法

上記で紹介したasyncio.runasyncio.create_taskについては、Python 3.7で導入されているため、Python 3.5や3.6では使用できないので注意してください。Python 3.5や3.6では、以下のようにget_event_loopでループを取得して実行する必要があります。

async def main():
    # 非同期タスクを作成する
    tasks = [
        loop.create_task(worker("task1")),
        loop.create_task(worker("task2")),
        loop.create_task(worker("task3")),
    ]
    # 全てのタスクが完了するまで待つ
    await asyncio.wait(tasks)


if __name__ == "__main__":
    t = time.time()
    # 旧来のイベントループの取得とメインコルーチンの実行
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
    # 実行時間を表示
    print(f"実行時間: {time.time() - t:.5f} sec")

最新のPythonバージョンで実行する場合には、asyncio.runasyncio.create_taskを使うことが推奨されます。

Note

【ジェネレータベースのコルーチン】

Pythonの古いバージョンでは、コルーチン実装にジェネレータを使用しています。@asyncio.coroutineデコレータや、yield fromといった記載の非同期プログラミングのコードを見たことがあるかもしれませんが、これはジェネレータベースのコルーチンです。

ジェネレータベースの非同期プログラミングは、現在でも実装可能ですが、最新のPythonではasyncawaitacyncioモジュールを使った実装が推奨されます。

非同期プログラミングにおける注意事項

asyncawaitを使った基本的な非同期プログラミングの例を見てみました。非同期プログラミングを実現する際には、同期処理である関数やメソッドの使用に注意が必要です。これらの同期関数、メソッドが非同期なプログラムのコード内で使用されるとイベントループがブロックされ、非同期の利点が失われてしまいます。

例えば、上記で紹介したサンプルコードにおいてスリープをtime.sleep(2)に変えてみると、結果は以下のようになります。

async def worker(task_name):
    print(f"start: {task_name}")
    # 2秒間スリープ
    time.sleep(2)
    print(f"end: {task_name}")
【実行結果】
start: task1
end: task1
start: task2
end: task2
start: task3
end: task3
実行時間: 6.00212 sec

上記結果を見てみると各タスクが同期的に実行され、結果6秒となります。これは、time.sleepが非同期を考慮していないためです。一方で、asyncio.sleepは、非同期に対応したものであったため、適切に非同期処理ができていたわけです。

非同期処理を実現する際に、外部のライブラリなどを利用することもよくあると思います。その場合には、適切に非同期に対応したライブラリであるかをよく確認する必要があります。

また、非同期プログラミング自体が、比較的難しいプログラミングであるため、十分に挙動を理解して使用しないと思いもよらない不具合を起こす場合があります。非同期プログラミングを採用する場合には、十分に検討するようにしましょう。

実践的な非同期プログラミング例

実践的な非同期プログラミング例についてもいくつか紹介します。

aiohttpを用いた非同期HTTP通信

HTTP通信は非同期通信による処理が効果的な処理です。aiohttpは、クライアントとサーバーの両方のユースケースで非同期HTTP通信をサポートしているサードパーティ製ライブラリとして使用できます。

aiohttpを用いた非同期通信の基本については「aiohttpを用いた非同期HTTP通信の基本」にまとめているので参考にしてください。このページでは、OpenWeatherのAPIを使って非同期にAPIを呼び出す例を扱っています。

asyncpgを用いた非同期データベース処理 (PostgreSQL)

データベース処理は非同期処理の効果が見込める処理の一つです。asyncpgは、PostgreSQLデータベースの非同期処理をサポートするPythonのサードパーティ製ライブラリとして使用できます。

asyncpgを用いた非同期処理の基本については「asyncpgを用いた非同期データベース処理 (PostgreSQL)」にまとめているので参考にしてください。このページでは、PostgreSQLへ非同期にアクセスするクラスを作成してみています。当該クラスは接続プールやコンテキストマネージャーに対応させています。

aiofilesを用いた非同期ファイル入出力

aiofilesは、ファイル入出力を非同期に実行することができるサードパーティ製ライブラリです。aiofilesは、標準モジュールのreadwrite等の非同期版として提供されているため、基本的な入出力を理解できている方には使いやすいと思います。

aiofilesを用いた非同期処理の基本については「aiofilesを用いた非同期ファイル入出力」にまとめているので参考にしてください。

まとめ

Pythonで、asyncawaitを用いた非同期プログラミングを実装する基本について解説しました。

同期処理・非同期処理・マルチスレッド・マルチプロセスといった処理方式の違いについてや、コルーチン、協調的マルチタスクといった考え方について冒頭で説明し、簡単な例で、asyncawaitを用いたPythonでの非同期プログラミングを紹介しています。

非同期処理は、I/O操作(ファイルの読み書き、ネットワーク通信等)が多いプログラムなどに特に有効で、適切に利用することでプログラムのパフォーマンスや利用者の利便性向上に大いに役立ちます。

ただし、非同期プログラミングは比較的難しい技術領域で、安易に採用すると予期せぬ挙動、不具合の原因になることがあります。解決するべき問題をよく検討して、非同期プログラミングを採用するべきかはよく考えるようにしていただけたらと思います。