🐕

PythonのThreadはCPU-Bound処理に最適なのは本当か

に公開

はじめに

Pythonで並行処理といえば「threading」と「asyncio」が有名です。
これらをどう使い分ければよいのか調べると、多くのサイトでは

I/O Bound は asyncio、CPU Bound は thread

と説明されています。

しかし私はこの説明に以前から疑問を持っていました。なぜなら、Pythonには GIL(Global Interpreter Lock) が存在し、これによって複数スレッドが同時にCPUを使うことはできません。つまり、CPU Bound処理においては threading と asyncio で速度に大きな差は出ないはずです。

では実際にはどうなのか? 本当にCPU Boundなら multiprocessing を使って並列処理すべきではないのか?
今回はその点を検証します。

結論

先に結論をまとめます。

  • Threading と Asyncio の実行速度はほぼ同じ(むしろわずかに Asyncio が速いこともある)

  • 「I/O Bound は asyncio、CPU Bound は thread」 というのは誤解。

  • CPU Bound なら multiprocessing を使って並列処理を行うべき。

  • ただし、非同期処理がどうしても書けない/対応できないライブラリを使う場合に限り、threading を使うのはアリ。

  • Cで書かれた拡張(NumPy/OpenCV など)が GIL を解放するなら、スレッドで速くなる可能性はある

まとめると以下の理解が妥当だと思います。

「I/O Bound は asyncio、asyncio が使えないかGILを解放できる場合は thread、CPU Bound は multiprocessing」

※もし誤解や認識違いがあれば、ぜひコメントでご指摘ください!

thread、asyncio、multiprocessingの仕組み(ざっくり)

threading

  • 仕組み: 1つのプロセスの中に複数のスレッドを立てて処理を並行実行する仕組み。

  • 制約: Python には GIL(Global Interpreter Lock) があるため、同じプロセス内のスレッドは「CPUを同時に使う」ことはできない。

asyncio

  • 仕組み: 1スレッド・1プロセスで イベントループ を回し、タスクを切り替えながら実行する。

  • 特徴: スレッドやプロセスを増やさないので軽量。タスク数が非常に多いケースでも効率的に動かせる。

multiprocessing

  • 仕組み: 複数のプロセスを立ち上げ、それぞれが独立したPythonインタプリタを持つ。

  • 特徴: GILの制約を受けずに、CPUコアごとに処理を並列実行できる。

速度比較

実験コード

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

async def fib_async(n):
    return fib(n)

def thread_main(nums):
    with ThreadPoolExecutor(max_workers=len(nums)) as exe:
        _ = [r for r in exe.map(fib, nums)]

def process_main(nums):
    with ProcessPoolExecutor(max_workers=len(nums)) as exe:
        _ = [r for r in exe.map(fib, nums)]

async def asyncio_main(nums):
    tasks = [fib_async(i) for i in nums]
    await asyncio.gather(*tasks)

if __name__ == "__main__":
    nums = list(range(10, 20))
    print("Numbers:", nums)
    start = time.time()
    thread_main(nums)
    elapsed = time.time() - start
    print("Thread:", elapsed)

    start = time.time()
    process_main(nums)
    elapsed = time.time() - start
    print("Process:", elapsed)

    start = time.time()
    asyncio.run(asyncio_main(nums))
    elapsed = time.time() - start
    print("Asyncio:", elapsed)

コード概要
以下で今回フィボナッチ数列の引数に渡すnを生成します。この場合30,31,...,39の数列がdef fib(n):に渡されます。threadやprocessの数は30から39の計算を同時にするため合計10個となります。

nums = list(range(30, 40))

結果(MacM3)

Numbers: [30, 31, 32, 33, 34, 35, 36, 37, 38, 39]
Thread: 13.78576111793518
Process: 6.177403211593628
Asyncio: 13.770554065704346

Thread と Asyncio はほぼ同じ速度。Process はコアを活用できるため約2倍速い。

次にnumsを10から20の範囲にしてみましょう。

nums = list(range(10,20))

結果(MacM3)

Numbers: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
Thread: 0.0012879371643066406
Process: 0.15528297424316406
Asyncio: 0.0014851093292236328

Thread と Asyncio はほぼ同じ速度。タスクが軽い場合は、Process の プロセス生成コスト が支配的になり、むしろ遅くなる。

終わりに

Python の threading は CPUバウンド処理に最適ではありません。
ただし、Cで書かれた拡張(NumPy/OpenCV など)が GIL を解放するなら、スレッドで速くなる可能性はある。

CPUバウンド処理を高速化したいなら multiprocessing が正解。

軽いタスクや短命ジョブでは プロセス生成のオーバーヘッドが逆効果になるため、ケースバイケースで選ぶ必要があります。

最終的なまとめは以下の通りです。

  • I/O Bound → asyncio

  • asyncio が使えない I/O → thread

  • CPU Bound → multiprocessing

  • GIL解放できるCPU Bound -> threadでもok

Discussion