Python | 自動化:非同期 asyncio

Python
スポンサーリンク

概要(asyncio は「1人で同時進行する」仕組み)

マルチスレッドやマルチプロセスは「人を増やす」イメージでした。
asyncio はちょっと違っていて、

「1人なんだけど、待ち時間のあいだに別の作業にパパっと切り替える」

というスタイルの仕組みです。

特に効いてくるのは、

  • Web API を大量に叩く
  • ソケット通信・WebSocket・チャットBOT
  • データ収集のように、「待ち」がとにかく多い処理

です。

CPU をゴリゴリ使う計算を速くする魔法ではありません。
「待ってる時間をムダにしない」が asyncio の本質です。


基礎イメージ(マルチスレッドとの違いから入る)

マルチスレッドと async/await の違い

マルチスレッドは、OS レベルで「本当に同時に動きうる」流れを複数持ちます。
スレッドごとに実行のタイミングは OS に任せているので、「いつどこで切り替わるか」がコードからは見えにくい。

asyncio は、Python の中に「イベントループ」という“司令塔”を用意して、

  • ある処理が「待ち」に入ったら、自分から await で「今ヒマだから、他の仕事どうぞ」と言う
  • 司令塔が「じゃ、別の処理を再開させるね」と切り替える

というルールで動きます。

大事なポイントは、

  • どこで切り替わるかを await でコード側が明示するので、流れを追いやすい
  • 1つのスレッド(1人)でたくさんの I/O をさばける
  • スレッドよりも「共有データ」「ロック」のややこしさが少ない

というメリットがある一方で、

  • async/await の書き方に慣れるまで少し時間がかかる
  • 非同期対応していないライブラリ(普通の requests など)は、そのままだと使いにくい

という側面もあります。


最小サンプルで「async/await の雰囲気」をつかむ

超シンプルな「待つだけ」の非同期処理

まずは、ネットワークもファイルも使わずに、asyncio.sleep だけで雰囲気をつかんでみます。

import asyncio

async def worker(name: str, delay: int):
    print(f"{name} 開始")
    await asyncio.sleep(delay)
    print(f"{name} 終了({delay}秒待った)")

async def main():
    task1 = asyncio.create_task(worker("A", 2))
    task2 = asyncio.create_task(worker("B", 3))

    await task1
    await task2

asyncio.run(main())
Python

ここでいったん、行ごとの意味を丁寧に追います。

async def worker(...)
この関数は「非同期関数(コルーチン)」になります。
中で await を使えるのは、async def の中だけです。

await asyncio.sleep(delay)
「delay秒寝ているあいだ、他の処理に CPU を譲ります」の意味です。
ここでイベントループが「じゃあ他のタスク動かすね」と切り替えます。

asyncio.create_task(worker(...))
イベントループに「この非同期関数をタスクとして登録してね」と依頼します。
戻り値は Task オブジェクトで、「実行中の非同期処理のハンドル」です。

await task1
task1 の完了を待ちます。task1 がまだ動いているなら、イベントループは他のタスクを動かし続けます。

asyncio.run(main())
Python プログラムのエントリーポイントから、非同期世界を起動する儀式だと思ってください。
イベントループの作成~終了まで面倒を見てくれます。

このコードを動かすと、「A 開始」「B 開始」の後に、2秒後に A 終了、さらに1秒後に B 終了、のように「同時進行」している感じが分かるはずです。


典型例:非同期で Web API を大量に叩く

同じことを「同期版(ふつうの requests)」でやるとどうなるか

とりあえず、普通の同期コードからイメージしてみます。

import time
import requests

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/3",
]

def fetch(url: str) -> int:
    resp = requests.get(url, timeout=10)
    return resp.status_code

def sync_main():
    start = time.time()
    results = []
    for url in URLS:
        status = fetch(url)
        results.append((url, status))
    elapsed = time.time() - start
    print("結果:", results)
    print(f"同期処理 合計: {elapsed:.2f}秒")

if __name__ == "__main__":
    sync_main()
Python

delay/1、delay/2、delay/3 に順番にアクセスするので、だいたい 1+2+3 秒 ≒ 6 秒近くかかります。

非同期版:aiohttp を使って並行実行する

非同期 HTTP クライアントとしてよく使われるのが aiohttp です。

pip install aiohttp

これを使って、同じ URL リストを「asyncio で同時進行」させてみます。

import asyncio
import time
import aiohttp

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/3",
]

async def fetch(session: aiohttp.ClientSession, url: str) -> tuple[str, int]:
    async with session.get(url, timeout=10) as resp:
        return url, resp.status

async def async_main():
    start = time.time()
    async with aiohttp.ClientSession() as session:
        tasks = [asyncio.create_task(fetch(session, url)) for url in URLS]
        results = await asyncio.gather(*tasks)
    elapsed = time.time() - start
    print("結果:", results)
    print(f"非同期処理 合計: {elapsed:.2f}秒")

if __name__ == "__main__":
    asyncio.run(async_main())
Python

ここが asyncio で一番よく使うパターンです。

async with aiohttp.ClientSession()
セッション(接続の管理)を非同期対応で扱うための文です。
「このブロックの間だけ session を使う」と覚えてOKです。

tasks = [asyncio.create_task(fetch(...)) for url in URLS]
URL ごとに fetch のタスクを作り、「全部イベントループに登録する」。

await asyncio.gather(*tasks)
複数タスクをまとめて待ち、結果をリストで返します。
タスクの順番と結果の順番が対応します。

delay/1、delay/2、delay/3 は「同時にリクエストが飛ぶ」ので、
合計時間はだいたい 3 秒ちょっとで収まるはずです。
これが「待ち時間を重ねない」非同期の強さです。


asyncio でつまずきやすいポイントを深掘りする

1. 「async 関数は呼ぶと“すぐ実行されない”」という感覚

ここでよく混乱が起きます。

普通の関数は、func() と呼んだ瞬間に中身が実行されます。
でも、async def で定義した関数は、func() と呼ぶと「コルーチンオブジェクト」が返ってきます。
これは「まだ動いていない、実行予定の処理」です。

このコルーチンを本当に実行するには、

  • await func() とする
  • asyncio.create_task(func()) でタスクにして、イベントループに渡す

のどちらかが必要です。

例:

async def f():
    print("実行")

coro = f()          # まだ実行されていない
await coro          # ここで初めて「実行」
Python

ここを理解していないと、「async 関数を呼んだのに動かない」「警告が出る」みたいな現象になります。

2. 同期コードと非同期コードを混ぜるときの注意

asyncio の世界の中で、ブロッキングな処理(普通の requests や time.sleep など)をそのまま呼ぶと、
「イベントループごと止まる」ので意味がなくなります。

非同期関数の中で、

  • time.sleep() ではなく await asyncio.sleep() を使う
  • HTTP は requests ではなく aiohttp、DB は対応ライブラリ(asyncpg など)を使う

という風に、「非同期対応の I/O を使う」ことが重要です。

どうしても同期関数を呼びたい場合は、

  • asyncio.to_thread()(Python 3.9+)で別スレッドに逃がす

という方法もあります。

import asyncio
import time

def blocking_task():
    time.sleep(3)
    return "done"

async def main():
    print("start")
    result = await asyncio.to_thread(blocking_task)
    print("result:", result)

asyncio.run(main())
Python

こうすると、「イベントループは止めずに、ブロッキング処理だけスレッドでやってもらう」ことができます。
ただし、これを多用しすぎると「結局スレッド地獄」になるので、
基本は「最初から非同期対応のライブラリを選ぶ」方向がおすすめです。

3. エラーとキャンセルの扱い

asyncio.gather はデフォルトでは、どれかのタスクが例外を出すとそこで例外を投げます。
他のタスクはキャンセルされます。

これが嫌で、「エラーはエラーで個別に扱いたい」場合は、return_exceptions=True を指定します。

results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
    if isinstance(r, Exception):
        print("エラー:", r)
    else:
        print("成功:", r)
Python

また、タスクを途中で止めたい場合は task.cancel() を使いますが、
キャンセルも例外(asyncio.CancelledError)として扱われるため、
タスク側で try: ... except asyncio.CancelledError: ... のように処理することがあります。

初心者のうちは、まずは「エラーが出たらログを出して落ちる」で良いので、
本番運用で徐々にキャンセルや個別エラー処理を覚えていく感じで大丈夫です。


自動化の中で asyncio をどう使うか(具体イメージ)

Web API 集計 BOT を asyncio 化するとどうなるか

すでに「Web API 集計 BOT」を学んでいる前提で話すと、
あの「1日ぶんのデータを API から取得して集計する」処理を、
日付ごと・パラメータごとに asyncio で並行に回せるわけです。

例えば、

「2025-01-01~2025-01-07 までの 7 日ぶんのデータを、順番ではなく同時に取得したい」

といったときに、

  • 同期版:for ループで 7 回 requests.get → トータル時間はほぼ 7 日ぶんの合計
  • 非同期版:7 日分の fetch を asyncio.gather で同時並行 → いちばん遅い日付に合わせた時間

という差が出ます。

データ収集 BOT とフォルダ監視の組み合わせ

フォルダ監視(watchdog)や cron と組み合わせて、

  • 新しいジョブが来たら、非同期で複数の外部サービスから情報を引っ張ってくる
  • 非同期で API を叩きつつ、ローカルの I/O も並行で進める

みたいな構成も作れます。

ここで大事なのは、

  • 「今の処理は本当に async にする意味があるか?」
  • 「同期版(concurrent.futures)で十分ではないか?」

を一度考えることです。

asyncio は強力ですが、「何もかも async 化する」のはおすすめしません。
ネットワークやソケットをガッツリ扱うところに絞って使うと、
コード量に対して得られるメリットが大きくなります。


まとめ(asyncio は「待ち時間を並列化する設計思想」)

ここまでのポイントをぎゅっとまとめると、こうなります。

asyncio は、「待ち時間(I/O)だらけの処理」を、1つのスレッドで効率的にさばくための仕組み。
async def で非同期関数を定義し、その中で await することで「ここで他の仕事に切り替えていいよ」と宣言する。
非同期 HTTP には aiohttp、DB には async 対応ライブラリなど、「非同期前提の I/O ライブラリ」を使うことが重要。
複数タスクを並行実行する基本パターンは、asyncio.create_taskasyncio.gather
同期コードと混ぜるときは、asyncio.to_thread などでブロッキング処理をイベントループから分離する。

最初は頭が少し混乱すると思います。
でも、「asyncio は“スレッドの代わり”ではなく、“待ち時間をうまく回す設計”なんだ」と理解できると、一気に見通しが良くなります。

タイトルとURLをコピーしました