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() ,来控制共享数据的读写权限。

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核心。

参考

Python:多进程并行编程与进程池的更多相关文章

  1. Python多进程库multiprocessing中进程池Pool类的使用[转]

    from:http://blog.csdn.net/jinping_shi/article/details/52433867 Python多进程库multiprocessing中进程池Pool类的使用 ...

  2. Python多进程库multiprocessing中进程池Pool类的使用

    问题起因 最近要将一个文本分割成好几个topic,每个topic设计一个regressor,各regressor是相互独立的,最后汇总所有topic的regressor得到总得预测结果.没错!类似ba ...

  3. Python之网路编程之进程池及回调函数

    一.数据共享 1.进程间的通信应该尽量避免共享数据的方式 2.进程间的数据是独立的,可以借助队列或管道实现通信,二者都是基于消息传递的. 虽然进程间数据独立,但可以用过Manager实现数据共享,事实 ...

  4. python 之 并发编程(进程池与线程池、同步异步阻塞非阻塞、线程queue)

    9.11 进程池与线程池 池子使用来限制并发的任务数目,限制我们的计算机在一个自己可承受的范围内去并发地执行任务 池子内什么时候装进程:并发的任务属于计算密集型 池子内什么时候装线程:并发的任务属于I ...

  5. PYTHON多进程编码结束之进程池POOL

    结束昨晚开始的测试. 最后一个POOL. A,使用POOL的返回结果 #coding: utf-8 import multiprocessing import time def func(msg): ...

  6. python之管道, 事件, 信号量, 进程池

    管道:双向通信 2个进程之间相互通信 from multiprocessing import Process, Pipe def f1(conn): from_zjc_msg = conn.recv( ...

  7. python并发编程之多进程2-(数据共享及进程池和回调函数)

    一.数据共享 1.进程间的通信应该尽量避免共享数据的方式 2.进程间的数据是独立的,可以借助队列或管道实现通信,二者都是基于消息传递的. 虽然进程间数据独立,但可以用过Manager实现数据共享,事实 ...

  8. python并发编程之多进程2数据共享及进程池和回调函数

    一.数据共享 尽量避免共享数据的方式 可以借助队列或管道实现通信,二者都是基于消息传递的. 虽然进程间数据独立,但可以用过Manager实现数据共享,事实上Manager的功能远不止于此. 命令就是一 ...

  9. python并发编程之进程池,线程池concurrent.futures

    进程池与线程池 在刚开始学多进程或多线程时,我们迫不及待地基于多进程或多线程实现并发的套接字通信,然而这种实现方式的致命缺陷是:服务的开启的进程数或线程数都会随着并发的客户端数目地增多而增多, 这会对 ...

  10. Python并发编程之进程池与线程池

    一.进程池与线程池 python标准模块concurrent.futures(并发未来) 1.concurrent.futures模块是用来创建并行的任务,提供了更高级别的接口,为了异步执行调用 2. ...

随机推荐

  1. Elastic:用 Docker 部署 Elastic Stack

    文章转载自:https://elasticstack.blog.csdn.net/article/details/100919273 前提条件 首选需要在主机上安装好docker和docker-com ...

  2. 努力一周,开源一个超好用的接口Mock工具——Msw-Tools

    作为一名前端开发,是不是总有这样的体验:基础功能逻辑和页面UI开发很快速,本来可以提前完成,但是接口数据联调很费劲,耗时又耗力,有时为了保证进度还不得不加加班. 为了摆脱这种痛苦,经过一周的努力,从零 ...

  3. Linux+Wine运行QQTIM (2022年9月)

    测试的版本Tim3.4.0 QQ9.6.7 如果你的系统没有Wine先装Wine,Wine在各大发行版的源都能找到.记住32位和64位的Wine都要装 去https://tubentubentu.pa ...

  4. JSP实现登录删除添加星座等(带样式)

    功能要求 1.完成两个页面 2.第一个登陆页面login. jsp 3.第二个用户管理页面useManage. jsp 4.有登录功能(能进行用户名密码的校验,用户名若为自己的学号密码为班级号,允许登 ...

  5. Ruoyi字典源码学习

    此文章属于ruoyi项目实战系列 使用目的 什么是字典数据:具体的值(0,1,"Y","N"),对应具体的业务逻辑("男","女& ...

  6. Docker容器技术基础

    Docker基础 目录 Docker基础 容器(Container) 传统虚拟化与容器的区别 Linux容器技术 Linux Namespaces CGroups LXC docker基本概念 doc ...

  7. ASCII(American Standard Code for Information Interchange,美国标准信息交换代码)

    ASCII(American Standard Code for Information Interchange,美国标准信息交换代码) ASCII简介 ASCII(American Standard ...

  8. 华为交换机VLAN常用命令

    划分vlan vlan 10 划分Vlan10 vlan batch 30 40 同时创建vlan30和40 dispaly vlan 查看vlan信息 int e0/0/1 进入某一个接口 port ...

  9. 设计模式常用的UML图------类图

    关系 UML将事物之间的联系归纳为6种,对应响应的图形 关联 定义:表示拥有的关系,具有方向性,一个类单向访问一个类,为单向关联.两个类可以相互访问,为双向关联. 聚合 定义:整体与部分的关系. 组合 ...

  10. 齐博x1内容页中下一页上一页的标签

    在模板中分别插入如下代码即可 前一页 {:fun('content@prev',$info,20)} 后一页 {:fun('content@next',$info,20)} 复制 其中20代表取标题多 ...