Python | 自動化:マルチプロセス

Python
スポンサーリンク

概要(マルチプロセスは「CPUを増やしてゴリゴリ計算させる」仕組み)

前回のマルチスレッドは「待ち時間のあいだに別の仕事をする」話でした。
マルチプロセスはもう一歩踏み込んで、「CPU のコアをフルに使って、重い処理そのものを速くする」ための仕組みです。

Python には GIL(グローバルインタプリタロック)があるので、
純粋に CPU を使いまくる計算は、スレッドを増やしてもあまり速くなりません。
そこで「プロセス(=Python自体を複数立ち上げる)」という荒技で、
コア数分だけ本当に並列に動かしてしまうのが multiprocessing です。

自動化の文脈だと、例えば

  • 大量のデータに重めの計算をかける
  • 画像を何千枚も変換・解析する
  • 大きなCSVを複数チャンクに分けて並列処理する

といった「ガチで重い処理」を速くしたいときに効きます。


マルチスレッドとの違い(どんなときにプロセスを選ぶべきか)

スレッドは「1つのPythonの中で仕事を分ける」

スレッドは、1つの Python プロセスの中で「流れ」を増やすイメージです。
同じメモリ空間を共有するので、共有データの扱いが難しい代わりに、
軽い I/O を並行させるのに向いています。

ただし GIL のせいで、CPU をフルに使う処理(for 文でひたすら計算など)は
スレッドを増やしても根本的には速くなりづらい。

プロセスは「Pythonをまるごと複数個起動する」

マルチプロセスでは、Python インタプリタ自体を複数立ち上げます。
つまり、プロセスごとにメモリも GIL も独立しているので、
複数コア CPU をちゃんと使い切ることができます。

乱暴に言えば、「4コアなら 4 個の Python がガチで同時に計算している」状態を作れる。
その代わりに、

  • プロセス間でのデータの受け渡し(シリアライズ)が発生する
  • 立ち上げコストがスレッドより重い
  • Windows だと書き方に注意が必要

というトレードオフがあります。

ざっくりした判断基準はこうです。

  • ネットワークやファイルI/Oが多い → まずはマルチスレッド
  • 計算そのものが重い(CPUバウンド) → マルチプロセス検討

最小例(重い計算をマルチプロセスで分散する)

まずはシングルプロセス版:素朴な重い処理

例として「0〜N までの数の二乗を足し合わせる」ような、CPU を使うだけの処理を考えます。

import time

def heavy_sum(n: int) -> int:
    s = 0
    for i in range(n):
        s += i * i
    return s

def single_process():
    nums = [10_000_00, 10_000_00, 10_000_00, 10_000_00]  # 同じ処理を4回
    start = time.time()
    results = [heavy_sum(n) for n in nums]
    elapsed = time.time() - start
    print("結果:", results)
    print(f"シングルプロセス: {elapsed:.2f}秒")

if __name__ == "__main__":
    single_process()
Python

4 回ぶん順番に heavy_sum を呼ぶので、単純に 4 倍近く時間がかかります。

multiprocessing.Pool を使って並列化する

同じ heavy_sum を、複数プロセスで同時進行させます。

from multiprocessing import Pool, cpu_count
import time

def heavy_sum(n: int) -> int:
    s = 0
    for i in range(n):
        s += i * i
    return s

def multi_process():
    nums = [10_000_00, 10_000_00, 10_000_00, 10_000_00]

    processes = min(len(nums), cpu_count())

    start = time.time()
    with Pool(processes=processes) as pool:
        results = pool.map(heavy_sum, nums)
    elapsed = time.time() - start

    print("結果:", results)
    print(f"マルチプロセス({processes}プロセス): {elapsed:.2f}秒")

if __name__ == "__main__":
    single_process()
    multi_process()
Python

ここで重要な点を整理します。

Pool(processes=processes)
プロセスプールを作ります。CPU コア数かタスク数の少ない方を使うのが基本です。
コア数以上に増やしても、コンテキストスイッチが増えて逆に遅くなることがあります。

pool.map(heavy_sum, nums)
シングルプロセスの map と同じ感覚で、「nums の各要素に heavy_sum を適用し、その結果をリストで返す」。
ただし中身はプロセスごとに実行されるので、複数コアを同時に使いにいきます。

シングル版とマルチ版の時間を比べると、CPU コア数に応じてそこそこ短くなるはずです(プロセス起動コストの分は引かれます)。


Windows と Linux/Mac での違い(if name == “main” が超大事)

なぜ main ガードが必要なのか

multiprocessing を Windows で使うときに一番ハマりやすいのが、

if __name__ == "__main__":
    ...
Python

を書き忘れることです。

Windows では、新しいプロセスを起動するとき、
「元のスクリプトをもう一度インポートして、その中からターゲットの関数を呼ぶ」という仕組みになっています。
このとき main ガードがないと、「子プロセスがまた Pool を作って子プロセスを起動し…」という無限増殖みたいな事態になります。

必ず、multiprocessing を使うスクリプトの末尾はこうしてください。

if __name__ == "__main__":
    single_process()
    multi_process()
Python

これは Linux/Mac でも悪いことにはならないので、
「multiprocessing を使うときは main ガード必須」と覚えておいてください。


自動化の具体例(大量ファイル処理をプロセスで並列化)

例:1ファイルごとに「重い処理」があるケース

自動化でよくあるのは、

  • 大量の画像ファイルに対して顔認識・OCR・特徴抽出などの重い処理をする
  • 大きな CSV を分割して、それぞれに集計や機械学習前処理をかける

といった、「I/O もあるけど、1ファイルあたりの計算もかなり重い」ケースです。

ここではシンプルに、ファイルごとに「擬似的な重い処理」をする例を考えます。

from pathlib import Path
from multiprocessing import Pool, cpu_count
import time

DATA_DIR = Path("data")

def heavy_process_file(path: Path) -> str:
    print(f"開始: {path.name}")
    total = 0
    for _ in range(5_000_000):
        total += 1
    print(f"終了: {path.name}")
    return path.name

def multi_process_files():
    paths = list(DATA_DIR.glob("*.csv"))
    if not paths:
        print("ファイルがありません")
        return

    processes = min(len(paths), cpu_count())
    start = time.time()

    with Pool(processes=processes) as pool:
        results = pool.map(heavy_process_file, paths)

    elapsed = time.time() - start
    print("処理結果:", results)
    print(f"マルチプロセス合計: {elapsed:.2f}秒")

if __name__ == "__main__":
    multi_process_files()
Python

ここで考えてほしいのは次の点です。

1 ファイルの処理 heavy_process_file は、引数と戻り値だけで完結していて、グローバル状態を書き換えていない。
プロセスごとに完全に独立しているので、データ競合の心配がほぼない。
ファイル名やパスだけを渡し、内部で読み書きさせる形にしているので、プロセス間で巨大なデータを直接渡さなくて済む。

この「1タスク=独立した関数」にして、戻り値で結果を受け取る設計は、マルチプロセスを安全に使ううえでもとても重要です。


プロセス間のデータやり取り(Queue / Manager)の考え方

基本スタンス:「なるべく共有しない」

マルチプロセスでは、プロセスごとにメモリ空間が分かれています。
つまり、普通の変数・リスト・辞書を共有することはできません。

共有したければ、

  • multiprocessing.Queue
  • multiprocessing.Manager
  • 共有メモリ(Value, Array)

などを使いますが、初心者のうちは「なるべく使わない設計」を目指すのが正解に近いです。

つまり、

  • タスクに必要な情報だけを引数で渡す
  • 結果は return で返す(Pool.map や apply がこれをしてくれる)
  • グローバルな状態を持たない or メインプロセスだけが持つ

という構造にしておくと、プロセス間通信の難しさから逃げられます。

それでも「ログや進捗を集約したい」場合

どうしても各プロセスからの情報を集約したいときは、
Queue を使って「メインプロセスに送る」という設計になりますが、
これはいきなりやると確実にごちゃつきます。

現実的には、

  • 各プロセスは自分専用のログファイル(ログ_プロセスID.log)に書く
  • 終了後にメインプロセスでマージする

くらいでも十分実務で耐えられます。

「リアルタイムに全てのプロセスの進捗をマージして一覧表示したい」みたいな贅沢な要件は、最初は捨てていいです。
まずは「きちんと並列に走って、正しい結果が出る」ことを優先します。


注意点の深掘り(オーバーヘッド・pickle・ライブラリの対応)

プロセス起動コストと「やりすぎ問題」

プロセスを立ち上げるのは、スレッドを作るよりも明らかに重いです。
だから、タスク1個あたりの処理が軽すぎると、「並列化の手間 > 節約できる時間」になります。

目安としては、

  • heavy_sum(1000) 程度の軽い処理を大量に並列化しても、遅くなることが多い
  • heavy_sum(10_000_000) くらいの重さを並列にかけるときに威力を発揮する

というイメージを持ってください。
「本当にマルチプロセスを使う価値があるくらい重いか?」を、一度冷静に考える癖が大事です。

関数や引数が「pickle 可能」である必要がある

multiprocessing は、関数や引数・戻り値をプロセス間でやり取りするために pickle(シリアライズ)を使います。
そのため、「モジュールのトップレベルに定義されている関数」「pickle できる型の引数・戻り値」しか扱えません。

例えば、

  • main 関数の中でネスト定義した関数
  • ラムダ関数
  • 一部のファイルハンドルやソケット

などは、そのままでは渡せないことがあります。

初心者向けの安全策としては、

  • heavy_sum や heavy_process_file などは、必ずファイルの先頭付近にトップレベル関数として定義する
  • 引数・戻り値は、基本的に int / float / str / list / dict / Path 程度にする

というルールを自分に課すと楽になります。

一部ライブラリはプロセスとの相性が悪いこともある

例えば、大きな機械学習モデルをロードするライブラリや、
GUI 系、DB コネクションなどは、プロセスごとに複製したり、
子プロセスで使えなかったりすることがあります。

こういうときは、

  • プロセス側では最小限の処理だけをやり、重い初期化はメイン側 or 別手段でやる
  • そもそもマルチプロセスを無理に使わず、バッチを分ける

などの設計変更も検討対象になります。


まとめ(「CPU を使い切るための並列化」として、慎重に使う)

Python のマルチプロセスを、自動化・バッチ処理の文脈で使うときのポイントを整理します。

マルチスレッドは「待ち時間を埋める」のに強く、マルチプロセスは「重い計算そのものを並列化する」のに強い。
multiprocessing.Pool を使えば、「関数と引数のリスト」を渡して結果を受け取る、というシンプルな形で並列化できる。
必ず if __name__ == "__main__": を書く(特に Windows)。
タスクは「引数と戻り値で完結するトップレベル関数」にして、共有状態を持たないように設計する。
プロセス起動コストや pickle 制約を意識し、「本当にマルチプロセスが必要なほど重い処理か?」を一度考えてから使う。

この軸を押さえておけば、
「画像を何千枚も解析する BOT」「大量ログに重い集計をかけるバッチ」
のような、CPU ヘビーな自動化でも、落ち着いてマルチプロセス化に踏み込めるようになります。

タイトルとURLをコピーしました