Python ProcessPoolExecutorとは?CPU処理を並列化する方法

PythonのProcessPoolExecutorは、複数のプロセスを使って計算処理を並列化するクラスです。

GILの制約を受けないため、CPUを使い切る処理を本当に速くできます。


ProcessPoolExecutorの基本的な使い方

import concurrent.futures as cf

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    with cf.ProcessPoolExecutor(max_workers=4) as ex:
        print(list(ex.map(heavy, [100, 200, 300])))

書き方はThreadPoolExecutorとまったく同じです。クラス名を変えるだけで切り替えられます。

ただしif __name__ == "__main__":が必須です。理由は後述します。


スレッドとの速度差

import concurrent.futures as cf, time, os

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    N = 2_000_000

    t = time.perf_counter()
    with cf.ThreadPoolExecutor(max_workers=4) as ex:
        list(ex.map(heavy, [N] * 4))
    th = time.perf_counter() - t

    t = time.perf_counter()
    with cf.ProcessPoolExecutor(max_workers=4) as ex:
        list(ex.map(heavy, [N] * 4))
    pr = time.perf_counter() - t

    print(f"Thread : {th:.2f}秒")
    print(f"Process: {pr:.2f}秒  → {th/pr:.1f}倍")
    print("CPU数:", os.cpu_count())

実行結果(CPU 8コアの環境):

Thread : 0.30秒
Process: 0.15秒  → 1.9倍
CPU数: 8

スレッドでは速くなりません。GILにより、同時にPythonのコードを実行できるスレッドは1つだけだからです。

プロセスはそれぞれ独立したインタプリタを持つため、この制約を受けません。


I/O処理では逆に遅くなる

import concurrent.futures as cf, time

def wait(n):
    time.sleep (0.3)
    return n

if __name__ == "__main__":
    for name, Ex in [("Thread", cf.ThreadPoolExecutor),
                     ("Process", cf.ProcessPoolExecutor)]:
        t = time.perf_counter()
        with Ex(max_workers=4) as ex:
            list(ex.map(wait, range(4)))
        print(f"{name:8}: {time.perf_counter() - t:.2f}秒")

実行結果:

Thread  : 0.31秒
Process : 0.41秒

プロセスのほうが遅くなりました。起動とデータの受け渡しにコストがかかるためです。

待ち時間が主体の処理にProcessを使わないでください。


__main__ガードが必須な理由

import concurrent.futures as cf

def f(n):
    return n * 2

with cf.ProcessPoolExecutor(max_workers=2) as ex:
    print(list(ex.map(f, [1, 2, 3])))

実行結果(macOS・Windows):

RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

macOSとWindowsでは、子プロセスが親のスクリプトを最初から読み込み直します。ガードがないと、読み込むたびにまた新しいプロセスを作ろうとし、無限に増殖します。

if __name__ == "__main__":の中は子プロセスでは実行されないため、これで止まります。

Linuxでは別の方式(fork)が既定のため動いてしまうことがありますが、環境を選ばず必ず書いてください。

同じ理由で、Jupyter NotebookではProcessPoolExecutorが動かないことがあります。処理を別の.pyファイルに切り出してimportすれば回避できます。


渡すデータはpickle化できる必要がある

プロセス間でやり取りするため、関数も引数も戻り値もpickle化されます。

import concurrent.futures as cf

if __name__ == "__main__":
    with cf.ProcessPoolExecutor() as ex:
        list(ex.map(lambda x: x * 2, [1, 2, 3]))

実行結果:

... Can't pickle <function <lambda>>: attribute lookup <lambda> on __main__ failed

lambdaは使えません。モジュールのトップレベルで定義した関数にしてください。

渡せる 渡せない
トップレベルの関数 lambda
数値・文字列・リスト・辞書 ローカル関数(関数の中で定義)
dataclass 開いているファイル・接続
NumPy配列 ロック・スレッド

引数を固定したいならfunctools.partial()を使います。

from functools import partial

ex.map(partial(process, scale=2), items)

データの受け渡しコストに注意

大きなデータを渡すと、pickle化・転送・復元に時間がかかります。

with cf.ProcessPoolExecutor() as ex:
    results = list(ex.map(process, huge_dataframes))   # 転送コストが大きい

並列化で稼いだ時間を転送で食い潰すことがあります。

設計 効果
大きなデータを渡す 遅い
ファイルパスを渡して各プロセスで読む 速い
細かいタスクを大量に投げる オーバーヘッドが目立つ
まとめて渡す(chunksize) 改善する
with cf.ProcessPoolExecutor() as ex:
    results = list(ex.map(process, items, chunksize=100))

chunksizeは何件ずつまとめて子プロセスに渡すかの指定です。1件あたりの処理が軽く件数が多いときに効きます。


グローバル変数は共有されない

import concurrent.futures as cf

counter = 0

def increment(n):
    global counter
    counter += 1
    return counter

if __name__ == "__main__":
    with cf.ProcessPoolExecutor(max_workers=2) as ex:
        print(list(ex.map(increment, range(4))))
    print("親のcounter:", counter)

各プロセスは別々のメモリ空間を持ちます。子プロセスで変更しても親には反映されません。

これは欠点ではなく利点でもあります。スレッドのような競合状態やロックの心配がありません。

結果を集めたいなら、戻り値として返してください。

with cf.ProcessPoolExecutor() as ex:
    results = list(ex.map(process, items))

total = sum(results)

max_workersの決め方

import os
print("CPU数:", os.cpu_count())

実行結果:

CPU数: 8

既定値はCPU数です。通常はそのままで構いません。

CPU数を超えて増やしても速くなりません。計算する場所が足りないうえ、メモリ消費だけが増えます。プロセスは1つあたりインタプリタ丸ごとなので、スレッドより遥かに重い点に注意してください。


ProcessPoolExecutorのまとめ

  • ProcessPoolExecutorはCPU処理を本当に並列化できる
  • 書き方はThreadPoolExecutorと同じ
  • I/O処理では逆に遅い
  • if __name__ == "__main__":が必須
  • 関数・引数・戻り値はpickle化できる必要がある(lambdaは不可)
  • 大きなデータはパスを渡して各プロセスで読む
  • グローバル変数は共有されない——戻り値で返す
  • max_workersはCPU数が目安

あわせて読みたい

プログラミング学習に興味がある方は、まずは無料体験会へ

無料体験会・お申し込みはこちら