ProcessPoolExecutor 传参踩坑实测:pickle 失败的四种写法

0 阅读10分钟

ProcessPoolExecutor 的报错有个特点:submit() 当时不炸,要等到 future.result() 才炸,而且报的往往是一句和业务毫无关系的 AttributeError。本文用七个真实用例把这几种写法钉死,每条都贴原始报错。

先说结论:pickle 要过两道关,失败分两种——第一种是「找不到这个对象的名字」(lambda、局部函数),报 AttributeError: Can't get local object;第二种是「找到对象了,但它没法被拆成字节」(文件句柄、生成器),报 TypeError: cannot pickle ...。这两关得分开看,压成一个「能不能重建」的判据解释不了句柄为什么失败。

✗ lambda x: x * 2
    AttributeError: Can't get local object '用例1_lambda.<locals>.<lambda>'
✗ 内层定义的函数
    AttributeError: Can't get local object '用例2_局部函数.<locals>.局部'
✓ 模块级普通函数          返回: 42
✓ 实例方法 s.方法         返回: 101
✗ 传打开的文件句柄
    TypeError: cannot pickle 'TextIOWrapper' instances
✓ 只传路径、子进程自己开文件  返回: 3433
✗ 传生成器对象
    TypeError: cannot pickle 'generator' object

注意第四行——实例方法是通过的。这和很多人的印象相反,后面单独讲。

问题出在哪

pickle限制的判据与两类报错

ProcessPoolExecutor 在 Windows 上用 spawn 启动子进程:不是 fork 一份内存,而是起一个全新的 Python 解释器,重新导入模块。这意味着所有跨进程传的东西必须过一趟序列化。

pickle 序列化函数时不做代码复制,它记的是一个限定名(模块名.限定名),子进程拿到名字后自己 import 回来。所以对函数来说,第一关的判据很明确:

这个对象必须有一个可以从模块顶层按名字找到的引用。

但这条只管函数。文件句柄、生成器这类对象有名字也没用——它们是运行时资源,本来就不存在「按名字重建」这回事。所以下面分开看:前两种死在第一关,后两种死在第二关。

一、lambda:没有名字。

from concurrent.futures import ProcessPoolExecutor

def 处理(x):
    # lambda 定义在函数内部,它的限定名是 处理.<locals>.<lambda>
    任务们 = [lambda i: i * 2]
    with ProcessPoolExecutor() as ex:
        ex.submit(任务们[0], 21)      # 炸

报错是 AttributeError: Can't get local object '处理.<locals>.<lambda>'。

这里要分清两种位置,它们的报错不一样,实测过:

lambda 定义在哪pickle.dumps 时的报错
模块顶层PicklingError: ... attribute lookup <lambda> on __main__ failed
函数内部AttributeError: Can't get local object 'xxx.<locals>.<lambda>'

两种都是「找不到可用名字」,只是 pickle 走的分支不同:顶层 lambda 在 __main__ 里找不到 <lambda> 这个名字,报 PicklingError;函数内的 lambda 名字属于某个 locals() 作用域,报 AttributeError。排查时看到哪个,就按哪个往下查,别只盯着一种。

注意它不是 TypeError: cannot pickle。很多人搜报错时按 "cannot pickle lambda" 找,搜不到——这两个错误来自 pickle 内部的两个不同阶段:

报错含义在 pickle 的哪一步
Can't get local object要按限定名找对象,但找不到save_global 取限定名阶段
cannot pickle 'XXX' object对象找到了,但它自己无法被拆成字节调用对象自身 __reduce__ 阶段

两阶段都在序列化过程之内,不是「还没开始」。但对排查者来说关键点是:这两种都不是子进程报的。

报错到底在哪产生,有一个能直接验证的办法:看 traceback 里有没有子进程的栈。实测的报错链路长这样(这段取自另写的一个最小复现,在 Python 3.13.9 上跑的,lambda 定义在 main() 里,所以限定名是 main.<locals>.<lambda>;它和上面用例一的 用例1_lambda.<locals>.<lambda> 是同一类错误、不同位置):

File "...\multiprocessing\queues.py", line 262, in _feed
File "...\multiprocessing\reduction.py", line 51, in dumps
AttributeError: Can't get local object 'main.<locals>.<lambda>'

行号会随 Python 版本变(上面是 3.13.9 的),但 queues.py 的 _feed 和 reduction.py 的 dumps 这两层调用关系是一样的。按文件名找,别记行号。

_feed 是父进程的队列喂料线程。也就是说:参数在父进程里序列化就失败了,异常被挂到 future 上,等到 future.result() 才抛给你。整个过程没有子进程参与。

这也解释了那个实践现象:submit() 不报错、要等 result()——不是子进程慢,是异常在父进程里排着队等你来取。

顺便记一个会先撞到的坑:ProcessPoolExecutor 写在模块顶层(不在 if __name__ == "__main__" 里)会先抛 RuntimeError: ... before the current process has finished its bootstrapping phase。那是 spawn 的启动限制,跟 pickle 无关,别混在一起查。

二、局部函数:有名字,但名字不在模块顶层。

def main():
    def 局部(x):        # 名字是 main.<locals>.局部
        return x + 1
    with ProcessPoolExecutor() as ex:
        ex.submit(局部, 1)    # 炸

报 Can't get local object '...<locals>.局部'。函数确实有名字,但那个名字挂在另一个函数的 locals() 里,子进程重新导入模块时,main() 根本没被调用过,所以这个作用域不存在。

三、模块级函数:通过。

def 模块级函数(x: int) -> int:
    return x * 2

这是唯一必然能过的形式。子进程 import __main__ 后能找到 模块级函数。

四、实例方法:能过,前提是类和实例都可 pickle。

class 服务:                     # 类在模块顶层
    def 方法(self, x):
        return x + 100

s = 服务()
ex.submit(s.方法, 1)            # 通过,返回 101

这条最容易写错。"方法不能传给进程池"是个流传很广的错误结论。 实际情况是:

  • 类是模块级的 → pickle 能按 __main__.服务 重建类
  • 实例的属性都可 pickle → 实例本身能序列化
  • 于是「实例 + 方法名」这个组合能还原

反过来说,如果类定义在函数内部(main.<locals>.服务),那它和局部函数一样炸——限制在类的可见性,不在"是不是方法"。

五、文件句柄 / 生成器:找到对象了,但无法序列化。

ex.submit(收句柄, open("data.txt"))        # TypeError: cannot pickle 'TextIOWrapper'
ex.submit(收任何, (i for i in range(3)))   # TypeError: cannot pickle 'generator'

这两条报的是 TypeError,方向对。原因是它们都含运行时状态:

  • 文件句柄指向内核里的 fd,别的进程没有这个 fd
  • 生成器含执行栈:当前跑到哪、局部变量是什么

同理还有数据库连接、socket、线程锁——都属这一类。

这里有一个必须提醒的排查陷阱:如果被测函数定义在 main() 里,所有用例会报同一个错(Can't get local object 'main.<locals>...'),看起来"都验证了",实际只验证了第一条。本文第一版就是这样,七个用例全挂、报错几乎一样,把函数提到模块顶层后才测出各自的差异。所以复现时先确认被测函数在模块顶层。

怎么改

原则只有一条:跨进程只传数据,资源在子进程里重建。

常见改法对照:

原来的写法改成说明
submit(lambda x: x*2, 21)定义模块级函数 def 双(x): return x*2lambda 一律换掉
submit(局部函数, x)把函数提到模块顶层提到顶层才算有模块级名字
submit(收句柄, open(p))submit(开文件并读, p)只传路径,子进程自己开
submit(收连接, conn)submit(查库, dsn, sql)只传连接串,子进程自己连
submit(收列表, (i for i in...))submit(收列表, list(gen))写成列表,别传生成器

表格里第三、四行是同一个思想:把"打开资源"这个动作搬到子进程内部。代码形态:

# 错误:句柄跨进程传
def 处理(fh):
    return len(fh.read())

# 正确:只传路径
def 处理(path: str):
    # 资源在子进程内部创建,也跟着在这里释放
    with open(path, "r", encoding="utf-8") as fh:
        return len(fh.read())

functools.partial 是个容易被忽略的例外——它能用,但要小心:

from functools import partial

# 可行:partial 的底层函数在模块级,绑定的参数可 pickle
处理某一批 = partial(模块级函数, 2)

# 仍然炸:底层函数是 lambda
坏例子 = partial(lambda x: x * 2, 21)

partial 只是把参数预先绑上,不改变"底层函数必须有模块级名字"这条规则。

如果参数很多,用一个 dict 打包:

def 执行任务(cfg: dict) -> int:
    # 配置在子进程里解析,不传连接对象
    with open(cfg["path"], "r", encoding="utf-8") as f:
        return len(f.read()) * cfg["倍率"]

ex.submit(执行任务, {"path": "data.txt", "倍率": 2})

这样扩展参数不用改函数签名,也避免位置参数传错顺序。

边界与例外

一、本文的实测只在 Windows 的 spawn 上做过,但结论对 fork 同样成立。

网上流传一个错的说法:「Linux 用 fork、复制内存,所以传 lambda 能跑」。

ProcessPoolExecutor 不靠 fork 共享内存传任务:它把「函数 + 参数」放进队列,由父进程的喂料线程序列化后再给子进程。不管 spawn 还是 fork,这步序列化都在(内部用 multiprocessing 的 ForkingPickler),所以 lambda 在 fork 下照样炸。

真正「fork 下能跑」的是另一种写法:multiprocessing.Process(target=lambda: ...)。它不走队列,子进程 fork 出来直接调用那个函数对象,当然不需要 pickle。两者不是一回事。

本文没在 Linux 上实测,这条依据的是「任务必须过队列序列化」的机制,不是实测数据。在 Linux 上跑同一段代码会得到同样的 Can't get local object。

二、ThreadPoolExecutor 完全不受影响。

线程共享同一地址空间,不需要序列化,lambda 和文件句柄都能随便传,本文所有内容对它都不适用。选进程还是线程,先问任务 CPU 密集还是 IO 密集,这比记 pickle 规则更重要。

三、dill / cloudpickle 能绕开,但代价要算清。

这两个库能序列化 lambda 和局部函数,但它们把序列化层做得很深,出错时报错栈指向序列化内部而不是业务代码,排查成本明显上升。本文不把它们当默认方案,也不否定特定场景下的必要性。

四、没有实测性能。

本文只讲"能不能跑通",没测进程池的吞吐、启动开销、序列化本身的时间成本。传一个大 list 和传一个路径字符串,序列化成本差几个数量级,这一点在数据量大时可能比"能不能跑"更关键——但没有实测数据,本文不给数字。

五、3433 是字符数,不是字节数。

它来自 len(f.read()),返回字符数;同样内容按 UTF-8 写成字节会更多(中文一个字 3 字节)。脚本一改这数字就变,读者跑出来不同属正常,重点是"子进程确实读到了文件"。

上手动作

三步,五分钟:

第一,把进程池里所有 lambda 找出来:

git grep -n "submit(lambda" -- "*.py"
git grep -n "map(lambda" -- "*.py"

这两个是最常见的入口。找到后逐个提成模块级函数,顺手起个有含义的名字,比 lambda x: x*2 好读。

第二,检查所有 submit 的第二个参数是不是"资源对象":

凡是打开的文件、连接、socket、生成器,全部改成传标识(路径、DSN、ID),资源在函数内部创建。

第三,提交前先自检:

import pickle

# 提交任务之前先本地序列化一次,比等进程池报错快得多
pickle.dumps(某个函数)
pickle.dumps(某个参数)

这一步能在提交前发现问题,报错位置明确,不用等进程池启动、不用猜哪个参数出错。写进测试里更好,CI 上就能拦住。