【ITニュース解説】Python Multiprocessing: Start Methods, Pools, and Communication
2025年09月15日に「Dev.to」が公開したITニュース「Python Multiprocessing: Start Methods, Pools, and Communication」について初心者にもわかりやすく解説しています。
ITニュース概要
Pythonのマルチプロセスは、メモリを独立させて複数の処理を安全に並行実行する機能だ。スレッドとの違い、プロセスを動かす方法、Poolでの効率的なタスク実行(map, apply_asyncなど)について解説する。
ITニュース解説
Pythonでプログラムを開発していると、より多くの処理を同時に、またはより速く実行したいという場面に遭遇することがある。このような「並列処理」を実現するための主要な方法として、「スレッド」と「プロセス」の二つがあり、Pythonでは特に「マルチプロセシング」という仕組みが強力な選択肢となる。この記事では、これらの概念と、Pythonでの具体的な使い方をシステムエンジニアを目指す初心者のために解説する。
まず、スレッドとプロセスの違いを理解することが重要だ。プロセスは、私たちが普段コンピュータで起動するアプリケーション(例:Webブラウザ、テキストエディタ)のような、独立した実行環境を指す。それぞれが独自のメモリ領域を持っており、他のプロセスからは直接アクセスできない。一方、スレッドは一つのプロセスの中で動く、より小さな作業単位だ。複数のスレッドが一つのプロセスの中に存在し、そのプロセスが持つメモリ空間やリソースを共有する。
この「メモリの共有」がスレッドとプロセスの大きな違いを生む。スレッドの場合、例えばグローバル変数のような共有可能なオブジェクトは、すべてのスレッドから直接読み書きできる。これはデータ共有が非常に簡単であるという利点があるが、同時に複数のスレッドが同じデータを同時に変更しようとすると、意図しない結果(競合状態)が発生し、データが壊れる危険性がある。このため、スレッド間で安全にデータを扱うには「同期プリミティブ」(Lockなど)といった特別な制御が必要になる。 対照的に、プロセスはそれぞれ独立したメモリ空間を持つため、あるプロセスが持つPythonオブジェクトを別のプロセスが直接見ることはできない。これにより、互いのデータが意図せず干渉し合う「競合状態」を心配する必要がなくなり、プログラム全体の安定性が高まる。しかし、プロセス間でデータをやり取りするには、「プロセス間通信(IPC)」という専用の仕組みが必要で、データの「直列化(pickle)」といった手間とオーバーヘッドが発生する。例えば、二つのスレッドがそれぞれグローバル変数を10万回ずつ増加させると、合計で20万になることを期待するが、実際には競合により期待通りの値にならないことがある。しかし、二つのプロセスがそれぞれ独立したグローバル変数を増加させても、その結果はそれぞれのプロセス内で独立しており、互いに影響を与えない。メインプロセスから見ると、子プロセスが変更した値は反映されないため、変更されていないように見える。
処理にかかる「オーバーヘッド」と「スケジューリング」の面でも違いがある。スレッドは軽量で、作成や切り替えにかかるコストが低い。しかし、Python(特にCpythonという最も一般的な実装)には「GIL (Global Interpreter Lock)」という仕組みがあり、一度に一つのPythonスレッドしか実行できないという制約がある。このため、CPUを大量に使う計算処理(CPUバウンドな処理)をスレッドで並列化しようとしても、真の並列実行はできず、性能が向上しないことが多い。スレッドは、ファイルの読み書きやネットワーク通信のような、処理の大部分が待ち時間で占められるI/Oバウンドな処理には向いている。 一方、プロセスは起動に時間がかかり、それぞれのメモリ空間を確保するため、スレッドよりも重い。しかし、各プロセスはそれぞれ独立したPythonインタープリタとGILを持つため、CPUバウンドな処理でも複数のCPUコアを使い、真に並列に実行できる。
通信方法も大きく異なる。スレッドでは、共通のメモリ空間を通して暗黙的に通信が行われる。あるスレッドが変数を変更すれば、他のスレッドはすぐにその変更を見ることができるため、非常に高速で複雑なPythonオブジェクトも簡単に共有できる。しかし、前述の通り競合状態のリスクが高く、データ破損を防ぐためにロックなどの同期メカニズムを導入する必要がある。
プロセス間ではメモリが分離されているため、明示的な「プロセス間通信(IPC)」が必要になる。Pythonのmultiprocessingモジュールでは、安全なキュー(multiprocessing.Queue)、双方向のパイプ(multiprocessing.Pipe)、複数のプロセスで共有できる辞書やリスト(multiprocessing.Manager)、高速な大規模データ配列(multiprocessing.shared_memory)など、様々な通信手段が提供されている。プロセス間通信はデータの直列化・非直列化が必要なため、スレッドの通信よりも遅くなるが、メモリが分離されているため競合状態の心配がなく、一つのプロセスがクラッシュしても他のプロセスに影響を与えにくいという「障害隔離」の利点がある。
Pythonでプロセスを実際に作成し、そのライフサイクルを管理するにはmultiprocessing.Processクラスを使用する。このクラスのオブジェクトを生成し、start()メソッドでプロセスを起動し、join()メソッドで子プロセスが終了するのを待つ。terminate()で強制終了したり、is_alive()で稼働状況を確認したり、pidでOS上のプロセスIDを取得したりすることも可能だ。WindowsやmacOSでmultiprocessingを使う際は、プログラムの開始点にif __name__ == "__main__":というブロックを記述することが必須である。これは、新しいPythonインタープリタがモジュールをインポートする際に、再帰的に子プロセスが生成されるのを防ぐためだ。
プロセスの開始方法にはspawn、fork、forkserverの三種類がある。
spawnはWindowsとmacOSのデフォルトで、親プロセスとはまったく独立した新しいPythonインタープリタプロセスを起動する。これにより、親プロセスから意図しない状態が引き継がれることがなく、非常に安全で予測しやすいが、すべてのリソースをゼロからロードするため起動は遅い。クロスプラットフォームでの安全性や予期せぬ問題回避を重視する場合に適している。ただし、子プロセスに渡せるのは直列化可能なオブジェクトに限られる。
forkはLinux/Unixのデフォルトで、親プロセスのメモリイメージをコピーして子プロセスを生成する。この「コピーオンライト」の仕組みにより、起動が非常に高速だが、親プロセスの状態をそのまま引き継ぐため、スレッドや特定のC拡張モジュール(NumPy、TensorFlowなど)と一緒に使うと予期せぬ問題を引き起こすリスクがある。高速起動が最優先で、複雑なC拡張やスレッドに依存しない環境で利用される。
forkserverは、最初に専用の「フォークサーバープロセス」を立ち上げ、そのクリーンなサーバーから新しいプロセスを生成する。forkのような高速性と、spawnのような安全性のバランスを取った方法だ。forkの危険性を回避しつつ、より高速な起動が必要な場合に有用で、ウェブサーバーや機械学習フレームワークのような複雑なアプリケーションで利用されることがある。一般的には、ポータブルで安全なコードにはspawn、Linux環境で高速起動が必要かつスレッドセーフな場合はfork、その中間を求める場合はforkserverを選ぶのが良いだろう。
多数の並列タスクを効率的に処理したい場合、個別にProcessオブジェクトを作成する代わりに「プロセスプール(multiprocessing.Pool)」を使用すると便利だ。プールは決まった数のワーカプロセスを管理し、そこにタスク(関数とその引数)を渡し、結果を受け取るという仕組みを提供する。これは素数判定、画像処理、シミュレーションなど、CPUを大量に使うタスクに最適だ。
Poolクラスは、コンピュータのCPUコア数に応じたワーカプロセスを自動で立ち上げ、タスクを分散して実行する。例えば、Pool.map()メソッドは、Python標準のmap()関数の並列版で、関数と入力リストを受け取り、すべてのタスクが完了するまで待機し、結果をリストとして返す。これは少量のタスクをまとめて処理し、すべての結果が一度に必要となる場合に非常に適しているが、タスク数が多いとすべての結果が揃うまで待つため、メインプロセスがブロックされることと、メモリ使用量が多くなる可能性がある。
Pool.imap()やPool.imap_unordered()は、mapとは異なり、結果を一つずつストリーミング形式で返す。これにより、タスクが長時間実行されたり、数が非常に多かったりする場合でも、結果が揃い次第すぐに次の処理に移れる。imapは入力した順序で結果を返すのに対し、imap_unorderedはタスクが完了した順序で結果を返すため、順序が重要でない場合にスループットを最大化できる。
Pool.apply()メソッドは、単一のタスクを実行し、その結果が返るまでメインプロセスをブロックする。これはマルチプロセシングの機能を単一タスクで試す場合や、特定の結果がすぐに必要な場合に便利だ。
一方、Pool.apply_async()メソッドはメインプロセスをブロックせず、タスクを非同期に実行する。これにより、メインプロセスはタスクの完了を待つことなく、他の処理を続行できる。apply_asyncはAsyncResultというオブジェクトを返し、このオブジェクトを通じてタスクの結果を後で取得したり(.get())、完了したかどうかを確認したり(.ready())、完了するまで待機したり(.wait())できる。また、タスクが成功した場合に実行される「コールバック関数(callback)」や、失敗した場合に実行される「エラーコールバック関数(error_callback)」を設定することも可能で、より柔軟な非同期処理ワークフローを構築できる。
まとめると、mapは小~中規模のデータセットで順序を保ちつつ一括処理するのに最適であり、imapとimap_unorderedは大規模なデータストリームに対して、順序の有無に応じて結果を流動的に処理するのに適している。applyは単発のタスクを同期的に実行する場合、apply_asyncは複数のタスクを非同期にスケジュールし、後で結果を取得する複雑なワークフローに利用するのが良いだろう。これらの機能を適切に使い分けることで、Pythonプログラムの並列処理能力を最大限に引き出し、より高性能で応答性の高いシステムを構築することが可能になる。