python multiprocessing 跨平台性研究

python 发布于 Jul 3, 2022 更新于 Jul 19, 2022

multiprocessing 是一个比较坑的东西,尤其是涉及到跨平台需求时。

最近在做一个开源项目,遇到个需求是做一个守护进程,选中的实现方案就是依赖 multiprocessing 和管道以及 python 的一些支持跨平台的 signal (SIGTERM 和 SIGKILL) 完成服务进程的管理。

流程是比较简单的,守护进程通过 multiprocessing 创建被 daemon 监视的服务进程,然后互相通过 pipe 传递信息,daemon 根据信息去杀死/重启服务进程。

至于为什么要用 multiprocessing,其实是当时以为 multiprocessing 是通过 fork 的方式创建进程的,提前创建好管道再 fork 可以比较方便地通讯。后来才发现 multiprocessing 有三种创建进程的方式,而且在不同平台使用的方式都不一样。

下面先讲讲三种不同的创建进程的方式。

创建进程的底层实现

  • fork:人见人爱的 fork,Unix 的进程创建系统调用。fork 从执行位置开始“分叉”出一个子进程,两个进程在 fork 时内存是拷贝关系,所以父进程的变量的值和打开的文件会被子进程继承,而且由于是复制的,子进程修改变量父进程不会被影响,这个特性经常用于创建管道等 IPC 场景;
  • spawn:Windows 支持且仅支持的方式,从零开始生成一个进程,父进程的一切内存都不会被拷贝到子进程,相当于启动了一个新的进程;
  • forkserver:python 创建进程的一种实现方式,利用 fork 实现类似 spawn 的效果,具体我也没有深入研究,应该类似 gperftools 的那个 tcmalloc,给你提前创建个池子,需要用就拿出来一个,性能会比较好。

我在最开始使用 multiprocessing 模块的时候,满脑子都是 fork,没有考虑到 Python 可能有别的实现方式,下面我来说说从我遇到问题到理解问题的原因的过程。

诡异的问题

我选择 pipe 的方式实现 IPC,使用 multiprocessing.Pipe 创建管道,创建完之后,保存到全局变量,然后在子进程读取这个全局变量获取管道的另一端。

这种方式成立的前提是创建进程使用 fork 方式,我也以为 Python 在 Unix-based 平台一定是使用 fork 创建进程的。但是运行时,我发现子进程根本没有获取到管道,我只能使用下面这个方式(传递参数给 multiprocessing 模块)来把管道交给子进程。

service = Process(target=__service_proc, args=(
    server_pipes, client_pipes, _CONTEXT), daemon=True)

当时懵掉了,为什么 fork 没继承全局变量?

后来我开始翻 multiprocessing 的 Process.start() 方法,逐渐发现了端倪。原来 Python 是对 fork 进行了封装,把之前父进程的内容通过 exec 等方法覆盖掉了。为啥要这样做?当时我理解为是为了兼容 Windows 平台。

最后我先通过传参的方式解决了。

更诡异的问题

六月底,我忽悠了几个伙伴参与这个开源项目,让他们现在环境里配置开发环境。当然,大家都是 Linux,只有我在 macOS 下写。结果在他们配置完的时候出现了一个问题,守护进程在他们那跑不起来!

报什么错呢,报“xxx对象(某全局变量)被重复初始化”。呃,这一眼 100% fork 啊,如果使用 fork 后 exec 覆盖的方式(遇到这个问题时我是这么认为的,但其实不完全准确),所有全局变量都应该会被重置的,不会有这个问题才对。但是我们都是 Unix-based 操作系统,multiprocessing 模块的行为咋就不一样了呢?

这回真想不明白了,于是去翻了 multiprocessing 的文档,看到了下面几行字:

spawn ... Available on Unix and Windows. The default on Windows and macOS.

fork ... Available on Unix only. The default on Unix.

forkserver ... (default on nothing)

🤔原来是 macOS 和 Unix 用的方式不一样啊,最后显式调用 multiprocessing.set_start_method('spawn', force=True),把创建新进程的方式设置成 spawn 了,这样全平台行为都一致了。

但是为什么会不一样呢?

Python 如何使用这三种方式

这是文档里面的一段 Note:

Changed in version 3.8: On macOS, the spawn start method is now the default. The fork start method should be considered unsafe as it can lead to crashes of the subprocess. See bpo-33725.

Changed in version 3.4: spawn added on all unix platforms, and forkserver added for some unix platforms. Child processes no longer inherit all of the parents inheritable handles on Windows.

On Unix using the spawn or forkserver start methods will also start a resource tracker process which tracks the unlinked named system resources (such as named semaphores or SharedMemory objects) created by processes of the program. When all processes have exited the resource tracker unlinks any remaining tracked object. Usually there should be none, but if a process was killed by a signal there may be some “leaked” resources. (Neither leaked semaphores nor shared memory segments will be automatically unlinked until the next reboot. This is problematic for both objects because the system allows only a limited number of named semaphores, and shared memory segments occupy some space in the main memory.)

3.4 之后 Unix 也支持 spawn 了,forkserver 部分 Unix 平台支持;macOS 在 3.8 之后改用 spawn 方式了,因为 fork 有副作用。(所以为了跨平台,我只能用 spawn 了)

然后看底下那个,Unix 用 spawn 会有一点点资源泄露,共享内存和信号量用 signal 杀进程不会被释放,重启系统才行,然后正好我需要用 signal。好像在我代码里也不是很好解决,不过问题不大,凑合用吧。


2022年7月3日

标签

Noam Chi

An Innovative Quant Developer. 2018 VEX World Final THINK Award🏆