asyncio

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

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

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 つずつ処理されるため最も単純で理解しやすい方法ですが、I/O 待ちが多い場合には、計算資源を有効に使いにくくなります。

非同期処理

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

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

このように、非同期処理は複数処理が同時に CPU 上で実行されるわけではなく、I/O 待ちの間に他のタスクへ切り替えることで全体の待ち時間を減らす考え方です。

マルチスレッドやマルチプロセスとは違い、非同期処理は、OS 機能を利用しながら、アプリケーションレベルのイベントループによってタスクの切り替えを制御します。

マルチスレッド

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

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

マルチプロセス

マルチプロセスでは、複数プロセスを起動し、それぞれ独立したメモリ空間で実行されます。複数 CPU コアを利用して並列実行されるかは OS のスケジューラに依存します。図の例では、タスク 1 はCPU 1、タスク 2 は CPU 2 で実行されています。

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

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

コルーチン

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

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

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

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

協調的マルチタスク

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

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

アプリケーション開発で、協調的マルチタスクを実現する際には、通常は 1 つのプロセス、1 つのスレッド上でイベントループを動かし、その中で複数タスクを切り替えながら実行します。

Python の非同期プログラミングでは、asyncio モジュールでタスク生成やイベントループといった協調的マルチタスクを実現する機能が提供されています。

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

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

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

基本的な実装方法

Python における非同期プログラミングは、asyncawait を用いて以下のように実装します。また、複数の非同期タスクを同時に実行し、すべてのタスクが完了するのを待って結果を取得するには、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

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

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

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

例では、worker 関数が、コルーチン(非同期関数)となっています。コルーチンの定義では、通常の関数定義の def の前に async キーワードを付けて「async def worker(task_name):」のように定義します。

この worker 関数では、途中で asyncio.sleep(2) により 2 秒間のスリープを入れています。このスリープは、ファイル入出力やネットワーク通信などの I/O 処理で制御が解放されるタイミングを表していると思ってください。

また、worker 関数は結果としてタスク名を返却するようにしています。

非同期処理の関数を実行する場合には、await キーワードを付けて「await asyncio.sleep(2)」のように実行します。await を付けると、その処理の完了を待っている間に、イベントループは他のタスクへ処理を切り替えることができます。

【タスクの生成】

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

タスクを非同期に実行するには「results = await asyncio.gather(*tasks)」のように asyncio.gather 関数を使用します。await キーワードをつけることを忘れないようにしましょう。

asyncio.gather は、複数のタスクやコルーチンを位置引数として受け取り、それらを並行して実行し、すべて完了した後に結果をまとめて返します。リストにまとめたタスクを渡す場合には、「*tasks」のようにアンパックして渡します。なお、asyncio.gather はタスクだけではなくコルーチンを直接渡すことも可能です。

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

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

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

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

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

同期処理の呼び出しには注意が必要

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

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

async def worker(task_name):
    print(f"start: {task_name}")
    # 2秒間スリープ (ブロック)
    time.sleep(2)
    print(f"end: {task_name}")

    return task_name
【実行結果】
start: task1
end: task1
start: task2
end: task2
start: task3
end: task3
===== 結果
task1
task2
task3
実行時間: 6.00461 sec

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

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

非同期処理の限界

asyncio を用いた非同期処理は、I/O バウンドな処理には非常に有効ですが、CPU バウンドな処理ではパフォーマンスの向上は期待できません。CPU バウンドな処理では、multiprocessingconcurrent.futures の使用を検討してください。

I/O バウンド/CPU バウンドの概要については以下を参考にしてください。

並行・並列処理、I/Oバウンド・CPUバウンドを理解する

非同期処理の難易度

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

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

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

aiohttp を用いた非同期 HTTP 通信

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

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

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

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

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

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

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

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

まとめ

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

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

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

ただし、非同期プログラミングは比較的難しい技術領域で、安易に採用すると予期せぬ挙動、不具合の原因になることがあります。解決したい課題に対して本当に必要かを見極めた上で、採用を判断することが大切です。

ソースコード

上記で紹介しているソースコードについては GitHub にて公開しています。参考にしていただければと思います。

あわせて読みたい
【Python Tech】プログラミングガイド
【Python Tech】プログラミングガイド

ABOUT ME
ホッシー
ホッシー
システムエンジニア
はじめまして。当サイトをご覧いただきありがとうございます。 私は製造業のメーカーで、DX推進や業務システムの設計・開発・導入を担当しているシステムエンジニアです。これまでに転職も経験しており、以前は大手電機メーカーでシステム開発に携わっていました。

プログラミング言語はこれまでC、C++、JAVA等を扱ってきましたが、最近では特に機械学習等の分析でも注目されているPythonについてとても興味をもって取り組んでいます。これまでの経験をもとに、Pythonに興味を持つ方のお役に立てるような情報を発信していきたいと思います。どうぞよろしくお願いいたします。

※キャラクターデザイン:ゼイルン様
記事URLをコピーしました