Python非同期 vs スレッド: asyncioを使うべき時
Pythonで並行処理が重要な理由
複数のAPIからデータを取得するPythonスクリプトがあるとします。動作はしますが遅い——各リクエストが前のリクエストの完了を待っているからです。asyncioとスレッドについて聞いたことがあるかもしれませんが、どちらを使うべきでしょうか?間違ったモデルを選ぶと、不必要に複雑なコードか、ひどく遅いコードになってしまいます。
このガイドはその混乱を解消します。Pythonのasyncとスレッディングモデルを比較し、それぞれをいつ使うべきかを正確に示し、自分のプロジェクトに応用できる実践的な例を提供します。
Pythonの並行処理の全体像を理解する
Pythonには主に3つの並行処理モデルがあります:
- スレッド – 単一プロセス内の複数の実行スレッドで、OSによって管理されます。CPUバウンドの処理ではGlobal Interpreter Lock(GIL)によって制限されます。
- Async(asyncio) – 単一スレッドのイベントループで、
awaitポイントでタスクを切り替えます。I/Oバウンドの操作に最適です。 - マルチプロセッシング – 別々のプロセスで、それぞれが独自のPythonインタプリタとメモリ空間を持ちます。GILを回避して真の並列処理を実現します。
GILは複数のスレッドが同時にPythonバイトコードを実行するのを防ぐミューテックスです。つまりスレッドはCPUバウンドのタスクを高速化しませんが、I/O操作中はGILが解放されるため、I/Oバウンドのタスクには役立ちます。
asyncioを使うべき時
asyncioは、プログラムが外部イベント——ネットワークリクエスト、ファイルI/O、データベースクエリ、タイマー——の待機にほとんどの時間を費やす場合に真価を発揮します。単一スレッドを使用するため、スレッド作成やコンテキストスイッチのオーバーヘッドを避けられます。イベントループは何千もの同時接続を効率的に管理します。
次の場合にasyncioを使いましょう:
- 多数の同時接続を処理するWebサーバーやクライアントを構築している場合(例: FastAPI、aiohttp)。
- タスクがI/Oバウンドで、高い並行性(数百から数千のタスク)が必要な場合。
- タスクのスケジューリングとキャンセルを細かく制御したい場合。
- すでにasyncフレームワークやライブラリを使用している場合。
複数のURLを同時に取得する簡単な例を次に示します:
import asyncio
import aiohttp
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f'Fetched {len(results)} pages')
asyncio.run(main())
スレッドを使うべき時
スレッドは、async版が存在しないブロッキングI/O操作がある場合や、asyncに対応していないレガシーコードを扱う場合に適しています。また、少数のタスクであれば理解しやすいという利点もあります。
次の場合にスレッドを使いましょう:
- 複数のファイルの読み取りや同期API呼び出しなど、ブロッキングI/Oを並行して実行する必要がある場合。
- asyncをサポートしていないライブラリと統合する場合。
- 中程度の数の並行タスク(数千ではなく数十)がある場合。
- コードを同期的に保ちつつ、I/Oを重ね合わせたい場合。
concurrent.futures.ThreadPoolExecutorを使った例:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url):
response = requests.get(url)
return response.text
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch, urls))
print(f'Fetched {len(results)} pages')
asyncioとスレッドの比較
判断の参考になるよう、並べて比較してみましょう:
| 項目 | asyncio | スレッド |
|---|---|---|
| 並行処理モデル | 単一スレッドのイベントループ | 複数のOSスレッド |
| 最適な用途 | I/Oバウンド、高い並行性 | I/Oバウンド、ブロッキング呼び出し |
| CPUバウンドの性能 | 低い(GIL) | 低い(GIL) |
| スケーラビリティ | 数千のタスク | 数百のスレッド |
| 複雑さ | async/await構文が必要 | 慣れ親しんだ同期スタイル |
| デバッグ | トレースバックで難しい場合がある | 標準的なデバッグツール |
| エコシステム | asyncサポートが拡大中 | 普遍的 |
CPUバウンドのタスクはどうする?
GILのため、asyncioもスレッドもPythonでCPU集約的な処理を並列化する助けにはなりません。数値計算、画像処理、データ圧縮などのCPUバウンドのタスクには、multiprocessingまたはconcurrent.futures.ProcessPoolExecutorを使いましょう。
例:
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i * i for i in range(n))
if __name__ == '__main__':
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_heavy, [10**6, 10**6, 10**6]))
print(results)
ベストプラクティスと落とし穴
- ブロッキング呼び出しとasyncioを混ぜないこと。 asyncタスク内でブロッキング関数を呼び出すと、イベントループ全体がブロックされます。ブロッキングコードをスレッドプールで実行するには
asyncio.to_thread()を使いましょう。 - スレッドプールのサイズを制限すること。 スレッドを作りすぎるとコンテキストスイッチのオーバーヘッドが発生します。
ThreadPoolExecutor(max_workers=N)を使いましょう。 - 例外を適切に処理すること。 asyncioでは、タスク内の未処理の例外が見逃される可能性があります。
asyncio.gather(..., return_exceptions=True)を使うか、コールバックを追加しましょう。 - 新しいI/O中心のプロジェクトにはasyncioを優先すること。 エコシステムは成熟しており、FastAPIやaiohttpなどのフレームワークが簡単に使えます。
- レガシーまたは単純なスクリプトにはスレッドを使うこと。 いくつかのブロッキング呼び出しを並列化するだけなら、スレッドの方が侵襲性が低いです。
FAQ
asyncioとスレッドを一緒に使えますか?
はい。asyncio.to_thread()またはloop.run_in_executor()を使って、ブロッキングコードをスレッドプールで実行できます。これは、イベントループをブロックせずにasyncコードから同期ライブラリを呼び出す必要がある場合に便利です。
asyncioはGILと一緒に動作しますか?
はい、asyncioは単一スレッドで実行され、GILの影響を受けます。ただし、I/Oバウンドのタスク向けに設計されているため、I/O操作中はGILが解放され、他のタスクが実行できます。CPUバウンドの処理では、asyncioは速度向上をもたらしません。
asyncioとスレッド、どちらが速いですか?
多くの並行操作を伴うI/Oバウンドのタスクでは、asyncioはスレッドのオーバーヘッドを避けるため、一般的に高速でスケーラブルです。少数のブロッキングタスクでは、スレッドの方が簡単で同様の性能を発揮する場合があります。どちらもCPUバウンドのタスクには役立ちません。
正しい選択をするために
まず、ボトルネックがI/OかCPUかを特定することから始めましょう。I/Oで高い並行性が必要ならasyncioを選びましょう。I/Oだがブロッキングライブラリや単純なスクリプトを扱うなら、スレッドは堅実な選択です。CPUバウンドの処理にはマルチプロセッシングを使いましょう。
これらのモデルを組み合わせることもよくあることを覚えておきましょう——たとえば、ネットワーク操作にasyncioを使い、ファイルI/Oにスレッドプールを使うなどです。重要なのはトレードオフを理解し、特定の問題に合ったツールを選ぶことです。
並行API呼び出しから返されたJSONデータを素早く整形または検証したい場合は、JSON Formatterを試して、簡単に整形とデバッグを行いましょう。