3.13 では通っていた Process(target=lambda: ...) が、3.14 では p.start() の行で——子プロセスが生まれる前に——落ちます。
_pickle.PicklingError: Can't pickle <function <lambda> at 0x752f935f7480>: it's not found as __main__.<lambda>
when serializing dict item '_target'
when serializing multiprocessing.context.Process state
when serializing multiprocessing.context.Process object
コードは1行も変えていません。3.13 では同じスクリプトが終了コード 0 で通ります。
Python 3.14 は、multiprocessing の既定 start method を fork から forkserver に変えました(fork が既定だった macOS 以外の POSIX——Linux や BSD 系——が対象です)。forkserver は子プロセスへ渡すものを pickle するので、pickle できない target(lambda・入れ子関数)が拒否されます。最小の修正は、target をモジュール直下で def した関数にすることです。
なぜ Python 3.14 で出るのか:既定の start method が変わった
同じ 1 行を各版で実行すると、境界がそのまま出ます。
import multiprocessing as mp
print(mp.get_start_method(), mp.get_all_start_methods())
| Python | 既定の start method | 選べる start method |
|---|---|---|
| 3.12 | fork | fork, spawn, forkserver |
| 3.13 | fork | fork, spawn, forkserver |
| 3.14 | forkserver | forkserver, fork, spawn |
変わったのは既定だけで、fork は 3.14 でも選べます(一覧に残っています)。
対象は、これまで fork が既定だったプラットフォーム——Linux、BSD 系、その他 macOS 以外の POSIX です(CPython のドキュメント)。macOS と Windows の既定は以前から spawn で、spawn も forkserver と同じく pickle を通ります。つまりこのコードはもともと macOS や Windows では落ちていて、fork のプラットフォームでだけ通っていました。「Linux の CI では通るのに Mac の開発機では落ちる」と言われていたコードで、3.14 では CI 側も落ちます。
(errfix の実測は Linux/amd64 の公式 python イメージだけです。BSD 系は上流ドキュメントに拠ります。)
まず、落ちている環境で mp.get_start_method() を出力してください。forkserver と出ていれば、以下がそのまま当てはまります。
「lambda は pickle できない」だけでは、昨日まで動いていた説明がつかない
Can't pickle <function <lambda>> を読むと、原因は lambda にあるように見えます。lambda が pickle できないこと自体は本当です。ただしそれは 3.14 で始まった話ではなく、ずっと前からそうでした。だから lambda だけを原因に据えると、3.13 でこのコードが動いていた事実と噛み合いません。
変化したのは、Process が pickle を通るようになったことのほうです。
fork:OS のfork()でプロセスのメモリをまるごと複製します。子は親の続きから始まるので、targetはすでに子のメモリの中にあります。pickle は使いません。 だから lambda でも入れ子関数でも、何でもtargetにできました。forkserver:あらかじめ起動しておいたきれいなサーバプロセスから子を fork します。子は親のメモリを引き継がないので、Processオブジェクト(targetとargsを含む)を pickle で送り届ける必要があります。ここで lambda が拒否されます。
証拠として、Pool と ProcessPoolExecutor は 3.13 の fork でも lambda で落ちていました(実測)。
# python:3.13-slim / start method = fork
POOL_ERR= PicklingError Can't pickle <function <lambda> ...>: attribute lookup <lambda> on __main__ ...
Pool はタスクをキュー越しに子へ送るので、start method が fork でも pickle を通ります。pickle を通る経路は昔から lambda を拒否していたわけです。3.14 が変えたのは、Process(target=...) という最後まで pickle を通らなかった経路を、pickle を通る側へ寄せたことです。
例外が親プロセスの p.start() の中で起きるのもこれで説明がつきます。トレースバックの自分のコードのフレームは p.start() の行を指し、その下は popen_forkserver._launch → reduction.dump と続きます。子はまだ生まれていません。
直し方:target をモジュール直下の名前付き関数にする
import multiprocessing as mp
def task(): # モジュール直下(インデントされていない)
print("done")
if __name__ == "__main__":
p = mp.Process(target=task)
p.start()
p.join()
pickle は関数を中身ごと送るのではなく、名前で送ります。子はその名前で関数を引き直します。ここで pickle が使う名前は、変数に束縛した名前ではなく __qualname__ です。エラー文がそう言っています。
Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>
pickle は __main__.<lambda> を探して失敗しています——__main__.task ではありません。冒頭のコードは task = lambda: ... とモジュール直下の名前に束縛していたのに落ちました。lambda の __qualname__ は、どんな名前に代入しても <lambda> のままだからです。入れ子関数も同じで、__qualname__ は make_nested.<locals>.inner になり、引き直せません。
だから、lambda を def に書き換えても、その def が main() やクラスの中にあるなら同じく落ちます。 関数の定義をモジュール直下へ出してください。
どの形が通るかを 3.14 で測ると、こうなります。
target に渡すもの | 3.13(fork) | 3.14(forkserver) |
|---|---|---|
モジュール直下で def した関数 | 通る | 通る |
functools.partial(その関数, 7) | 通る | 通る |
束縛メソッド(Job().run/pickle できる状態だけを持つ) | 通る | 通る |
functools.partial(その関数, open(path)) | 通る | TypeError: cannot pickle 'TextIOWrapper' instances |
| 関数の中で定義した関数(closure) | 通る | Can't pickle local object ...<locals>.inner |
| lambda | 通る | Can't pickle <function <lambda> ...> |
partial と束縛メソッドが通るのは、名前で引けるからではありません。これらは関数ではないので、pickle は中身を組み立て直す手順で送ります——包んでいる関数さえ引き直せれば、残りは値として送れます。逆に言えば、保持している引数やインスタンスの状態が pickle できなければ落ちます(表の 4 行目)。この「値として送る」経路は、次の節の args とまったく同じものです。
target を直しても消えないとき:args も pickle される
pickle を通るのは target だけではありません。args に渡したものも同じ経路を通ります。
handle = open("/etc/hostname")
p = mp.Process(target=task, args=(handle,)) # 3.13 では通っていた
# python:3.14-slim
TypeError: cannot pickle 'TextIOWrapper' instances
ファイルハンドル・DB 接続・ソケット・ロックといった「開いているもの」を args で渡していたコードは、fork のメモリ複製に頼って動いていたので、target を直しても別の TypeError に変わるだけです。子の中で開き直す形(接続文字列やパスだけを args で渡し、子が自分で open/connect する)に変えてください。
同じことが partial の中にも起こります。lambda を functools.partial(upload, open(path)) に置き換えると、関数は引き直せるようになりますが、包み込んだファイルハンドルがここで落ちます。
エラーにならない変化:親で更新したグローバルが子に届かない
forkserver の子は親のメモリを引き継ぎません。子を作るために先に起動される forkserver プロセスが、モジュールを __mp_main__ として読み直し、子はそのサーバから fork されます。そのため、親が起動後にモジュールのグローバルへ加えた変更は、サーバにも子にも届きません。
import multiprocessing as mp
CONFIG = {}
def task():
print("CHILD_SEES=", CONFIG)
if __name__ == "__main__":
CONFIG["loaded"] = True # 親で読み込んでから子を起こす
mp.Process(target=task).start()
| 出力 | 終了コード | |
|---|---|---|
| 3.13(fork) | CHILD_SEES= {'loaded': True} | 0 |
| 3.14(forkserver) | CHILD_SEES= {} | 0 |
どちらも終了コード 0 です。 例外もエラー出力も出ません。子が見るのは、モジュールを読み直した直後の状態です。上の例では CONFIG が {} に戻りますが、モジュールのトップレベルで接続プールやキャッシュを組み立てているなら、子はそれをもう一度作った状態を持ちます(親のものを引き継ぐわけではありません)。いずれにせよ、親が起動後に加えた変更は反映されません。
PicklingError で止まるコードは直せば済みますが、この形は CI も素通りします。3.14 へ上げるときは、Process を起こす前に親が用意していたグローバルを洗い出し、子側で組み立て直すか、args で明示的に渡す形へ変えてください。
同じ理由で、if __name__ == "__main__": ガードが必須になります。ガードなしでトップレベルに Process().start() を書いたスクリプトは、forkserver プロセスがモジュールを読み直したときにその行を再実行し、3.14 では終了コード 1 で落ちます(3.13 の fork では通っていました)。これは spawn が既定の macOS・Windows で以前から要求されていた条件が、fork のプラットフォームにも来た形です。
fork に戻す(コードをすぐ直せないとき)
start method は明示的に指定できます。fork は 3.14 でも選べるので、既定が変わる前と同じ挙動に戻せます。
import multiprocessing as mp
if __name__ == "__main__":
mp.set_start_method("fork") # 3.14 でも受理される
...
3.14 でこれを入れると、lambda を target にしたスクリプトが警告も出さずに終了コード 0 で通ります(実測)。ライブラリ側で Process を起こしているなど、target の定義位置を自分で直せない場合の足場になります。
ただし条件があります。プロセスがスレッドを持っていると、fork は警告を出します。
DeprecationWarning: This process (pid=1) is multi-threaded, use of fork() may lead to deadlocks in the child.
この警告は 3.13 にもありました(multiprocessing の内部モジュールから出るので、既定では抑制されます。-W always::DeprecationWarning を付けると見えます)。マルチスレッドのプロセスを fork すると、他のスレッドが握ったままのロックが子に複製され、子がそのロックを待って止まることがあります。既定が forkserver に変わったのはこの危険が理由なので、set_start_method("fork") は「危険ごと以前の挙動に戻す」指定です。
スレッドがいるかどうかは、目視ではなく実行して確かめてください。
import threading
print(threading.active_count()) # 1 より大きければスレッドがいる
自分で threading を使っていなくても、gRPC・OpenTelemetry・boto3・DB ドライバなどがバックグラウンドスレッドを立てます。警告が出ないことは、安全の証明にはなりません——警告は「Python が multithreaded だと検出できたとき」に出るものです。恒久策としては target と args を pickle できる形へ直すほうを選び、fork は移行期間の足場に留めてください。
なお、set_start_method() を 2 回目に呼ぶと RuntimeError: context has already been set になります(force=True を渡せば上書きできますが、ライブラリが選んだ start method を奪うことになります)。ライブラリの中でも呼ばれていて衝突する場合は、既定を変えずにこの文脈だけを切り替えられます。
ctx = mp.get_context("fork") # 既定を変えずに、この文脈だけ fork
p = ctx.Process(target=task)
切り分け(うまくいかないとき)
mp.get_start_method()がforkと出るのに落ちる:PoolやProcessPoolExecutorを使っていませんか。これらはforkでもタスクを pickle して送るので、start method と無関係に lambda を拒否します(3.13 でも同じです)。この場合は 3.14 の変更とは別件で、直し方は同じ(名前付き関数にする)です。Can't pickleではなくCan't pickle local object ...<locals>.xxx:入れ子関数です。main()やクラスの中でdefした関数をtargetにしています。定義をモジュール直下へ出してください。targetを直したのにTypeError: cannot pickle '...' instancesに変わった:args側です。開いているオブジェクトを渡しています。子の中で開き直す形へ変えてください。- 例外は出ないが結果がおかしい:グローバル状態が子に届いていない可能性があります。上の節の形です。
Processを起こす前に親が用意していたモジュールレベルの状態を洗い出してください。 - 子プロセスの起動が 3.13 より遅くなった:
forkserverの子は、親のメモリではなく別に起動されたサーバのメモリから始まります。親が温めていたキャッシュや import 済みの状態を引き継がないぶん、子の中で初期化をやり直すことになります。重い import をサーバ側に先に済ませて全ての子に継承させるなら、multiprocessing.set_forkserver_preload(["重いモジュール"])があります。なお、start method の変更が本当に原因かは測って切り分けてください(当プロジェクトは起動時間を実測していません)。 - どうしても 3.13 の挙動が要る:
set_start_method("fork")で戻せます。スレッドを持つプロセスではデッドロックの危険が付いてくることは、上の節のとおりです。 - 3.14 の他の変更も先に知っておきたい:同じ 3.14 で、
asyncio.get_event_loop()の暗黙生成とsqlite3の名前つき placeholder も、3.13 まで通っていた形が落ちるようになりました。
検証環境
python@sha256:b877e50b...(python:3.14-slim/ Python 3.14.6・Linux)、ネットワーク不要- 再現:
Process(target=lambda: ...)をstart()すると_pickle.PicklingError: Can't pickle <function <lambda>で終了コード 1 - 修正:
targetをモジュール直下の名前付き関数にすると、同じスクリプトが終了コード 0
再現から修正までは errfix の検証ハーネスが機械的に確認しています。本文の版境界(3.12・3.13 は fork/3.14 は forkserver)、target の形の表(partial に pickle 不可の値を包むと落ちることを含む)、args の TypeError、task = lambda のようにモジュール直下の名前へ束縛しても落ちること、グローバル状態が届かないこと(終了コード 0 のまま)、__main__ ガードが必須になること、set_start_method("fork") が 3.14 で受理されること・2 回目は RuntimeError になり force=True で上書きできること、スレッド有りの fork が 3.13 でも同じ DeprecationWarning を出すことは、いずれも公式 python イメージの 3.12・3.13・3.14 で実測しています。
macOS・Windows の既定が spawn であること、および BSD 系など Linux 以外の POSIX も今回の変更対象であることは、CPython の公式ドキュメントに拠ります(当プロジェクトの実測は Linux/amd64 のコンテナのみ)。子プロセスの起動時間は実測していません。