概要(マルチプロセスは「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()
Python4 回ぶん順番に 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 ヘビーな自動化でも、落ち着いてマルチプロセス化に踏み込めるようになります。
