☰
Python多进程写同一个list/dict:原理、方案与避坑指南
2026/10/17 1:34:06 网站建设 项目流程

干过Python并行开发的人,应该都经历过这么一幕:写好的多进程脚本跑起来,子进程吭哧吭哧算半天,最后你发现主进程里的list还是空的,或者dict还是初始值。最气人的是去技术社区问一句"Python多进程怎么往同一个list里写东西",底下回答五花八门,有说加锁的,有说用global的,还有说直接append就行——照着试,全翻车。

这个标题里的"写同一个list/dict"本身就是个容易误导人的说法。今天我把这个问题的原理、可行方案、性能和坑一次性讲透,重点介绍multiprocessing.Manager、Queue、共享内存三类主流做法,以及每类方案在什么场景下才值得用。文章适合那些已经会用multiprocessing启动子进程,但还没真正搞懂进程间数据如何同步的开发者,看完可以直接照抄。

1. 为什么多进程"写同一个list/dict"这句话本身就是个坑

1.1 先看一段一定会翻车的代码

假设你有一段处理数据的任务,想把每个子进程的计算结果累积到一个全局list里:

from multiprocessing import Process import time result_list = [] result_dict = {} def worker(idx): # 模拟耗时计算 time.sleep(1) result_list.append(idx * 2) result_dict[idx] = idx * idx if __name__ == "__main__": processes = [] for i in range(4): p = Process(target=worker, args=(i,)) processes.append(p) p.start() for p in processes: p.join() print("list:", result_list) # 竟然打印 [] print("dict:", result_dict) # 竟然打印 {}

跑完之后,子进程里的append和赋值"好像"都没发生过。有同学会在worker里加一句print,发现子进程明明执行了啊,list也有内容啊,怎么一回到主进程就空了。

1.2 翻车的原因:进程内存是独立地址空间

关键就一句话:multiprocessing创建子进程时,会把父进程当前的内存状态"复制"一份给子进程,但之后两者各过各的,不再共享任何普通对象。这里的复制不是Java里那种引用传递,而是在操作系统层面为子进程分配了独立的地址空间。你把result_list传给子进程,子进程拿到的其实是同一份对象在它自己地址空间里的副本,对这个副本做append,真实写入的是子进程内存页,主进程那边看到的还是原来那个空list。

有人可能会提写时复制(Copy-on-Write),说fork时父子进程不是共享物理内存页吗?没错,fork确实让父子进程先共享同一批物理页,但只要有一方去写这些页,内核就会把对应的页复制一份再修改。子进程执行append时,这个list所在的内存页被复制到子进程私有空间,修改从此与父进程无关。所以不仅"看不见",就算在子进程里反复修改,也不会对父进程有任何影响。

这跟多线程有本质区别:线程共享同一进程的堆空间,一个线程new出来的对象,其他线程通过同一个引用马上就能看到。进程则是"复制整个世界,然后在自己的世界里生活"。

1.3 为什么多线程的直觉在这里失灵

我见过太多人把threading的经验直接搬到multiprocessing上。在threading里,你写shared_list.append(x),所有线程操作的是同一个list,所以问题只在并发冲突,加个Lock搞定。但在多进程里,问题压根不是并发冲突,而是"你操作的list根本不是主进程的那个list"。

再叠加Python的GIL,很多人的误区就更深了。多线程因为GIL的存在,CPU密集任务反而变慢,于是大家转向多进程来用满多核。但进程间数据的传递方式完全变了:多线程靠共享内存,多进程必须靠进程间通信(IPC)机制,比如队列、管道、共享内存、信号量。你没法再用"大家把数据往同一个容器里丢"这种思维,除非那个容器本身挂在某个进程间共享的通道上。

理解了这一点,下面所有方案都会变得很好懂——无非是"用什么机制让子进程的写入能被主进程看到"。

2. 最贴合标题的方案:multiprocessing.Manager的代理容器

2.1 Manager的本质:中间人进程

multiprocessing.Manager大概是所有想"直接写同一个list/dict"的人第一反应会找到的答案。它的原理并不神秘:启动一个独立的manager进程,由它来维护真正的list和dict对象,你拿到的manager.list()返回的其实是一个代理对象(Proxy)。子进程对这个代理执行append时,操作被序列化后发送给manager进程,由manager进程在自己内存里修改真正的list,主进程下次访问时再把最新状态拿回来。

说白了,manager就是给所有进程提供了一个"公共前台",大家去前台办业务,前台背后才是真正的账本。整个过程涉及序列化和网络通信(本地socket),所以注定比直接操作内存慢,但换来的是"看起来像在操作普通list/dict"的接口。

2.2 正确写法和两个容易忽略的注意事项

用Manager改造刚才的代码,只需要把list/dict换成代理对象:

import multiprocessing import time def worker(shared_list, shared_dict, idx): time.sleep(1) shared_list.append(idx * 2) shared_dict[idx] = idx * idx if __name__ == "__main__": with multiprocessing.Manager() as manager: shared_list = manager.list() shared_dict = manager.dict() processes = [] for i in range(4): p = multiprocessing.Process( target=worker, args=(shared_list, shared_dict, i) ) processes.append(p) p.start() for p in processes: p.join() print("list:", shared_list) print("dict:", shared_dict)

注意两点。一是尽量用with multiprocessing.Manager() as manager的写法,退出时保证manager进程被正确关闭,否则某些平台上可能留下残留进程。二是args里直接传代理对象就行,不需要层层转发,子进程拿到代理后会自己去跟manager进程通信。

2.3 Manager不是万能药:复合操作仍要加锁

很多人以为用了Manager就彻底安全了,这是个危险误解。Manager提供的list和dict,单个方法调用确实是原子操作,比如append、d[key] = value,因为每次调用都是一次独立的IPC请求。但多个方法组合在一起,比如"先判断key是否存在,再赋值",或者"检查list长度后追加",就会读出旧数据再写回,中间可能被其他进程打断。

我处理过一个真实案例:8个子进程同时往manager.dict()里做if key not in d: d[key] = [],结果多个进程都判断key不存在,各自创建新列表,最后互相覆盖。这种"检查再更新"必须在外层加锁:

lock = manager.Lock() def worker(shared_dict, lock, idx): with lock: if idx not in shared_dict: shared_dict[idx] = [] shared_dict[idx].append(idx)

Manager还自带Lock、Semaphore、Condition,别自己再去import多线程的Lock,那就是另一条路子了。另外,Manager在Windows和Linux下都能用,但Windows使用spawn方式启动子进程,所有传给Process的参数都必须能pickle,Manager代理对象本身没问题,可如果你在worker里用了lambda表达式,在Windows上会直接报错。

3. 换一个思路:用Queue把结果"上交"给主进程

3.1 生产消费模型为什么比共享容器更顺

如果你只是需要"把每个子进程的结果收集起来",那么本质上你要的不是共享一个list,而是让每个子进程把结果交到某个统一的地方。这种情况下,multiprocessing.Queue往往比Manager更合适。

Queue内部基于管道和缓冲区,自带线程安全/进程安全的put和get,数据从写端进入,从读端取出,天然不会出现两个进程同时改同一个内存位置的问题。子进程不需要知道别人写了什么,只需要把结果put进来;主进程负责get,最后组装成list或dict。这完全符合"生产-消费"模型,代码也更直白:

import multiprocessing import time def worker(q, idx): time.sleep(1) q.put((idx, idx * 2, idx * idx)) if __name__ == "__main__": q = multiprocessing.Queue() processes = [] for i in range(4): p = multiprocessing.Process(target=worker, args=(q, i)) processes.append(p) p.start() result_dict = {} for _ in range(4): idx, double_value, square_value = q.get(timeout=10) result_dict[idx] = {"double": double_value, "square": square_value} for p in processes: p.join() print("dict:", result_dict)

这里主进程直接把每个结果放进dict,子进程从头到尾只接触Queue,不需要什么共享list,最终成果反而更干净。

3.2 收尾阶段最容易踩的坑:还有多少条没读完

用Queue很多人的习惯是"先join所有子进程,再while not q.empty() get数据",这个顺序在数据量大时很容易丢数据。原因在于Queue的底层缓冲区是异步的:子进程put之后立刻返回,数据可能还在内存缓冲里,还没有完全刷到管道对端。如果你立刻join子进程,再判断q.empty(),可能缓冲区还没被后台线程完全写入,主进程就认为队列空了。

正确姿势有几种。最简单的是"先get完所有预期数据,再join子进程",像我上面那段代码,因为知道总共4个子进程,每条任务一个结果,所以循环4次get即可。如果任务数未知,可以用q.close()配合q.join_thread()确保写入完成,或者干脆在子进程put后put一个哨兵值(比如None),主进程读到哨兵就知道结束。我个人最推荐"按任务数收数据",最不容易出问题。

3.3 其实大多数人可以直接用Pool.map

聊到收集结果,还得提一句:很多人根本不需要手动启动Process。如果你的任务是"一个输入数据对应一个结果"这种典型并行映射,multiprocessing.Pool的map方法就是专门干这个的,它内部已经处理好了任务分配和结果收集:

import multiprocessing def add_one(x): return x + 1 if __name__ == "__main__": with multiprocessing.Pool(processes=4) as pool: results = pool.map(add_one, range(8)) print("list:", results)

这段代码简洁到几乎没有出错的余地。Pool.map会阻塞等待所有任务完成,返回一个和输入顺序一致的list。如果你想在任务完成后逐个处理结果,可以用imap/imap_unordered。如果任务太多内存放不下,还可以用imap惰性取值。这也是我给新人最常提的建议:先想想能不能用Pool,能的话就别去折腾Manager和Queue。Pool底层虽然也用队列,但框架帮你把收集的活干了。

4. 高性能场景:放弃list/dict形态,用共享内存

4.1 共享内存的基本玩法

Manager慢,Queue也有序列化开销。如果你的子进程要频繁写入大量数据,每秒几十万次那种,再走"进程间通信"路线就扛不住了。这时候该上共享内存。

Python自带的multiprocessing.Array和multiprocessing.Value封装了共享内存,底层是一块各进程都能直接访问的物理内存区域,读写不需要经过序列化和socket,所以比Manager快一到两个数量级。代价是这些接口只支持C语言原生类型,不支持list/dict这种Python对象结构。换句话说,你得把自己的数据"压扁"成数组或结构体。

4.2 用共享数组模拟一个"固定容量dict"

假设你要让多个子进程往同一个"字典"里写数据,key是整数,value是浮点数,已知最多100个键。可以这样设计:用一个整型数组,第0个位置维护当前已写入的键数量,后面的位置按"key, value"交替排列,再加一个锁防止两个进程同时写入同一个槽位。

import multiprocessing def worker(shared_array, lock, idx): # 数据计算 key = idx * 10 value = idx * 0.5 with lock: # 找到当前数组写入位置 pos = 1 + shared_array[0] * 2 shared_array[pos] = key shared_array[pos + 1] = value shared_array[0] += 1 if __name__ == "__main__": # 容量100个键,数组长度=1(计数器)+100*2 arr = multiprocessing.Array("d", 201) lock = multiprocessing.Lock() processes = [] for i in range(4): p = multiprocessing.Process(target=worker, args=(arr, lock, i)) processes.append(p) p.start() for p in processes: p.join() for i in range(arr[0]): key = int(arr[1 + i * 2]) value = arr[1 + i * 2 + 1] print(key, "->", value)

这个方案的关键是把"字典是否已有这个键"的判断变成"写入一个新条目",因为每条任务写一个不同的键,所以不存在重复覆盖问题。如果多个进程可能写同一个键,那就得先遍历数组查一下key是否已存在,或者用另一个共享数组记录空闲槽位的状态。

4.3 写共享内存时锁和原子性怎么把控

multiprocessing.Array("d", 201)默认自带进程锁,单个元素的读写是原子的,但"查找到哪个位置再写入"这个过程并不是原子的,所以必须自己加Lock。一个常见坑是忘记加锁,多个子进程同时执行pos = 1 + shared_array[0] * 2,读到同一个位置,然后互相覆盖。这种bug比Manager的"检查再更新"更难查,因为它不是必现,只在进程交错执行的瞬间发生。

如果你想完全不写锁,可以用multiprocessing.RawArray配合multiprocessing.Value的原子自增操作。不过说实话,这种极限优化对绝大多数项目没有必要,加Lock的实测开销远小于一次进程通信,正确性远比那几百纳秒的锁开销重要。先把锁加上,用perf_counter测一下吞吐,如果还满足不了需求,再考虑无锁方案。

还有一点:Python 3.8之后有multiprocessing.shared_memory.SharedMemory,它给你一块裸的共享字节区,可以配合numpy的frombuffer直接做高性能数值计算。如果你已经在用numpy处理数据,这个方案非常顺手,但实现复杂度又高一层,我建议先掌握Array/Value这套再进阶。

5. 所有方案对比与我的选型建议

5.1 一张表把方案说明白

这里把几类核心方案按性能、复杂度、适用条件放在一起,方便直接对照:

方案是否保持list/dict形态性能代码复杂度适用场景
Manager.list / dict是慢,每次操作都是序列化+IPC低小数据量、低频写入、需要跨平台
Queue + 主进程聚合否,结果在主进程组装中等,每次put/get有序列化中任务结果收集、生产消费模型
Pool.map / imap否,框架直接返回list中高极低输入输出一一对应的并行计算
Array / RawArray + Lock否,需要自定义结构和索引高,接近内存速度高高频数值写入、性能瓶颈场景
SharedMemory + numpy否,裸字节内存最高很高大数据块、数值计算密集场景

单看这张表,你会发现"多进程写同一个list/dict"这个诉求本身也可以做技术降级:多数时候你需要的不是"共享容器",而是"高效地把子进程运算结果汇总成容器"。想清楚这一点,方案选择就豁然开朗了。

5.2 我在真实项目中踩过的坑和排查过程

有一次我在做数据聚合项目,一开始图省事用了Manager.dict,10个子进程每秒各写几百个键值对,结果主进程打印结果时发现数据对不上,最诡异的是单次运行结果还不一样,有时候丢几个键,有时候重复覆盖。排查过程我至今记得。

第一步是怀疑代码逻辑,给每个子进程打印它写入的键,确认没有逻辑错误。第二步怀疑Manager代理的原子性,查官方文档发现单方法原子但复合操作不原子,于是我在所有"检查再更新"的地方加了manager.Lock,问题依旧。第三步我开始怀疑Manager本身在高并发下的数据一致性,做了个压测脚本:100万次写入,统计最终数量。结果发现即便加了锁,Manager在小规模测试没问题,压力一上来还是偶尔少数据。

最后定位到原因:manager进程是单线程逐条处理代理请求的,当多个子进程同时提交复合操作时,锁本身在manager进程内部并不能完全兜住所有跨方法的时序问题。正确的做法是把复合操作封装成一个manager暴露的自定义方法,让它整体在manager进程内原子执行,或者干脆改用Queue。那次之后我的原则就变成了:需要复杂共享逻辑就别用Manager,需要性能就别用Manager,Manager只适合低频次、key值独立、单方法操作的轻量协作。

另一个印象很深的坑来自Windows环境:在Linux下.run()里传个函数给Process就完事,到了Windows却报"无法pickle本地函数"。我后来养成习惯,所有多进程相关的函数都写在模块顶层,不写在if __name__的代码块内部,参数只传可pickle的数据类型和Manager代理对象,再也没出过这类问题。

5.3 最后一次经验总结

虽然标题是"写同一个list/dict",但真正写代码时我的决策路径其实很固定:如果数据量小、操作简单、还要list/dict接口,用Manager;如果是任务结果汇总,优先Pool.map;如果任务之间接收的是流式数据,用Queue;如果高频写操作已经变成性能瓶颈,那就放弃list/dict的执念,上共享内存。

每次给同事review代码,看到他们纠结"怎么让多进程共享dict"的时候,我都会反问一句:你想要的究竟是"共享"这个dict,还是"拿到最终汇总结果"?大多数时候答案是后者。想明白这个问题,很多技术选型就简单了。希望这篇文章能帮你少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询