要約
- Python 3.14からmultiprocessingの開始方法がforkからforkserverになり、forkの挙動に依存したコードは、ランタイムアップデートで動作しなくなる場合があります
- Pythonのマルチプロセス処理の開始方法は、現在spawn、fork、forkserverの3つがあります
- forkserverは、spawnのような振る舞いをしつつ、CoWを活用できる方式です
Pythonのマルチプロセス処理
Pythonには並列実行の仕組みとして標準でconcurrent.futuresモジュールが提供されており、これで並列実行を少し抽象化して扱えます。
この並列実行の裏の仕組みとしては2種類あり、スレッドベースとプロセスベースがあります。(今回はスレッドベースについては触れません。)
プロセスベースはconcurrent.futuresモジュールのProcessPoolExecutorを使った場合、multiprocessingモジュールを内部で利用し、別プロセスで並列実行を実現します。
そしてプロセスベースには、さらにspawn、fork、forkserverの3つの開始方法があります。
挙動の違いをコードで確認
この違いでハマったのが、spawnではクラスに動的に追加した属性が引き継がれないという点でした(要はグローバル変数の変更です)。
以下のコードを試してみましょう。
親プロセスで追加したクラス属性を、子ワーカー(プロセスまたはスレッド)で読み取れるかを試すものです。
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, Executor
import multiprocessing
import os
class Sample:
pass
def worker(tag):
return tag, os.getpid(), [a for a in dir(Sample) if not a.startswith("__")]
def run(name, executor: Executor):
attr_name = f"custom_attribute_{name}"
# 動的にクラスに属性を追加。これをworkerから参照できるか?(こんなコードは書かないでください)
setattr(Sample, attr_name, attr_name)
with executor as ex:
f = ex.submit(worker, f"{name}")
print(f.result())
# 片付け
delattr(Sample, attr_name)
if __name__ == "__main__":
import multiprocessing.context
context_keys = multiprocessing.context._concrete_contexts.keys()
executors = [
("threadpool", ThreadPoolExecutor()),
] + [
(f"processpool_{key}", ProcessPoolExecutor(mp_context=multiprocessing.get_context(key))) for key in context_keys
]
print("Main PID:", os.getpid())
for (name, executor) in executors:
run(name, executor)
これをLinux(WSL)のPython 3.12で実行してみると以下のようになります。
Main PID: 758746
('threadpool', 758746, ['custom_attribute_threadpool'])
('processpool_default', 758750, ['custom_attribute_processpool_default'])
('processpool_fork', 758760, ['custom_attribute_processpool_fork'])
('processpool_spawn', 758769, [])
('processpool_forkserver', 758789, [])
次にWandboxで(おそらくLinuxの)Python 3.14を実行した結果は次のとおりです。processpool_defaultの結果が異なります。
Main PID: 2
('threadpool', 2, ['custom_attribute_threadpool'])
('processpool_default', 6, [])
('processpool_fork', 9, ['custom_attribute_processpool_fork'])
('processpool_spawn', 13, [])
('processpool_forkserver', 16, [])
threadpoolはThreadPoolExecutorを使った方式です。親プロセスで付け足したクラス属性が子スレッドでも読み取れています。
processpool_forkはProcessPoolExecutorのforkを使った開始方式です。親プロセスで付け足した属性が子プロセスでも読み取れています。
しかしspawnやforkserverでは読み取れていません。開始方式が違うだけで挙動が変わるのは驚きですね。
processpool_defaultの結果がバージョンで異なるのは、3.14から開始方式がforkからforkserverになったためです。
なぜこうなるのか?
これは、プロセスの用意の仕方の違いによるものです。コードを読んだ限りでの私の理解は以下のとおりです。
- spawn: 別のPythonコマンドを実行してプロセスを用意し、
__main__を含むモジュールも再インポートし直してから指定された関数を呼びます。引数はpickleでシリアライズされます - fork: OSのforkを使ってプロセスを用意するため、状態もファイルディスクリプタもコピーされます(高速ですが、spawnと挙動が一致しません)
- forkserver: 別のPythonコマンドを実行してfork用のプロセスを用意し、それをforkすることでプロセスを生成します。それ以外の挙動はspawnと同じです
なぜPython 3.14でforkserverがデフォルトになったのか?
リリースノートにあったこのPRを追いかけると、事情が分かってくると思います。
https://github.com/python/cpython/issues/84559
コメントを見る限り、forkとspawnで動作が一致しない点を問題視し、spawnと動作が一致するforkserverをデフォルトにしたかったように思われます。
どうすべきか
Python 3.13では動作したのに、Python 3.14では変数が参照できなくなるなどのエラーが発生し、動作しなくなった、といった事例が起こりえます。
以下のいずれかで対応するとよいでしょう。
- forkserver or spawn で動作するよう修正する(推奨)
- 3.13にダウングレードする
- 3.14以降で3.13以前の挙動に合わせたい場合は、以下のようにしてforkserverではなくforkを使うよう明示する
# forkを使うよう明示する(実際にはPOSIX環境であるかのチェックもすべき)
if __name__ == "__main__":
multiprocessing.set_start_method('fork', force=False)
forkだとどういった問題が起きるか
forkだとどういった問題が起きるのでしょうか。
- ファイルの書き込み時にバッファが溜まっている状態でforkすると、二重書き込みが起きる(サンプルコード)
- spawn(forkserver)ではグローバル変数は引き継がれないため、エラーになります
一見すると不可思議な挙動ですが、forkの性質上避けられないものであり、子プロセス生成時にファイルディスクリプタを含めてコピーされるために発生します。
ではspawn or forkserverであれば良いかというと、そう単純でもありません。multiprocessingでは、子プロセスに渡す引数もシリアライズを介したコピーが行われます。つまり、状態を持ち変化しうる(いわゆるイミュータブルでない)インスタンスなどを渡すと、意図しない動作になる場合があります。例えば疑似乱数です。
- 疑似乱数の内部状態をコピーしてしまった結果、2つのプロセス間で同じ乱数が出続ける(サンプルコード)
このように、状態が変化するインスタンスを扱う際には、注意が必要です。
マルチプロセス処理を実装する場合は、ドキュメントのガイドラインを読んでおくとよいでしょう。
forkserverのメリット・デメリット
spawnと比べたforkserverのメリットは、2つあるといえます。
-
forkserverはforkによるCoWが効くため、Pythonインタプリタを再ロードせずに済み、プロセスのメモリもCoWにより節約できます
-
forkserverは「fork用のプロセス」を用意するため、そこにpreloadさせるset_forkserver_preloadという機能も追加されています(サンプルコード)
- spawnは都度プロセスを用意するため、モジュールを何度も読み込む必要がありますが、forkserverではfork用プロセスにpreloadしてからforkすることで、1回に抑えつつメモリもCoWで節約できます
- その代わり、forkで問題が起きる処理をpreloadで行うと、当然fork特有の問題が発生します
forkserverのデメリットは以下のとおりです。
- 環境依存である(特にWindowsではfork自体が利用できないのでspawn一択)
- forkserver用のプロセスが常駐する(ただし、CoWにより十分活用できる場合は許容範囲でしょう)
forkserverが使える環境であれば、spawnの高速版のように使えると考えてよいでしょう。