【Python】スレッドとプロセスはどう選ぶ?待ち時間と計算負荷で考える

PythonのTopに戻る

結論:待ち時間が多いか、Pythonの計算が多いかで考える

通信やファイル待ちが中心ならスレッド、Pythonの計算が中心ならプロセスをまず候補にする。ただし、並行化すれば必ず速くなるわけではない。仕事が小さければ起動や受け渡しの費用の方が大きくなり、直列処理が有利になる。最初に処理全体を計測して、待っている時間と計算している時間のどちらが問題かを確かめよう。

一般的なGILありのCPythonでは、複数スレッドでPythonコードのCPU計算を同時に進めることに制約がある。一方、I/O待ちの間は別スレッドが進めるし、NumPyなどの内部処理がGILを解放する場合もある。「CPU処理だから全てプロセス」と一律には決められない。Pythonの実装・ビルドや使うライブラリの性質まで含めて選ぶ必要がある。

そのまま動かせる例

例は短い待機を含む仕事をThreadPoolExecutorへ、平方和の計算をProcessPoolExecutorへ渡す。標準ライブラリだけで動く小さな比較で、速度の優劣を示すベンチマークではない。両方式で入力に対応する正しい結果を回収できることを確かめる。実際の通信や大きな計算は行わず、同時実行は2仕事に限定している。

コードをexample.pyとして保存し、ファイルを実行する。プロセス側はspawnを明示し、呼び出す関数をトップレベルへ置き、入口をmainガードで保護している。ノートブックへ貼った場合と同じように動くとは限らない。今回はLinuxで実行したもので、他OSでの動作を実行確認したという意味ではない。

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import multiprocessing as mp
import time

def wait_job(value):
    time.sleep(0.01)
    return value * 2

def cpu_job(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

def main():
    inputs = [1000, 2000, 3000, 4000]
    with ThreadPoolExecutor(max_workers=2) as pool:
        futures = [pool.submit(wait_job, i) for i in range(4)]
        waited = [f.result(timeout=5) for f in futures]
    context = mp.get_context("spawn")
    with ProcessPoolExecutor(max_workers=2, mp_context=context) as pool:
        futures = [pool.submit(cpu_job, n) for n in inputs]
        calculated = [f.result(timeout=10) for f in futures]
    assert waited == [0, 2, 4, 6]
    expected = [(n - 1) * n * (2 * n - 1) // 6 for n in inputs]
    assert calculated == expected
    print("thread results:", waited)
    print("process results:", calculated)
    print("both results checked: True")

if __name__ == "__main__":
    main()

実行結果

thread results: [0, 2, 4, 6]
process results: [332833500, 2664667000, 8995500500, 21325334000]
both results checked: True

共有メモリとデータの受け渡しを比べる

スレッドは同じプロセスのメモリを共有するため、大きな配列をそのまま参照しやすい。ただし共有値を同時に変更するなら競合対策が必要になる。プロセスは通常別のメモリ空間で動き、関数や引数、結果を受け渡す費用が加わる。大きな配列を毎回コピーする設計では、計算の並列化で得た効果を打ち消すことがある。

今回渡すのは小さな整数だけなので、その問題を避けて仕組みを確認できる。実際の仕事では必要な範囲だけを渡す、仕事をある程度まとめる、読取専用データの置き方を工夫するといった設計が重要になる。共有メモリを導入すれば自動的に安全になるわけではなく、寿命や同期の管理も必要なので、まず単純な分離から始めたい。

ワーカー数と待ち時間に上限を置く

同時数を増やすとCPU、メモリ、ファイル記述子、接続先の処理能力などを余分に使う。CPU数と同じ値が常に最適というわけでもない。特に数値ライブラリが内部で複数スレッドを使っている場合は、外側のプロセス数と掛け合わさって過剰な並列化になることがある。まず少数で測定し、全体の負荷と処理時間を見ながら調整しよう。

例のFuture.resultのtimeoutは結果を待つ側の期限であり、実行中の関数を強制停止する指定ではない。withを抜けるときにはExecutorが仕事の終了を待つ。ここでは仕事自体が短く有限なので終了するが、実際の通信には通信側のtimeoutも必要になる。停止が必要なら仕事が協調的に中断できる構成や、資源を分ける設計まで考えておく。

比較前に同じ結果と仕事量をそろえる

本番の方式を決める際は、直列・スレッド・プロセスで同じ入力と処理内容を使い、結果が一致することを先に確認する。起動時間を含めるか、プールを使い回すかでも評価は変わる。1回だけの測定や、OSのキャッシュが一方だけ温まった条件で判断しないようにしたい。失敗回収や終了待ちを省いた速さにも実用上の意味は乏しい。

Python 3.13以降にはGILを無効にした構成もあるが、この例の実行環境は通常のCPython 3.12である。最新の機能説明を読んでも、手元の実行環境へそのまま当てはめないこと。まず1仕事を正しく有限時間で終えられるようにし、その後で同時実行と失敗処理を足す順番が、原因を切り分けやすい。方式を選ぶ理由と制約を実行記録に残しておくと見直しにも役立つ。

確認環境と参考資料

例はLinux・CPython 3.12.14で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。

関連項目:cProfileで遅い処理を探す:tottimeとcumtimeの読み方 / ThreadPoolExecutorで結果と失敗を取りこぼさず回収する / ProcessPoolExecutorが動かないとき:mainガード・pickle・起動方式 / 共有変数の更新がずれる原因は?Lockで競合を防ぐ

PythonのTopに戻る