别闹了Fork:在 Linux 上快速创建大型进程
Forking 是在 Linux 中创建进程的方式。尽管多年来 fork 已经改进到使用 COW(写时复制)语义,它仍然必须从父进程向子进程复制一定数量的数据。实验室的一些学生在使用模糊测试机器时遇到了一些问题...
TL;DR
- 如果你想启动一个新程序,使用
posix_spawn()
替换fork()
。 - 如果你想同时运行多个进程处理巨大任务,使用进程池来避免耗时的页表复制。
Fork 很慢
实验室的一些学生在使用模糊测试机器。模糊测试用变异的输入启动应用程序数千次,以查看是否触发了 bug。
这听起来像是用高计算能力暴力对待应用程序, 就像深度学习对用户数据做的事情一样。
困扰我们的是,随着模糊测试的进行,启动目标应用程序所需的时间越来越长。我们对程序进行了采样,发现最耗时的部分是 fork()
系统调用。事实证明,收集每次启动运行信息的模糊测试进程在内存中越来越大。当进程膨胀到一定程度时,克隆成为一个我们不能忽视的性能影响因素。
我们知道 fork()
已经被优化了几十年。它现在使用 COW(写时复制)语义,这意味着在修改之前不会复制用户空间中的数据。但是,它仍然必须从父进程向子进程复制一定数量的数据。
进程池和 Spawn
我们立即想到动态语言解释器面临着和我们一样的问题。随着程序的执行,解释器进程也会经历内存膨胀。如果他们选择创建一个新进程,这可能会非常昂贵。我们调查了 Python 面对多进程程序时提供的几个解决方案。考虑了两个最具代表性的解决方案:池和 spawn。
进程池
进程池技术是一个简单直接的解决方案。它在程序初始化时创建几个空进程,在进程变得过大之前,然后在需要时将任务分配给进程执行。执行完成后,进程被回收到池中以供使用。1
限制
- 我们必须提前确定所需的并发进程数量。如果我想同时运行 100 个进程,发现池中只有 5 个可用,程序必须停止等待,直到运行中的进程被释放并回收。
- 对于已经被
exec()
替换为全新任务的进程,我们无法将其回收到池中。这意味着我们将永远失去这个进程。
总结:池化技术应该用于多任务处理,特别是多进程程序的初始化。但是,它不应该用于快速启动其他进程。
Spawn
当使用 exec()
启动其他进程时,我们知道 fork()
中的复制是不必要的,因为子进程立即被替换为新进程。那么它到底复制了什么呢?最突出的部分是页表。页表是 Linux 为实现 COW 必须复制的部分。它记录特定内存地址中的数据是否已被修改。
vfork()
Berkeley 版本的 Unix (BSD) 在 1980 年代初引入了 vfork()
系统调用。vfork(2)
不会将父进程复制到子进程。两个进程共享父进程的虚拟地址空间;父进程被挂起直到子进程退出或调用 exec()
2
死锁
人们发现当应用程序有多个线程运行时,vfork()
可能引入新问题:死锁。
死锁可能由于动态链接器 ld.so.1
参与解析必要符号而发生。
特别是,假设子进程调用外部函数(如 exec()
)。在这种情况下,
动态链接器可能被调用来解析过程链接表 (PLT) 条目,
动态链接器将获取互斥锁。这个锁可能已经被父进程中的不同线程持有。
如果这种情况发生,它将在父子进程之间创建死锁,因为父进程被挂起
直到子进程调用了 exec()
或 exit()
。结果,父子进程都会挂起。
我发现这很有趣,因为我在用 Rust 编写异步程序时遇到了同样的问题。
看我的文章 异步互斥锁。
posix_spawn()
在 Linux 上,posix_spawn()
只是用 fork()
和 exec()
实现的。如果安全的话,它将使用 vfork()
代替 fork()
。你可以使用带有 POSIX_SPAWN_USEVFORK
标志的 posix_spawn(2)
来避免从大进程分叉时复制页表的开销,同时 Linux 可以保护你免受我们上面提到的死锁。
这是我们最终采用的解决方案。它能够快速创建新进程,避免 fork 的无效复制,并且不需要提前创建进程,无论池大小如何。
Footnotes
© LICENSED UNDER CC BY-NC-SA 4.0