Python非同期処理の実践

Python非同期処理の実践

深夜のデバッグと、Python非同期処理の深淵へのダイブ。

深夜2時。モニターの光だけが部屋を照らす中、私は再び「`asyncio`」の沼に足を踏み入れている。

Webアプリケーションを開発していて、大量のリクエストを捌く必要が出てきたとき、誰もが「非同期(Asynchronous)」という魔法の言葉に辿り着く。FastAPIを採用すれば、勝手に裏側で非同期処理が行われているように見えて、実はその中身を正しく理解していないと、いざパフォーマンスが出ない、あるいは挙動がおかしくなった時に手も足も出なくなる。

今回は、単なる「`asyncio`の使い方」の紹介ではない。私が数々のプロジェクトで血を流しながら学んだ、非同期処理の裏側にある構造、そして初心者が必ずと言っていいほどハマる落とし穴についての技術的な備忘録を書くことにした。


1. 非同期(Async)と並列(Parallel)の混同という罠

まず最初に整理しておかなければならないのは、「非同期処理=マルチスレッドやマルチプロセスで同時に動いている」わけではない、という点だ。

Pythonの`asyncio`は単一のプロセス内で動作する。裏側では「イベントループ」という仕組みが、待機状態にあるタスクを管理している。この構造を正しく理解せずに実装を進めると、後で取り返しのつかないパフォーマンスのボトルネックに突き当たる。

  • 同期(Synchronous): 料理人が一人いて、コンロの前でお湯が沸くまでじっと待っている状態。
  • 非同期(Asynchronous): 料理人がお湯を沸かしている間に、別の場所で野菜を切る。お湯が沸いた通知(イベント)を受け取ったら、またその工程に戻る。
この「待ち時間の有効活用」こそが`asyncio`の核心であり、FastAPIやaiohttpが高速だとされる理由だ。しかし、ここで多くの開発者が犯す致命的なミスがある。それは「ブロッキング処理を非同期関数のなかに混ぜてしまうこと」である。

なぜこれが致命的なのか

もし君が `async def` の中で `time.sleep(5)` や、同期的なHTTPライブラリ(`requests`など)を実行したとしよう。その瞬間、イベントループは完全に停止する。他のタスクもすべて止まり、アプリケーション全体がフリーズする。

これは「非同期の皮を被った同期処理」になってしまっている状態だ。この差を理解していないと、数千リクエストを捌くはずのシステムが、たった一つの重い計算や同期的なI/O操作によって全滅する。「イベントループを止めるな」。これは非同期プログラミングにおける鉄則である。


2. FastAPI × aiohttp の実戦構成と「真の非同期」への道

モダンなWeb開発において、FastAPIは非常に強力な選択肢だ。しかし、フレームワークを採用しただけで中身まで自動的に最適化されるわけではない。

特に外部APIを叩く場合、`aiohttp`や`httpx`といった非同期クライアントを使うことが推奨される。ここで、よく使われるライブラリの特性を比較してみよう。

特徴aiohttphttpx
主な用途非同期Webクライアント・サーバーモダンなHTTPクライアント(Sync/Async両対応)
同期APIのサポートなし(非同期専用)あり(`requests`に近い操作感)
HTTP/2 対応なしあり
エコシステム非常に成熟しており、安定性が高い比較的新しく、型安全な設計が好まれる

私は個人的に、複雑な非同期処理を積み重ねるなら`aiohmtp`を選ぶ。しかし、シンプルにAPIを叩くだけであれば、近年のトレンドである`httpx`も非常に優れた選択肢だ。

実装例:複数の外部ソースからデータを取得する

以下は、FastAPIのエンドポイント内で、複数のデータソースを非同期で並行処理する際の典型的なパターンだ。単に順番に待つのではなく、`asyncio.gather`(またはPython 3.11以降なら`TaskGroup`)を使って同時に走らせる。

import asyncio
import httpx
from fastapi import FastAPI
from typing import List

app = FastAPI()

# 外部APIからデータを取得する関数(aiohttpやhttpxを使用)
async def fetch_data(client: httpx.AsyncClient, url: str) -> dict:
    try:
        response = await client.get(url, timeout=10.0)
        response.raise_for_status()
        return response.json()
    except Exception as e:
        # 実際の実装ではロギングなどを適切に行う
        print(f"Error fetching {url}: {e}")
        return {"error": str(e)}

@app.get("/aggregate")
async def get_aggregated_data():
    urls = [
        "https://api.example.com/data1",
        "https://api.example.com/data2",
        "https://api.example.com/data3"
    ]
    
    # リクエストを並行して処理するためのクライアント。
    # 毎回作成するのではなく、コンテキストマネージャやライフサイクルで管理するのが理想。
    async with httpx.AsyncClient() as client:
        # ここがポイント:gatherを使うことで3つのリクエストを同時に発行する
        tasks = [fetch_data(client, url) for url in urls]
        results = await asyncio.gather(*tasks)
    
    return {"results": results}

このコードの肝は、`await client.get(...)` をループの中で一つずつ待つのではなく、リクエストをタスクとしてリストに格納し、`gather` で一気に投げている点だ。これにより、ネットワーク待ち時間の大部分を並列で消化でき、全体のレスポンスタイムを劇的に短縮できる。


3. 現場で必ずぶち当たる「沼」の正体

ここからは、ドキュメントを読んでいるだけでは絶対に気づけない、実戦でのハマりポイントについて語る。

① ブロッキング処理をどう隔離するか

どうしても同期的なライブラリ(例えば特定のDBドライバや、重い画像処理など)を使わなければならないケースは現実にある。その時、安易に「諦めて同期で書く」のは最悪の選択だ。

この場合は `run_in_executor` を活用する。これにより、重い処理を別スレッドやプロセスに追いやり、メインのイベントループを占有させないように制御する。

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

# 重い計算や同期ライブラリを呼び出す関数
def blocking_task():
    # この処理が数秒かかると、単に書くだけではイベントループが停止する
    time.sleep(5) 
    return "Finished"

async def main():
    loop = asyncio.get_running_loop()
    # ThreadPoolExecutorを使って、別スレッドで実行。メインループは自由になる。
    result = await loop.run_in_executor(None, blocking_task)
    print(result)

② セッションの再利用(Connection Pooling)

初心者がやりがちなのが、リクエストごとに `aiohttp.ClientSession()` や `httpx.AsyncClient()` を作成することだ。これは致命的なパフォーマンス低下を招く。 なぜなら、接続の確立(TCPハンドシェイクやTLSネゴシエーション)には無視できない時間がかかるからだ。これらを再利用するためのコネクションプールを適切に管理する(例えばFastAPIの`Lifespan`イベントなどでクライアントを初期化し保持する)ことが、プロレベルの実装への第一歩となる。

③ TimeoutとRetryの設計

非同期処理は「ネットワークが不安定な環境」で動くことが多いため、単に `await` するだけでは不十分だ。 `asyncio.wait_for` を使って厳格なタイムアウトを設けること、そして指数バックオフ(Exponential Backoff)を用いたリトライロジックを組み込むことは、堅牢なプロダクション環境のシステムを作る上で必須の工程である。

4. まとめ:非同期は「魔法」ではない

Pythonの非同期処理は、強力な武器になる一方で、使い方を誤ればシステムの安定性を著しく損なう諸刃の剣だ。改めて整理すると、以下の3点が重要となる。

1. イベントループを止めるな: `time.sleep` や同期的なI/Oを `async def` の中に直接書かないこと。
2. コネクションを再利用せよ: クライアントセッションは可能な限り長寿命なオブジェクトとして管理する。
3. 並行性を意識せよ: 複数の独立したI/O操作があるなら、`gather` や `TaskGroup` で同時実行のメリットを最大化する。

結局のところ、非同期プログラミングとは「リソースを無駄に待たせないための設計」そのものだ。この哲学を理解していれば、FastAPIでもaiohttpでも、ツールが変わっても迷うことはなくなるはずだ。

さて、そろそろコーヒーをもう一杯淹れて、次のバグ修正に取りかかるとしよう。