多进程中的fork() 和 spawn() 的区别

53

我在阅读来自 python文档 的两个描述:

spawn

父进程启动一个全新的Python解释器进程。子进程将只继承运行进程对象run()方法所必需的资源。特别地,不会继承从父进程中不必要的文件描述符和句柄。使用此方法启动进程相对于使用fork或forkserver而言较慢。 [在Unix和Windows上可用。在Windows和macOS上为默认值。]

fork

父进程使用os.fork()来复制Python解释器。当子进程开始时,它实际上与父进程完全相同。子进程继承父进程的所有资源。请注意,安全地复制多线程进程是有问题的。 [仅在Unix上可用。在Unix上为默认值。]

我的问题是:

  1. 是否因为fork不尝试识别要复制哪些资源,所以速度更快?
  2. 是否因为fork会复制一切,所以会比spawn()“浪费”更多资源?

1
fork由于写时复制而快速。spawn需要构建一个全新的进程。 - anthony sottile
2个回答

98

在3种多进程启动方法之间存在权衡:

  1. fork 更快,因为它会对父进程的整个虚拟内存进行写时复制,包括初始化的Python解释器、加载的模块和内存中构建的对象。

    但是,fork不会复制父进程的线程。因此,在父进程中保持的锁(在内存中)被其他线程持有并留在子进程中,没有拥有线程来解锁它们,当代码尝试获取其中任何一个锁时,这些锁会导致死锁。此外,具有分叉线程的任何本地库都处于损坏状态。

    复制的Python模块和对象可能有用,也可能只是不必要地膨胀每个分叉子进程。

    子进程还“继承”了操作系统资源,如打开的文件描述符和打开的网络端口。这些也可能会导致问题,但Python会解决其中的一些问题。

    因此,fork快速但不安全,而且可能膨胀。

    但是,这些安全问题根据子进程的操作可能不会引起问题。

  2. spawn可以从头开始启动一个Python子进程,它没有父进程的内存、文件描述符、线程等。 从技术上讲,spawn会分叉出当前进程的副本,然后子进程立即调用exec,用新的Python替换自己,然后请求Python加载目标模块并运行目标可调用对象。

    因此,spawn是安全的、紧凑的和较慢的,因为Python需要加载、初始化自身、读取文件、加载和初始化模块等操作。

    然而,与子进程执行的工作相比,它可能不会明显变慢

  3. forkserver会分叉出当前Python进程的一个副本,缩减到大约一个新的Python进程。这就成为了“fork server”进程。然后每次启动一个子进程时,它都会请求fork server来分叉一个子进程并运行其目标可调用对象。

    这些子进程都是紧凑的,并且没有死锁。

    forkserver更加复杂,文档也不够完善。 Bojan Nikolic的博客文章更详细地解释了forkserver及其秘密的set_forkserver_preload()方法来预加载一些模块。在Python 3.7.0之前,使用这种未记录的方法时要小心,特别是要注意错误修复

  4. 所以forkserver虽然快速、紧凑和安全,但它更加复杂且文档不够完善。

[由于这方面的文档并不好,因此我从多个来源整合了信息并进行了一些推理。如有错误,请评论指出。]


3
@michalmonday 如果父进程在fork子进程时是单线程的,则“fork”选项更安全。因此,在启动其他线程之前,请尽早fork出额外的(子)进程。我不知道“fork”还有什么其他安全问题。 - Jerry101
1
即使模块未被使用,fork()不会导致膨胀。这些模块占用的内存与父进程共享,因为fork()执行写时复制,所以如果子进程不使用这些模块,则它们不会消耗任何额外的内存。 - Lie Ryan
2
@LieRyan 如果这些页面不被使用,它们不会占用 RAM 空间,但它们将增加子进程的地址空间,这可能会使其更接近 Out Of Memory killer。此外,在这些页面中添加/删除对任何 Python 对象的引用将更新其引用计数,因此需要复制其页面。Python 的循环检测 GC 可能需要扫描这些页面,因此将它们交换到 RAM 中并且会造成 GC 工作的开销。 - Jerry101
1
如果需要更新引用计数,则可能需要复制页面,但这仅意味着模块实际上被使用。另一方面,多进程生成方法始终会复制模块,无论是否使用该模块。尽管有refcount和GC,fork仍然比使用spawn时需要复制的内容少得多。 - Lie Ryan
2
多进程生成不像子进程生成。使用子进程生成,您正在生成一个不同的Python程序,该程序可以具有不同(并且希望更小)的已加载模块列表。但是使用多进程生成,初始化将预加载在主进程中加载的所有模块,因此始终比fork更臃肿。 - Lie Ryan
显示剩余2条评论

15
  1. 是因为fork不需要尝试识别哪些资源需要复制,所以才更快吗?

是的,fork更快。内核可以克隆整个进程,并且只复制修改过的内存页作为一个整体。将资源传输到新进程并从头开始引导解释器则是不必要的。

  1. 由于fork会复制所有内容,相比于spawn()它会浪费更多资源,是吗?

现代内核上的fork只进行写时复制,并且仅影响实际发生变化的内存页。需要注意的是,“写”已经包括在CPython中简单迭代对象。这是因为对象的引用计数会增加。

如果您有长时间运行的进程并使用大量小对象,则可能会比使用spawn浪费更多内存。据说Facebook声称将Python进程从“fork”切换到“spawn”后,其内存使用情况显著降低。


1
@Kimi spawn:Windows,macOS 上的 Python 3.8+;fork:Unix 包括 macOS,但 Python 版本需小于 3.8。 - Darkonaut
1
@Darkonaut 谢谢!但是为什么“大量小对象”会导致更多的内存浪费呢?我认为由于“对象单元”很小,所以复制可以更具体?还是因为最小的复制单位不是对象而是页面,对小对象进行一次更改将导致整个页面被复制,其中包括许多重复的小对象? - Crystina
1
@Crystina 是的,后者。这也意味着您的子进程最终会获得其任务实际上不需要的页面副本,只是因为父进程对完全不相关的对象进行了某些操作。 - Darkonaut
1
@Kimi 很抱歉,我不知道多进程在 Docker 中的行为。建议您使用 Docker 标签提出一个单独的问题。 - Darkonaut
Python文档:“从3.8版本开始更改:在macOS上,生成的启动方法现在是默认的。应该考虑使用fork启动方法不安全,因为它可能导致子进程崩溃。请参见bpo-33725。” - lemi57ssss
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接