Pythonの並行処理:スレッド、プロセス、Asyncioのどれを使うべきか?

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Pythonの速度ボトルネック:言語そのものが原因であることは稀

「Pythonは遅い」という決まり文句は、批判的な人々の間でよく言われることです。確かにインタープリタ言語ではありますが、体感的な遅延の多くは構文そのものではなく、実行を停止させるタスクの処理方法に原因があります。5,000件のURLをスクレイピングしたり、4K画像を処理したり、高トラフィックのAPIを構築したりする場合、並行処理戦略がアプリケーションの快適さを左右します。

キャリアの初期、私は重いデータスクリプトを高速化しようとして「スレッド」を投入したことがあります。しかし驚いたことに、実行時間は逆に15%も増加してしまいました。それが私とグローバル・インタプリタ・ロック(GIL)との最初の出会いでした。Pythonのパフォーマンスをマスターすることは、単にコードを並列に実行することではありません。CPUかI/Oか、どの具体的なアーキテクチャのボトルネックが足かせになっているかを特定することなのです。

コアコンセプト:並列処理(Parallelism) vs 並行処理(Concurrency)

コードを書く前に、複数のことを同時に行う(並列処理)ことと、多くのことを一度に扱う(並行処理)ことの違いを区別する必要があります。忙しい厨房を想像してください。並列処理とは、4人の専門シェフを雇って同時に調理することです。並行処理とは、1人の熟練したウェイターが10のテーブルを掛け持ちすることです。ウェイターは一度にすべてのテーブルにいるわけではありませんが、非常に素早く立ち回るため、すべての客が自分に対応してくれていると感じます。

1. Multiprocessing:力仕事の担当

GILは、複数のスレッドが同時にPythonバイトコードを実行するのを防ぎます。16コアのプロセッサを搭載したマシンであっても、標準的なマルチスレッドスクリプトは、しばしば単一のコアに縛られたままになります。Multiprocessingは、Pythonインタープリタの全く新しいインスタンスを生成することで、これを回避します。各プロセスは独自のメモリ空間と独自のGILを持ちます。

最適なケース: CPUバウンドなタスク。100MBのCSVを処理したり、画像を圧縮したり、機械学習の推論を実行したりする場合、すべてのCPUコアを100%活用するにはmultiprocessingが唯一の方法です。

2. Multithreading:I/O待ちのウェイター

スレッドは同じメモリ空間を共有するため軽量ですが、GILの制約を受けます。しかし、Pythonは賢いです。ブロッキングI/O操作の間はGILを解放します。あるスレッドがデータベースのレスポンスを200ミリ秒待っている間に、別のスレッドが割り込んでファイルのダウンロードを開始できるのです。

最適なケース: 中規模なI/Oバウンドなタスク。20〜50個の同時APIリクエストを実行するような、プロセスのオーバーヘッドが過剰になる場合に最適です。

3. Asyncio:モダンなジャグラー

Asyncioはシングルスレッドかつシングルプロセスです。「イベントループ」を使用してタスクをスケジュールします。ネットワークリクエストなどのawaitポイントにタスクが到達すると、ループはそのタスクを一時停止し、即座に次のタスクに移ります。これは非常に効率的です。スレッドが8MBのRAMを消費するのに対し、非同期タスクはわずか数キロバイトしか消費しません。

最適なケース: 高い並行性が求められるI/O。5,000件の同時WebSocket接続を処理したり、スケーラブルなスクレイパーを構築したりする必要がある場合、asyncioが標準的な選択肢となります。

実践演習:現実世界のシナリオ

パフォーマンスは理論ではありません。これらのモデルが負荷の下でどのように動作するかを見てみましょう。

シナリオA:CPUバウンド(巨大な素数の計算)

数学的計算にスレッドを使うのは罠です。GILによって処理が直列化され、コンテキストスイッチによって逆に遅延が発生します。ここでは、ProcessPoolExecutorを使用して4つの物理コアに負荷を分散する方法を示します。

import time
from concurrent.futures import ProcessPoolExecutor

def heavy_computation(n):
    # CPU集約型のタスクをシミュレート
    return sum(i * i for i in range(n))

def run_parallel():
    numbers = [10**7, 10**7, 10**7, 10**7]
    start = time.perf_counter()
    
    with ProcessPoolExecutor() as executor:
        results = list(executor.map(heavy_computation, numbers))
    
    end = time.perf_counter()
    print(f"Multiprocessingの実行時間: {end - start:.2f} 秒")

if __name__ == "__main__":
    run_parallel()

シナリオB:I/Oバウンド(Webデータの取得)

30以上のAPIからデータを取得する場合、OSレベルのスレッドによる膨大なメモリオーバーヘッドを回避できるasyncioが威力を発揮します。ネットワークの遅延を、他の作業を行う絶好の機会として利用します。

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    # 30件のリクエストをシミュレート
    urls = ["https://google.com", "https://python.org", "https://github.com"] * 10
    start = time.perf_counter()
    
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
    
    end = time.perf_counter()
    print(f"Asyncioは {len(results)} ページを {end - start:.2f} 秒で取得しました")

if __name__ == "__main__":
    asyncio.run(main())

現場で学んだ教訓:実践的なヒント

間違ったモデルを選択すると、追跡が非常に困難なバグが発生します。本番システムを運用する中で学んだことは以下の通りです。

  • RAMに注意: Multiprocessingはリソースを消費します。ベースのスクリプトが100MB使用する場合、16個のプロセスを生成すると即座に1.6GBのRAMを消費します。プロセスを拡張する前に、必ずメモリの上限を計算してください。
  • データ共有は高コスト: プロセス間でデータを移動するにはシリアライズ(IPC)が必要で、これは低速です。大きな共有辞書を頻繁に変更する必要がある場合はスレッドの方が高速ですが、競合状態を防ぐためにLock()が必要になります。
  • CPU処理を分離する: async def関数の中で直接重い計算を行わないでください。イベントループ全体がブロックされ、他のすべての接続がフリーズします。loop.run_in_executorを使用して、別プロセスにオフロードしてください。
  • オーバーエンジニアリングを避ける: 小さなタスクでは、プロセスプールのセットアップ時間が実際の処理時間を上回ることがあります。タスクが50ミリ秒未満で終わるなら、単純なforループの方が通常は高速です。

意思決定マトリクス

新しいプロジェクトを開始する際、私は以下のシンプルなロジックに従います。

  1. ネットワークやディスクの待機が中心ですか?
    • 接続数が50未満? シンプルなthreadingを使用。
    • 数百から数千? スケーラビリティのためにasyncioを使用。
  2. 重い計算処理が中心ですか?
    • 常にmultiprocessingを使用。
  3. その両方が含まれますか?
    • ネットワーキングを処理するasyncioコアを構築し、計算処理をProcessPoolExecutorにオフロードする。

最後に

本番システムを運用し、これらのモデルを使い分けることができるのは、シニアエンジニアの証です。「最高の」ツールがあるわけではなく、特定のボトルネックに対して「適切な」ツールがあるだけです。まずはコードをプロファイリングして、実際にどこで時間が費やされているかを見極めることから始めてください。CPUを待っているのか、通信を待っているのかが分かれば、自ずと選択肢は決まります。Pythonは遅くありません。ただ、適切な指揮者が必要なだけなのです。

Share: