Python:多进程并行编程与进程池

Python的并行编程可以采用multiprocessingmpi4py模块来完成。

multiprocessing是Python标准库中的模块,实现了共享内存机制,也就是说,可以让运行在不同处理器核心的进程能读取共享内存。在基于共享内存通信的多进程编程中,常常通过加锁或类似机制来实现互斥。

mpi4py库实现了消息传递的编程范式(设计模式)。简单来说,就是进程之间不靠任何共享信息来进行通信(也叫做shared nothing),所有的交流都通过传递信息代替。在信息传递的代码中,进程通过send()receive()进行交流(与共享内存通信形成对比)。

本文先介绍multiprocessing模块,mpi4py留在下一篇博客介绍。

1 Unix进程模型回顾

Python的multiprocessing进程模块在Unix上是基于fork()编程接口的(MacOS上默认是spawn()),故在介绍Python的并行编程模块之前,首先让我们回顾一下Unix的进程模型。

进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。进程提供给应用程序以下的抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

进程间进行通信需要一些诸如共享内存的特殊手段,我们后文第3节部分会提到。

Unix系统上常通过以下方式创建子进程:

# include <stdlib.h>
# include <unistd.h>
# include <stdio.h>

int main(){
    pid_t pid;
    int x = 1;
    pid = fork();
    if (pid == 0){ //Child
        printf("child: x=%d\n", ++x);
        exit(0);
    }
    //Parent
    printf("parent: x=%d\n", --x);
    exit(0);
}

在Unix系统上运行这个程序时,我们得到下面的结果:

parent: x=0
child: x=2

fork()函数是一点需要注意的事它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork()返回子进程的PID;在子进程中,fork() 返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。

2 用multiprocessing模块创建进程

前面我们说过,在Unix下Python的multiprocessing模块实际上是调用fork()系统调用来创建进程的,这么坐会克隆出一个Python解释器,在fork()时会包含所有的状态,也即父进程的所有资源都由子进程继承。此外,父进程既可以在产生子进程之后继续异步执行,也可以暂停等待子进程创建完成之后再继续执行。Python的multiprocessing库通过以下几步创建进程:

  1. 创建进程对象
  2. 调用start()方法,开启进程的活动
  3. 调用join()方法,在进程结束之前一直等待。

如下所示我们创建了3个进程:

import multiprocessing
from multiprocessing import shared_memory
import os
import time

def foo(i, data):
    data[i] = i
    print ('called function in process: %d, data[%d] = %d' % (os.getpid(), i, data[i]))
    time.sleep(10)
    return

if __name__ == '__main__':

    n_processes = 8
    data = [None for i in range(n_processes)]
    
    begin = time.time()
    Process_jobs = []
    for i in range(n_processes):
        # 每个子进程都拿了一个data副本
        p = multiprocessing.Process(target=foo, args=(i, data))
        Process_jobs.append(p)
        p.start()
 
    for i in range(n_processes):
        Process_jobs[i].join()
    end = time.time()
    
    print(data)
    print("time: %d" % (end - begin))

代码打印输出:

called function in process: 39195, data[1] = 1
called function in process: 39198, data[4] = 4
called function in process: 39200, data[6] = 6
called function in process: 39197, data[3] = 3
called function in process: 39201, data[7] = 7
called function in process: 39196, data[2] = 2called function in process: 39194, data[0] = 0

called function in process: 39199, data[5] = 5
[None, None, None, None, None, None, None, None]
time: 11

可以看到子进程的打印是乱序的,说明我们不能对子进程的执行顺序做出任何假设。此外,可以看到每个子进程都拥有主进程中data的副本(也即地址空间独立),在子进程中对data进行修改并没有影响到我们主进程data中的值。最后,我们可以看到虽然我们每个子进程都会睡眠10s,但是整个程序的执行时间是11s,说明我们子进程的执行是并行的(本机为8核CPU)。

这里我们使用VSCode的调试插件,可以直观地看到子进程的创建情况:

诶,我们不是一共生成了8个子进程吗,为什么显示有9个子进程吗?这是因为我是在MacOS上测试的,而在Python3.8版本之后: 对于 MacOS,spawn()启动方式是默认方式。 因为 fork()可能导致subprocess崩溃。若以spawn()方式启动多进程会同时启动一个资源追踪进程,负责追踪当前程序的进程产生的、并且不再被使用的命名系统资源(如命名信号量以及SharedMemory对象)。当所有进程退出后,资源追踪会负责释放这些仍被追踪的的对象。通常情况下是不会有这种对象的,但是假如一个子进程被某个信号杀死,就可能存在这一类资源的“泄露”情况。

3 共享内存

进程有独立的地址空间既是优点也是缺点。这样一来,一个进程不可能不小心覆盖另一个进程的虚拟内存,这就消除了许多令人迷惑的错误——这是一个明显的优点。另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高。

进程间通信的手段有许多,包括管道、消息队列,系统V共享内存,系统V信号量(semaphore)、socket(不同主机上)等。Python的multiprocessing采用共享内存进行进程进程间通信,如下所示:

import multiprocessing
from multiprocessing import shared_memory
import os
import time

def foo(i, data):
    data[i] = i
    print ('called function in process: %d, data[%d] = %d' % (os.getpid(), i, data[i]))
    time.sleep(10)
    return

if __name__ == '__main__':

    n_processes = 8
    data = [None for i in range(n_processes)]
    # 需要置为共享内存
    data = shared_memory.ShareableList([999 for i in range(n_processes)])
    
    begin = time.time()
    Process_jobs = []
    for i in range(n_processes):
        # 每个子进程都拿了一个data副本
        p = multiprocessing.Process(target=foo, args=(i, data))
        Process_jobs.append(p)
        p.start()
 
    for i in range(n_processes):
        Process_jobs[i].join()
    end = time.time()

    print(data)
    print("time: %d" % (end - begin))
  
    data.shm.close()
    data.shm.unlink()

该代码运行结果如下:

called function in process: 43848, data[6] = 6
called function in process: 43842, data[0] = 0
called function in process: 43849, data[7] = 7
called function in process: 43843, data[1] = 1
called function in process: 43846, data[4] = 4
called function in process: 43844, data[2] = 2
called function in process: 43847, data[5] = 5
called function in process: 43845, data[3] = 3
ShareableList([0, 1, 2, 3, 4, 5, 6, 7], name='psm_c252aac3')
time: 11

可以看到8个子进程成功修改了共享内存中的内容。

4 终止/杀掉进程

我们可以使用terminate() 方法终止一个进程,这在Unix系统上是使用SIGTERM信号完成的。另外,我们可以使用 is_alive()方法来判断一个进程是否还存活。

# 杀死一个进程
import multiprocessing
import time

def foo():
    print('Starting function')
    time.sleep(0.1)
    print('Finished function')

if __name__ == '__main__':
    p = multiprocessing.Process(target=foo)
    print('Process before execution:', p, p.is_alive())
    p.start()
    print('Process running:', p, p.is_alive())
    p.terminate()
    time.sleep(0.1) # 注意,传信号需要时间,故sleep一下
    print('Process terminated:', p, p.is_alive())
    p.join()
    print('Process joined:', p, p.is_alive())
    print('Process exit code:', p.exitcode)

该代码打印:

Process before execution: <Process name='Process-1' parent=50932 initial> False
Process running: <Process name='Process-1' pid=50944 parent=50932 started> True
Process terminated: <Process name='Process-1' pid=50944 parent=50932 stopped exitcode=-SIGTERM> False
Process joined: <Process name='Process-1' pid=50944 parent=50932 stopped exitcode=-SIGTERM> False
Process exit code: -15

最后,我们通过读进程的ExitCode状态码(status code)验证进程已经结束, ExitCode可能的值如下:

  • == 0: 没有错误正常退出
  • > 0: 进程有错误,并以此状态码退出
  • < 0: 进程被-1 *信号杀死并以此作为 ExitCode 退出
    在我们的例子中,输出的ExitCode-15 。负数表示子进程被数字为15的信号杀死。

如果我们修改为p.kill()则为立即杀掉一个进程。则上述程序会打印输出:

Process before execution: <Process name='Process-1' parent=52481 initial> False
Process running: <Process name='Process-1' pid=52492 parent=52481 started> True
Process terminated: <Process name='Process-1' pid=52492 parent=52481 stopped exitcode=-SIGKILL> False
Process joined: <Process name='Process-1' pid=52492 parent=52481 stopped exitcode=-SIGKILL> False
Process exit code: -9

也即子进程接收到数字为9的信号,即被杀死。终止进程和杀死进程的区别在于终止进程会先将数据由内存写入磁盘(类似于关机),但杀掉进程则不会保存数据(类似于直接给计算机断掉电源)。

Unix系统上常见信号查询手册如下:

最后,还需要强调一点,Python虽然提供了用信号杀掉进程的函数,但是并不提供用信号直接杀掉线程的函数,这是因为杀掉进程远比杀掉线程安全,具体原因可以参见知乎问题《为什么大多数语言的标准库都不提供或不鼓励使用“杀线程”的功能?》

5 进程间同步

多个进程可以协同工作来完成一项任务,而这通常需要共享数据。所以在多进程之间保持数据的一致性就很重要了。需要共享数据协同的进程必须以适当的策略来读写数据。相关的同步原语和线程的库很类似。

进程的同步原语如下:

  • Lock: 这个对象可以有两种状态:锁住的(locked)和没锁住的(unlocked)。一个Lock对象有两个方法, acquire()release() ,来控制共享数据的读写权限(如果使用with Lock():这样的语法,那么程序的控制流程在进入和离开with语句块时会自动调用acquire()release()方法,我们将称with语句为创建了一个上下文环境(context)[8])。

  • Event: 实现了进程间的简单通信,一个进程发事件的信号,另一个进程等待事件的信号。 Event对象有两个方法, set()clear(),来管理自己内部的变量。

  • Condition: 此对象用来同步部分工作流程,在并行的进程中,有两个基本的方法: wait()用来等待进程, notify_all()用来通知所有等待此条件的进程。

  • Semaphore: 用来共享资源,例如,支持固定数量的共享连接。

  • Rlock: 递归锁对象。其用途和方法同Threading模块一样。

  • Barrier: 将程序分成几个阶段,适用于有些进程必须在某些特定进程之后执行。处于障碍(Barrier)之后的代码不能同处于障碍之前的代码并行。

下列代码展示了如何使用Barrier来同步两个进程。我们有4个进程,进程1和2由Barrier管理,而进程3和4则没有同步策略。

import multiprocessing
from multiprocessing import Barrier, Lock, Process
from time import time
from datetime import datetime

def test_with_barrier(synchronizer, serializer):
    name = multiprocessing.current_process().name
    synchronizer.wait()
    now = time()
    with serializer:
        print("process %s ----> %s" % (name, datetime.fromtimestamp(now)))

def test_without_barrier():
    name = multiprocessing.current_process().name
    now = time()
    print("process %s ----> %s" % (name, datetime.fromtimestamp(now)))

if __name__ == '__main__':
    synchronizer = Barrier(2) # Barrier声明的参数代表要管理的进程总数:
    serializer = Lock()
    Process(name='p1 - test_with_barrier', target=test_with_barrier, args=(synchronizer,serializer)).start()
    Process(name='p2 - test_with_barrier', target=test_with_barrier, args=(synchronizer,serializer)).start()
    Process(name='p3 - test_without_barrier', target=test_without_barrier).start()
    Process(name='p4 - test_without_barrier', target=test_without_barrier

以上代码运行结果如下:

process p3 - test_without_barrier ----> 2022-12-09 20:09:18.382580
process p1 - test_with_barrier ----> 2022-12-09 20:09:18.432348
process p2 - test_with_barrier ----> 2022-12-09 20:09:18.432586
process p4 - test_without_barrier ----> 2022-12-09 20:09:18.464562

可以看到with_barrier的进程1和进程2比without_barrier的进程3和4时间差的小很多。
关于用Barrier同步两个进程的过程,可描述如下图:

6 使用进程池

下面的例子展示了如果通过进程池来执行一个并行应用。我们创建了有8个进程的进程池,然后使用pool.map()方法来进行一个简单的并行计算(这里采样了函数式编程中的map-reduce思想),并与串行的map()方法进行对比。

import time
import multiprocessing

def function_square(x):
    return x * x

if __name__ ==  '__main__':
    data = [i for i in range(1000000)]

    # Parallel implementation
    begin = time.time()
    pool = multiprocessing.Pool(processes=8)
    results1 = pool.map(function_square, data)
    pool.close()
    pool.join()
    end = time.time()
    print("Parallel time: %f" % (end - begin))
    
    
    # Nonparallel code
    begin = time.time()
    results2 = map(function_square, data)
    end = time.time()
    print("Nonparallel time: %f" % (end-begin))

    if list(results1) == list(results2):
        print("Correct!")
        print(results1[:10])

上述代码运行结果如下:

Parallel time: 1.638756
Nonparallel time: 0.000003
Correct!
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

可见串行和并行代码运行结果相同,但是居然并行的时间更慢!这是由于我们计算的主要时间耗费在进程间通信上了(主进程需要将列表的各个部分拷贝到子进程),而每个子进程的计算量又很小,是典型的计算开销小于通信的情况,故不能取得理性的(8倍)的加速比。这告诉我们在使用Python多进程并行编程时要完成的工作规模必须足够大,这样可以弥补额外产生的通信开销。

这里需要说明一下使用Python进程池进行并行化处理时需要考虑的重要因素:

  • 这种并行化处理技术只适用于可以将问题分解成各个独立部分的情况。
  • 任务必须定义成普通的函数来提交。实例方法、闭包或者其他类型的可调用对象都是不支持并行处理的。
  • 函数的参数和返回值必须课兼容于pickle编码。任务的执行是在单独的解释器中完成的,这中间需要用到进程间通信。因此,在不同的解释器间交换数据必须要进行序列化处理(这一点尤其重要,在实际项目中我发现Pytorch的sparse_coo_tensor(类型就无法进行序列化),在调用p.start()时会报错NotImplementedError: Cannot access storage of SparseTensorImpl

7 用多进程来规避GIL

多进程编程的一个好处就是可以用来规避GIL的限制。比如假设我们有以下线程代码:

# Performs a large calculation (CPU bound)
def some_work(args):
    ...
    return result

# A thread that calls the above function
def some_thread():
    while True:
        ...
        r = some_work(args)
        ...

我们可以采用如下方法将代码修改为使用进程池的方式:

import os

# Processing pool (see below for initiazation)
pool = None

#  Performs a large calculation (CPU bound)
def some_work(args):
    ...
    return result

# A thread that calls the  above function
def some_thread():
    while True:
        ...
        r = pool.apply(some_work, (args))
        ...
        
# Initiaze the pool
if __name__ == "__main__":
    import multiprocessing
    pool = multiprocessing.Pool()

在上面这段代码中,每当有线程要执行CPU密集型的任务时,它就把任务提交到池中,然后进程池将任务转交给运行在另一个进程中的Python解释器。当线程等待结果的时候就会释放GIL。此外,由于计算是在另一个单独的解释器中进行的,这样就不再受到GIL的限制了。在多核系统上,将会发现采用这种技术能轻易地利用到所有的CPU核心。

参考

posted @ 2022-12-10 12:17  orion-orion  阅读(1135)  评论(0编辑  收藏  举报