【Python】非同期処理とは?初心者向けにasyncioやasync/awaitをわかりやすく解説

はじめに

Pythonで何かの処理を行うとき、多くのケースでは処理をひとつひとつ順番に行う方法を使います。
これは同期処理と呼ばれ、ある作業が終わるまで次の作業に移れません。
シンプルでわかりやすい一方、大量のデータを読み込むような作業やネットワーク越しのやり取りが増える場面では、処理が待ち時間に阻まれてしまうことがあるでしょう。

例えばファイルを複数ダウンロードするとき、ひとつ目のダウンロード完了を待ってからでなければ、ふたつ目やみっつ目に取りかかれないという状態です。
こうした待ち時間が積み重なると、プログラムが“止まっている”ように感じてしまいます。

そこで注目されるのが非同期処理です。
Pythonにはasyncioというライブラリや、async/awaitという構文が用意されており、これらを使うと複数の処理を“ほぼ同時に”進めることができるようになります。
実際の作業を並列に進めるというよりも、待ち時間をうまく活用して他の処理を進めるというイメージが近いかもしれません。

この記事では、Pythonの非同期処理とは何かという概要から、実務での活用シーンやサンプルコードを交えてわかりやすく紹介します。
初心者の方にもなるべく理解しやすいよう、平易な言葉で丁寧に説明しますので、どうぞ最後まで読んでみてください。

この記事を読むとわかること

  • Pythonにおける非同期処理の概要
  • 同期処理との違いやメリット・デメリット
  • asyncioasync/awaitを使う基本的な流れ
  • 実務で役立つ具体的な使用シーン
  • タスクを管理する方法やコード例

非同期処理と同期処理の違い

同期処理では、ある処理が完了するまでプログラムの流れが停止してしまいます。
一方で非同期処理を利用すると、別の処理の結果を待つあいだに他の作業を進められます。
そのためネットワーク通信やファイルI/Oのように、処理待ち時間が長くなる状況で効果を発揮しやすいです。

同期処理が苦手とする場面

同期処理はコードの流れがわかりやすい点が特徴です。
しかし、複数の外部サービスにアクセスするようなケースでは、ひとつのリクエストの完了を待っているあいだにプログラムが“何もできない”状態になることがあります。
結果として作業が長引き、使用者が待たされる時間が増えてしまうかもしれません。

非同期処理を使うメリット

非同期処理を導入すると、ひとつのタスクを待っているあいだに別のタスクを進められるようになります。
待ち時間が有効活用できるので、特にI/Oが多いシーンでは効率的です。
「多数のファイルを読む」「APIを通じて多数のデータを取ってくる」などが一例です。

ただし、非同期処理はコードの流れがわかりにくくなることもあります。
複数の処理がほぼ同時進行するため、データの整合性を保つ方法を慎重に考える必要があるでしょう。

実務で役立つシーン

非同期処理を使うときは、待ち時間を減らせるシチュエーションを意識するとイメージしやすいです。
具体的には以下のような場面が考えられます。

複数の外部APIにアクセスする

一度に複数のURLへアクセスするとき、ひとつひとつ順番に処理すると時間がかかりがちです。
非同期処理なら、ひとつ目がレスポンスを待っている間にも別のアクセスを始められます。

大量のファイルを読み込む・書き込む

ファイル入出力にはディスクの制約が伴います。
大量のデータを扱うときでも、非同期処理なら待ち時間を分散させることができるでしょう。

多数のデータ解析やWebスクレイピング

同じ処理を多数のリソースに対して実行する場合、待ち時間の積み重ねがボトルネックになりやすいです。
これも非同期処理で並行化すれば、効率化が期待できます。

一方、CPUをフルに使うような計算を同時に行いたい場合は別の工夫が必要です。
Pythonの グローバルインタプリタロック (GIL)が影響し、CPUを多用する部分はマルチプロセスなど別の方法を検討するケースもあります。
ただし、ネットワークやファイルI/Oの待ち時間が中心の場合には、asyncioやasync/awaitによる非同期処理が有効でしょう。

非同期処理の基本的な仕組み

Pythonで非同期処理を行うには、asyncioという標準ライブラリを使うことが多いです。
また、コード側ではasyncawaitというキーワードを使って書く方法が一般的です。

イメージとしてはイベントループと呼ばれる仕組みが中心にあり、タスクを同時進行させる役割を果たしています。
プログラム中で待機が発生したら別のタスクに切り替えて処理を続け、効率よく作業を進めるわけです。

asyncとawait

import asyncio

async def fetch_data():
    print("データを取得しています...")
    await asyncio.sleep(2)  # 実際はAPIコールなどの待ち時間を想定
    return "取得したデータ"

async def main():
    print("処理を開始します")
    data = await fetch_data()
    print("結果:", data)

asyncio.run(main())

上の例では、fetch_data()という関数がasyncで宣言されています。
関数の中でawaitキーワードを使うと、一時的に処理を“中断”して他の作業に切り替えることができます。
await asyncio.sleep(2)は2秒待機する処理を示すもので、実際の開発では外部APIのレスポンスを待つようなイメージと似ています。

main()関数もasyncとして宣言し、内部でawait fetch_data()を呼ぶことでfetch_data()の完了を待つことができます。
ただし待ち時間の間、Python内部のイベントループは他の処理を進めることが可能です。

タスクの並行実行を行うには

非同期処理の強みを発揮するには、複数のタスクを同時に進めることがポイントになります。
Pythonでは、asyncio.gather()などを使ってタスクをまとめて実行できます。

asyncio.gather()の例

import asyncio

async def download_file(file_name, wait_time):
    print(f"{file_name} のダウンロードを開始します")
    await asyncio.sleep(wait_time)  # ダウンロードにかかる処理時間をシミュレート
    print(f"{file_name} のダウンロードが完了しました")

async def main():
    task1 = asyncio.create_task(download_file("fileA.zip", 3))
    task2 = asyncio.create_task(download_file("fileB.zip", 2))
    task3 = asyncio.create_task(download_file("fileC.zip", 1))

    await asyncio.gather(task1, task2, task3)

asyncio.run(main())

download_file()という非同期関数を3つ呼び出しています。
ここではダウンロードに時間がかかることをasyncio.sleep()でシミュレートしていますが、実際の開発では外部サーバへのリクエストを送るケースを想定できます。

asyncio.create_task()は非同期のタスクを生成するための関数です。
そしてasyncio.gather()を使うと、複数のタスクを同時に実行して、すべてのタスクが終わるのを待つことができます。

この仕組みによって、待ち時間が多い処理を並行に進めて、全体の処理時間を減らす効果が期待できるわけです。

イベントループとは

イベントループは、非同期処理を裏側で制御するための仕組みです。
プログラムが動いているあいだ、タスクが“実行可能か待機中か”を常にチェックしながら、次に進められるタスクに切り替えていきます。

メインとなるイベントループ

  • Pythonプログラムを起動したときに作られるメインのイベントループ
  • asyncio.run(main())と書くと、その時点でイベントループを起動し、main()が完了するまでループが走ります

非同期処理の場合、1つのタスクが外部サービスからの応答待ちになれば、ほかのタスクを動かし始めます。
これによって無駄な待ち時間が減らせるのです。

プログラムの実行タイミングやタスクの切り替えのタイミングはイベントループが管理しています。

非同期I/Oの流れ

非同期I/Oでは、基本的に実行したい処理をコルーチンという形で定義し、タスクとしてイベントループに登録します。
Pythonコードで言うと、async defで定義した関数がコルーチンにあたります。

簡単な流れ

  1. コルーチン (async関数)を作成
  2. イベントループの中でコルーチンを実行して、タスクとして動かす
  3. どのタスクが待ち状態になったかを判断し、待ち状態のタスクは一時停止
  4. 待ち状態ではないタスクを引き続き実行
  5. すべてのタスクが完了するまでこのサイクルを繰り返す

イベントループは「今どのタスクが実行できるか」を見ながらコルーチンを切り替えて動かしているのです。

具体的なコード例:Webスクレイピングを想定

実際の現場では、複数のURLを取得してデータをまとめるような作業があるかもしれません。
そういった例を見てみましょう。
下記のコードでは、requestsではなくaiohttpという非同期通信を支援するライブラリを使って、複数のURLからデータを取得しています。

import asyncio
import aiohttp

async def fetch(url, session):
    async with session.get(url) as response:
        # ここで実際のレスポンスを取り出す
        text_data = await response.text()
        return text_data

async def main():
    urls = [
        "https://example.com/",
        "https://example.org/",
        "https://example.net/"
    ]

    async with aiohttp.ClientSession() as session:
        tasks = []
        for url in urls:
            tasks.append(asyncio.create_task(fetch(url, session)))

        results = await asyncio.gather(*tasks)

        for i, result in enumerate(results):
            print(f"URL: {urls[i]}")
            print(f"取得したデータの一部: {result[:50]}...\n")

asyncio.run(main())

この例では、aiohttp.ClientSession()を使って非同期でリクエストを並行処理しています。
session.get(url)の完了を待つあいだに他のURLの処理も進めることができるので、複数のサイトから素早くデータを集められます。
asyncio.gather()によって複数のタスクを一度に走らせ、結果が返ってきた順に処理できるのもポイントです。

実務でネットワークアクセスや外部リソースへの問い合わせを多数行うような場面では、こうした実装がよく見られます。

エラーハンドリングについて

非同期処理では、並行して動いているタスクのどこかでエラーが発生する可能性があります。
同期処理のときよりもエラーの場所やタイミングが把握しにくくなるので、エラーハンドリングを注意して書くのが大切です。

try/exceptとタスクの組み合わせ

import asyncio

async def faulty_task(name):
    if name == "Task2":
        raise ValueError(f"{name}でエラーが発生しました")
    await asyncio.sleep(1)
    return f"{name}は正常に完了しました"

async def main():
    tasks = [
        asyncio.create_task(faulty_task("Task1")),
        asyncio.create_task(faulty_task("Task2")),
        asyncio.create_task(faulty_task("Task3"))
    ]

    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)

    for d in done:
        if d.exception():
            print("エラーを検出しました:", d.exception())
        else:
            print("結果:", d.result())

asyncio.run(main())

この例では、asyncio.wait()を使ってタスクを待機し、先にエラーが出たタスクがあればそこで報告しています。
エラーが出たからといって自動的に他のタスクがすべて止まるわけではありません。
複数のタスクをまとめて扱うときは、こうした仕組みを導入しておくと安心です。

async/await以外の考え方

非同期処理を扱う手法は、Pythonに限らずいくつか存在します。
たとえばマルチスレッドやマルチプロセスを使う方法です。
ただしスレッドでは排他制御やデータ競合の管理が必要になる場合があり、プロセスではメモリの扱いやプロセス間通信が発生します。

一方、asyncioasync/awaitは、シングルスレッドを基本にしつつイベントループで複数タスクを切り替えるアプローチです。
そのため、I/O待ちが多い場面であればシンプルに導入できる可能性が高いです。

CPU負荷の高いタスクを並列化したい場合は、マルチプロセスなど他の手段を検討する必要があります。

非同期処理を使う上で気をつけたいポイント

非同期処理は便利ですが、コードが複雑になることもあります。
タスクがあちこちで同時進行していると、どのタイミングでデータが書き換えられるかがわかりにくくなるかもしれません。

状態管理に注意する

複数のタスクが同じ変数やリソースに同時にアクセスする場合、想定外の不具合が起こる可能性があります。
読み書きの順序が異なるだけでバグが発生することもあるため、必要に応じて同期的にアクセスする仕組みを設けることが重要です。

ログの扱い

非同期の複数タスクが同時にログを出力すると、メッセージが入り混じって読みづらくなるケースがあります。
開発現場では、タスクごとに別のログファイルを使うか、ログにタスクIDを含めるなどの工夫を行うことが多いです。

過度に非同期処理を使わない

待ち時間がほとんどなく、単純に順番に処理するだけで済む場合は同期処理で十分です。
非同期処理は慣れていないと理解が難しいことも多いため、必要な箇所だけに導入するのが良いでしょう。

まとめ

ここまで、Pythonの非同期処理について基本的な考え方から具体的なコード例まで解説しました。
初心者の方にとっては聞き慣れないキーワードが多く登場したかもしれませんが、待ち時間を有効に使うことで効率的なプログラムを作りやすくなるメリットがあります。

特にネットワーク通信やファイル入出力のようなI/Oが多い状況では、非同期処理が有効に働きます。
asyncioasync/awaitを活用すれば、複数の外部アクセスを同時進行させることが可能です。
一方で、コードの見通しが悪くなったり、エラーハンドリングが難しくなったりする面もあるため、実装するときにはタスク管理やデータの整合性に注意してください。

Pythonにはさまざまな機能が備わっていますが、非同期処理を覚えておくとプログラムの幅がぐっと広がるでしょう。
まずは簡単な例から試しつつ、余裕があればaiohttpなどを使ったネットワークアクセスや、複数ファイルの入出力に挑戦してみると理解が深まりやすいです。
ぜひ基本の仕組みを押さえながら、自分の作りたいものに合わせて活用してみてください。

Pythonをマスターしよう

この記事で学んだPythonの知識をさらに伸ばしませんか?
Udemyには、現場ですぐ使えるスキルを身につけられる実践的な講座が揃っています。