Python の標準ライブラリに含まれる multiprocessing モジュールは、マルチプロセスでの並列処理に用いられる。
Pure Python の処理を並列化しようとしたとき、今のところ最初に検討するのがマルチプロセスになるはず。
というのも、Python のマルチスレッドには多くの場合に GIL (Global Interpreter Lock) の制約があるため。
最も一般的に用いられている Python 処理系の CPython は、まだデフォルトで同時に実行できるスレッドがプロセスあたり一つに限られる 1。
つまり、マルチスレッドでは I/O バウンドな処理を高速化できても、CPU バウンドな処理を高速化できない。
そこで、マルチコア CPU の恩恵を得るためにはスレッドではなくプロセスをたくさん横に並べようという発想になる。
ただし、プロセスはスレッドに比べると生成のコストやプロセス間通信に費やすオーバーヘッドが相対的に大きいといったデメリットがある。
前置きが長くなったけど、今回の本題は multiprocessing モジュールの開始方式 (start method) の違いについて。
開始方式というのは、multiprocessing モジュールが新しくプロセスを作成するときのやり方を指している。
現状で以下の 3 つの方式が用意されていて、プラットフォームごとにデフォルトの開始方式が異なる。
- spawn
- fork
- macOS を除く POSIX システムのデフォルト
- forkserver
今回は、メジャーな OS でデフォルトになっている spawn と fork について、どのような違いがあるのか調べたので備忘録として書いておく。
どのように調べたかというと CPython の multiprocessing モジュールのソースコードを読んだ。
使った環境は次のとおり。
macOS の環境は以下を使った。
$ sw_vers
ProductName: macOS
ProductVersion: 15.3.1
BuildVersion: 24D70
$ uname -srm
Darwin 24.3.0 arm64
$ python3 -V
Python 3.12.9
Linux の環境は以下を使った。
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 24.04.2 LTS
Release: 24.04
Codename: noble
$ uname -srm
Linux 6.8.0-53-generic aarch64
$ python3 -V
Python 3.12.3
もくじ
基本的な使い方について
開始方式の説明に入る前に、おさらいとして multiprocessing モジュールの基本的な使い方について見ておく。
以下のサンプルコードでは、multiprocessing モジュールで作成した子プロセスが、親プロセスから文字列を受け取って標準出力に書き出す。
import multiprocessing as mp
def f(message):
"""メッセージを受け取って標準出力に書き出す関数"""
print("Hello,", message)
def main():
p = mp.Process(target=f, args=("World!",))
p.start()
p.join()
if __name__ == "__main__":
main()
上記を適当なファイル名で保存して実行してみよう。
$ python3 greet.py
Hello, World!
ちゃんとメッセージが表示された。
プロセスの情報を書き出してみる
とはいえ、先ほどの例だとマルチプロセスになっているのかさえよく分からない。
そこで、次はそれぞれのプロセスの情報を書き出すようにしてみよう。
以下のサンプルコードでは、それぞれのプロセスや特殊変数の内容を書き出している。
import os
import multiprocessing as mp
def print_proc_info(context):
"""プロセスに関する情報を標準出力に書き出す関数"""
print(context)
print("__name__:", __name__)
print("parent process id:", os.getppid())
print("process id:", os.getpid())
print("")
def f():
"""子プロセスで実行する関数"""
print_proc_info("### Child ###")
def main():
print_proc_info("### Parent ###")
p = mp.Process(target=f)
p.start()
p.join()
if __name__ == "__main__":
main()
上記を実行する。
すると、親と子のプロセスそれぞれの PID や、親プロセスの PID、そして __name__
変数の内容が出力される。
$ python3 procinfo.py
__name__: __main__
parent process id: 61809
process id: 82787
__name__: __mp_main__
parent process id: 82787
process id: 82789
上記を見ると、子プロセスの os.getppid()
で親プロセスの PID が返されていることが分かる。
また、__name__
の内容も親プロセスと子プロセスでは変わっているようだ。
具体的には子プロセスの場合は __name__
に入っているのが "__mp_main__"
になっている。
ただし、これは前述した開始方式に spawn を使った場合の特徴になる。
上記は、デフォルトの開始方式が spawn になっている macOS で実行したもの。
試しにデフォルトの開始方式が fork の Linux (Ubuntu) で実行してみよう。
$ python3 procinfo.py
__name__: __main__
parent process id: 1225
process id: 1475
__name__: __main__
parent process id: 1475
process id: 1476
すると、今度は __name__
に入っている内容が、どちらのプロセスも "__main__"
になっていることが分かる。
ちなみに、macOS と Linux はそれぞれデフォルトの開始方式が異なっているだけに過ぎない。
つまり、明示的に別の開始方式を指定して使うことはできる。
例えば以下のコードでは set_start_method()
関数を使って明示的に開始方式に fork を指定している。
import multiprocessing as mp
import os
def print_proc_info(context):
"""プロセスに関する情報を標準出力に書き出す関数"""
print(context)
print("__name__:", __name__)
print("parent process id:", os.getppid())
print("process id:", os.getpid())
print("")
def f():
"""子プロセスで実行する関数"""
print_proc_info("### Child ###")
def main():
mp.set_start_method("fork")
print_proc_info("### Parent ###")
p = mp.Process(target=f)
p.start()
p.join()
if __name__ == "__main__":
main()
上記であれば macOS 上で実行したとしても __name__
に入る内容が親子のプロセスで同じになる。
開始方式の fork について
さて、とうとう本題である開始方式の説明に入る。
まずは、より単純な開始方式の fork について。
開始方式の fork は、実装に文字通り fork というシステムコールを使っている。
システムコールは、ユーザ空間からカーネル空間の機能を利用するために使われる API の一種。
その中で fork というシステムコールは、呼び出し元のプロセスを複製して新しいプロセスを生成するのに使われる。
呼び出し元のプロセスと新しく作られるプロセスは、それぞれ親子関係を持ったプロセスになる。
もし、システムコールや fork といった単語に耳馴染みがないときは以下をおすすめしたい。
以下に、multiprocessing モジュールの開始方式として fork を用いるのと概念的に類似したサンプルコードを示す。
標準ライブラリの os モジュールには fork システムコールを薄くラップした API があることから、それを利用している。
import os
import sys
def print_proc_info(context):
"""プロセスに関する情報を標準出力に書き出す関数"""
print(context)
print("__name__:", __name__)
print("parent process id:", os.getppid())
print("process id:", os.getpid())
print("")
def main():
pid = os.fork()
if pid == 0:
print_proc_info("### Child ###")
sys.exit(0)
else:
print_proc_info("### Parent ###")
os.waitpid(pid, 0)
if __name__ == "__main__":
main()
上記のコードがどのように動くのか、最初は少しイメージしにくいかもしれないので少し補足する。
上記の os.fork()
を呼んだ時点で、同じ行を実行しているプロセスが 2 つできる。
ただし、それぞれのプロセスで os.fork()
の返り値が異なっている。
まず、呼び出し元となった親プロセスは返り値として新たに生成された子プロセスの PID が返される。
一方で、新たに生成された子プロセスではゼロが返される。
それぞれのプロセスはその後にある if の条件分岐にそれぞれ入って、返り値ごとに異なる処理を実行する。
親プロセスに関してはプロセスの情報を出力した後で、子プロセスが終了するまで os.waitpid()
関数がブロックする。
そして子プロセスに関してはプロセスの情報を出力した後で os.exit()
関数を使ってプロセスを終了する。
子プロセスが終了すると、親プロセスの os.waitpid()
のブロックが解除されるので親プロセスも終了する。
さて、それでは上記のサンプルコードを適当なファイル名で保存して実行してみよう。
$ python3 fork.py
__name__: __main__
parent process id: 61809
process id: 89320
__name__: __main__
parent process id: 89320
process id: 89321
先ほど multiprocessing モジュールを使ったサンプルコードを Linux 上で実行したのと同じ結果が得られることが分かる。
このように、開始方式の fork は比較的シンプルな作りをしている。
理解する上で重要なポイントは、子のプロセスはメモリなどの状態を親のプロセスから複製しているところ。
そのため親子のプロセスは、特に何もしなくても fork した時点で存在する変数などは同じものが使用できる。
これはメリットとして働く場合もある一方で、誤って同じ資源を異なるプロセスから利用してしまうことで何らかの競合が生じる恐れもある。
開始方式の spawn について
それでは、次にもう少し複雑な処理をしている開始方式の spawn について。
端的に言うと開始方式の spawn は、fork した後で子プロセスが Python のインタプリタを exec し直している (fork-exec)。
その上で、呼び出し元のモジュールを読み込み直したり、実行すべき処理や結果をプロセス間通信でやり取りする。
システムプログラミングの知識があれば、この説明だけでもある程度のイメージが付くかもしれない。
たとえばシェルなどがコマンドを実行するときにやっている処理と大して変わらない。
pipe を使ったプロセス間通信について
本丸となる開始方式の説明に入る前に、少し寄り道して理解の前提として必要な pipe について説明する。
pipe は fork と同様にシステムコールのひとつで、プロセス間通信を実現するための手段のひとつ。
典型的には fork した親子のプロセス間でデータをやり取りするのに使う。
もちろん後ほど spawn に関するサンプルコードの中で使用する。
以下に pipe を fork と組み合わせて使うサンプルコードを示す。
このコードでは親のプロセスから子のプロセスへ pipe を使ってメッセージを送っている。
メッセージを受け取った子のプロセスは、受け取ったメッセージを標準出力に書き出す。
import os
def print_proc_info(context):
"""プロセスに関する情報を標準出力に書き出す関数"""
print(context)
print("__name__:", __name__)
print("parent process id:", os.getppid())
print("process id:", os.getpid())
print("")
def main():
child_r, parent_w = os.pipe()
pid = os.fork()
if pid == 0:
print_proc_info("### Child ###")
os.close(parent_w)
with os.fdopen(child_r, "r") as pipe_r:
message = pipe_r.read()
print("Received message:", message)
else:
print_proc_info("### Parent ###")
os.close(child_r)
with os.fdopen(parent_w, "w") as pipe_w:
pipe_w.write("Hello, World!")
pipe_w.flush()
os.waitpid(pid, 0)
if __name__ == "__main__":
main()
上記に適当な名前をつけて実行してみよう。
$ python3 pipe.py
__name__: __main__
parent process id: 61809
process id: 96303
__name__: __main__
parent process id: 96303
process id: 96304
Received message: Hello, World!
上記の出力から、親プロセスのメッセージを子プロセスで受信できていることが確認できる。
開始方式の spawn と概念的に類似したコード
さて、それでは本丸となるサンプルコードを示す。
これは multiprocessing モジュールの開始方式として spawn を用いる場合にやっていることと概念的にほぼ変わらない。
import os
import pickle
import sys
def print_proc_info(context):
"""プロセスに関する情報を標準出力に書き出す関数"""
print(context)
print("__name__:", __name__)
print("parent process id:", os.getppid())
print("process id:", os.getpid())
print("")
def f(message):
"""メッセージを受け取って標準出力に書き出す関数"""
print("Hello,", message)
def spawn_child_process(target, args):
"""multiprocessing の spawn モードと類似した子プロセスの生成を実現する関数"""
child_r, parent_w = os.pipe()
pid = os.fork()
if pid == 0:
os.close(parent_w)
os.set_inheritable(child_r, True)
os.execvp(
sys.executable,
[
sys.executable,
__file__,
"--multiprocessing-fork",
str(child_r),
],
)
else:
os.close(child_r)
pickled_data = pickle.dumps((target, args))
with os.fdopen(parent_w, "wb") as pipe_w:
pipe_w.write(pickled_data)
pipe_w.flush()
os.waitpid(pid, 0)
def is_spawned_child_process():
"""fork & exec した後の子プロセスかを判断する関数"""
return len(sys.argv) > 1 and sys.argv[1] == "--multiprocessing-fork"
def main():
if is_spawned_child_process():
print_proc_info("### Child ###")
child_r = int(sys.argv[2])
with os.fdopen(child_r, "rb") as pipe_r:
message = pipe_r.read()
target, args = pickle.loads(message)
target(args)
else:
print_proc_info("### Parent ###")
spawn_child_process(f, "World!")
if __name__ == "__main__":
main()
上記には、いくつか理解する上で重要なポイントがある。
まず、子プロセスは fork した後に os.execvp()
という関数を使っている。
この関数は exec と呼ばれるシステムコールの薄いラッパになっている。
exec システムコールを使うと、現在のプロセスで異なるプログラムをメモリ上にロードして新たに実行できる。
実行しているのは sys.executable
なので、要するに呼び出し元の親プロセスで実行していたのと同じ Python 処理系になる。
そして、Python 処理系の引数には __file__
を渡していることから、元々のプロセスが実行していたのと同じモジュールが読み込まれる。
これによって、親のプロセスと同じ関数や変数などが読み込まれて使えるようになる。
ただし、新しいプロセスでモジュールを読み込み直しているので親プロセスとオブジェクト自体は異なっている。
さらに、親プロセスと子プロセスの間でやり取りが必要な情報は、前述した pipe を使って通信している。
具体的には、子プロセスで実行してほしい呼び出し可能オブジェクトや、その引数を Pickle でシリアライズして親プロセスから送っている。
なお、今回は単純のために子プロセスから親プロセスの方向で戻すはずの返り値やエラーなどに関する情報は省いている。
それでは、実際にコードに適当な名前をつけて実行してみよう。
$ python3 spawn.py
__name__: __main__
parent process id: 61809
process id: 98614
__name__: __main__
parent process id: 98614
process id: 98615
Hello, World!
上記から、ちゃんと子プロセスで処理が実行されたことが分かる。
ちなみに開始方式に spawn を使った multiprocessing のコードは、REPL で対話的に実行すると動作しない。
これは、上記のサンプルコードでやっていることを確認すれば自明で、REPL では __file__
が存在しないから。
要するに、新しく起動した Python プロセスにモジュールを読み込ませて関数などのオブジェクトの状態を復元できないことが原因のようだ。
また、開始方式に spawn を使う場合、子プロセスでは実行したくない処理を if __name__ == "__main__":
のインデントの中に置く必要がある。
これについても、新しく起動した Python プロセスにモジュールを読み込ませることが理由になる。
もしインデントの外に出ていると、起動した Python プロセスにモジュールを読み込ませるタイミングでそのコードが評価されてしまう。
前述した通り、開始方式が spawn だと子プロセスの __name__
は "__mp_main__"
になる。
そのため、子プロセスでは条件分岐のインデント内の処理が実行されなくなる。
まとめ
今回は multiprocessing モジュールの開始方式である spawn と fork がやっていることについて調べた。
まず開始方式の fork は、単純に fork(2) しているだけ。
シンプルでオーバーヘッドが少ない点がメリットになる。
その反面、プロセス間で共有するものが多いことから競合が生じやすい。
そして開始方式の spawn は、fork(2) した上で exec(2) している。
これには exec(2) したり、その後でモジュールの読み込みやプロセス間通信に費やすオーバーヘッドが大きいデメリットがある。
その反面、Python のプロセスを一から作り直していることからプロセス間で共有するものが少なくて競合が生じにくい。
なお、今回は第三の開始方式である forkserver について扱わなかった。
どうやら forkserver は子プロセスを生成するために専用のプロセスを用意して、UNIX ドメインソケットでやり取りする実装になっているようだ。
機会があれば、また改めて紹介したい。
ちなみに、一般的な処理の並列化を目的とする場合には multiprocessing モジュールよりも concurrent.features モジュールを使うのがおすすめ。
concurrent.features モジュールの ProcessPoolExecutor の方が multiprocessing モジュールよりも高レベルな API なので楽に並列化できる。
docs.python.org
参考
CPython の multiprocessing モジュールに関するソースコードは以下で読める。
github.com