Error [Can't pickle <function <lambda>]

`Can't pickle <function <lambda>>` の直し方(Python 3.14 で multiprocessing が落ちる)

FIX SUMMARY verified
Applies when
python@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1Python 3.14 changes the default multiprocessing start method from fork to forkserver on the platforms where fork was the default (non-macOS POSIX: Linux, BSDs); measured on Linux: 3.12/3.13 = fork, 3.14 = forkserver. forkserver pickles the Process object by __qualname__, so a lambda target that fork used to copy through memory is now rejected in the parent at start()

Verified: reproduced in python@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1, then the Can't pickle <function <lambda> signature was gone after the fix (exit 0).

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.12forkfork, spawn, forkserver
3.13forkfork, spawn, forkserver
3.14forkserverforkserver, fork, spawn

変わったのは既定だけで、fork は 3.14 でも選べます(一覧に残っています)。

対象は、これまで fork が既定だったプラットフォーム——Linux、BSD 系、その他 macOS 以外の POSIX です(CPython のドキュメント)。macOS と Windows の既定は以前から spawn で、spawnforkserver と同じく 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 オブジェクト(targetargs を含む)を pickle で送り届ける必要があります。ここで lambda が拒否されます。

証拠として、PoolProcessPoolExecutor は 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._launchreduction.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 に書き換えても、その defmain() やクラスの中にあるなら同じく落ちます。 関数の定義をモジュール直下へ出してください。

どの形が通るかを 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 で渡し、子が自分で openconnect する)に変えてください。

同じことが 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 だと検出できたとき」に出るものです。恒久策としては targetargs を 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 と出るのに落ちるPoolProcessPoolExecutor を使っていませんか。これらは 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 不可の値を包むと落ちることを含む)、argsTypeErrortask = 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 のコンテナのみ)。子プロセスの起動時間は実測していません。

検証(machine-verified)

この修正は python@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 のバージョン固定コンテナ内で再現し、修正後に Can't pickle <function <lambda> のシグネチャが消えることを機械で確認しています。

verify — run-case.mjs
$ node run-case.mjs python/multiprocessing-forkserver-pickle-lambda
● reproduce Can't pickle <function <lambda> present ✓
● apply fix exit 0
● re-run Can't pickle <function <lambda> gone ✓
PASS verified · python@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 · signature gone

確認したのは上のイメージの中だけです。別の環境で直らなかった、記述が違う、という場合は 報告してください(対象と検証イメージは件名・本文に入ります)。