多进程编程是提升计算效率的一种重要方法,关于其介绍可以参见参考内容1。python3提供了multiprocessing
标准库来支持跨平台的多进程编程,非常方便。最近在利用该标准库进行编程,发现在进程间实现变量共享(通信)时,有一些细节问题跟常规理解不太一致,如果不清楚,会在使用时带来不小的麻烦。
1.问题提出
假设有一个长度为L
,每个位置的初始值为0的向量;再给定一个函数f
,该函数的作用是将任意的值(限定为字符型、整型和浮点型)转化为[0,L-1]
之间的整数k
最后,将上述向量在k
位置上的值改写为1(如果已经为1,则不作任何操作)。
2.解决方法
如果不考虑多进程,那么只需要遍历给定集合中的每个值,并利用f
进行计算,然后找到L
中的对应位置,将值改写为1即可。写成伪代码就是:
给定集合Value,L
for i in Value:
loc=f(i)
if L[loc]=0:
L[loc]=1
else:
continue
但这有一个问题,如果给定的集合非常大,比如说有1亿个数字,那么上述操作可能会耗时较大。注意到:
对集合中的每个元素进行函数操作,并将向量对应位置上的值改为1
这两个步骤是独立于集合中的其他元素的。也就是说,在对集合中的第一个值进行上述操作时,也可以同步对第二个值进行上述操作,它们之间互不影响。因此,可以利用多进程进行加速。
3.多进程加速
要想利用多进程加速,一个很自然的想法就是,主进程中定义了原始向量,然后每个子进程获取到这个原始向量、函数f
以及原始数据集合的一个子集,然后开始运算。这里有一个问题:如何保证经过各个进程计算之后的向量的变化正确地同步到原始向量中去了呢?
具体来说,假设原始数据集合共100个值,子进程的数量选择4个。平均来讲,每个进程可以拿到25个值、一个向量和函数f
。在每个进程内部,完成运算后,改变的是该进程中的向量,这里需要一种机制,让这种改变也同步到主进程中的向量中去。只有这样,才可以收集到每个进程中的运算结果。
用专业的话来说,这叫做进程间的通信问题。
就上述例子而言,我们需要一个特殊的向量,这个向量可以在各个进程之间“穿梭”并记录每个进程的计算结果。multiprocessing
模块提供了这样的方法:
import multiprocessing
def some_calculation(ml, i):
ml.append(i)
if __name__ == '__main__':
m = multiprocessing.Manager()
ml = m.list()
print('Before: ', ml)
for i in range(4):
p = multiprocessing.Process(target=some_calculation, args=(ml, i, ))
p.start()
p.join()
print('After : ', ml)
执行上述代码,其输出结果如下:
Before: []
After : [0, 1, 2, 3]
multiprocessing.Manager()
类提供了list
方法来创建特殊的列表,其特殊性就体现在该列表可以在各个进程之间“穿梭”,从而记录每个进程的结果,并汇总到总进程中。
还可以验证如果采用普通的列表对象,即用ml = []
替换上述代码中的ml = m.list()
,是不会取得上述效果的。
关于Manager
类的其他方法,可以参考官方文档。
4.通信对象的迭代问题
这一小节的题目可能不太好理解,先看问题:
import multiprocessing
def some_calculation(ml):
ml.append(2)
ml[1].append(2)
ml[0] += 1
if __name__ == '__main__':
m = multiprocessing.Manager()
ml = m.list([1, [1]])
print('Before: ', ml)
p = multiprocessing.Process(target=some_calculation, args=(ml,))
p.start()
p.join()
print('After : ', ml)
实际的输出是什么呢?可能会跟想象的不太一样:
Before: [1, [1]]
After : [2, [1], 2]
我们想要做的事情其实很直观,对于一个初始的向量[1, [1]]
,首相将其转化为可以在进程间“穿梭”的特殊对象,然后放入子进程去计算。计算的内容也非常简单:1、在向量后面追加一个值2
;2、在向量索引为1处的值(是一个列表)后面追加一个值2
;3、将向量索引为0出的值加1
。
从结果来看,第一步和第三步成功实现,第二步却没有实现。这是什么原因呢?
原来,Manager
的实例只会管理那些直接被管理的对象,如果该对象中存在其他的可变对象,那么是并不会被纳入管理范畴的。这个答案及下面的解决方案来自参考内容2。
要想解决,只需要将some_calculation
稍加修改:
def some_calculation(ml):
ml.append(2)
l = ml[1]
l.append(2)
ml[1] = l
ml[0] += 1
即,先将可变对象另外赋值到一个变量,然后操作该变量,最后将操作后的变量写入到被管理的对象中即可。
5.额外的问题
这一部分要记录一个关于利用多进程修改布隆过滤器时的问题。如果你不了解什么是布隆过滤器,可以参看这篇文章。实际上一开始给出的例子就可以理解为这个问题的简化版本。
假设我有10000个数据需要插入到布隆过滤器中。按照上面的思路,加入一共创建4个子进程,那么对于每一个子进程而言,需要处理的数据为2500个。由于Manager
类并没有提供直接管理布隆过滤器对象的方法,所以,我们需要将布隆过滤器放入一个Manager().list()
中,在计算时取出这个布隆过滤器,计算完成后将布隆过滤器重写放入Manager().list()
内。代码如下:
# generate.py
import multiprocessing
from pybloom import BloomFilter
import pickle
def do_calculation(ml, data):
bf = ml[0]
for i in data:
bf.add(i)
ml[0] = bf
if __name__ == '__main__':
size = 2000
num_process = 4
batch = int(size / num_process)
m = multiprocessing.Manager()
ml = m.list([BloomFilter(size)])
data = list(range(size))
p = multiprocessing.Pool(num_process)
for i in range(num_process):
sub_data = data[i*batch: (i+1)*batch]
p.apply_async(func=do_calculation, args=(ml, sub_data,))
p.close()
p.join()
bf = ml[0]
with open('./bf', 'wb') as f:
pickle.dump(bf, f)
按照最初的设想,每个进程会处理500个数据,然后我们从主进程中获取到最后的布隆过滤器对象,并将其序列化至磁盘上。
接下来运行以下代码:
# test.py
import pickle
with open('bf', 'rb') as f:
bf = pickle.load(f)
if __name__ == '__main__':
count = 0
for i in range(2000):
if i not in bf:
count += 1
print(count)
其作用是将过滤器对象加载到内存中,然后进行检验。理想状况下,屏幕打印的值为0,这说明在生成过滤器对象时,每个子进程都完成了计算,并且将结果同步到了主进程中。但事实上,如果我们运行多次generate.py
,并相应的运行test.py
,我们会发现打印的值不一样!而且打印的数字要么是0,要么是500的整数倍。因此,我猜测某个(些)子进程的运算结果并没有同步到主进程。
依赖于BloomFilter
对象的union
方法,我们可以用以下代码来实现分布式运算:
import multiprocessing
from pybloom import BloomFilter
import time
import pickle
capacity = 500000
def do_calculation(data):
bf = BloomFilter(capacity=capacity)
for i in data:
bf.add(i)
return bf
if __name__ == '__main__':
MULTI = 1
t1 = time.time()
size = capacity
ori_data = list(range(size))
if not MULTI:
bf = do_calculation(ori_data)
else:
inputs = []
pool_size = int(multiprocessing.cpu_count())
batch = int(size/pool_size)
for i in range(pool_size):
inputs.append(ori_data[i*batch: (i+1)*batch])
pool = multiprocessing.Pool(
processes=pool_size
)
pool_outputs = pool.map(do_calculation, inputs)
pool.close()
pool.join()
bf = BloomFilter(capacity=size)
for b in pool_outputs:
bf = bf.union(b)
with open('multi_bf', 'wb') as f:
pickle.dump(bf, f)
print('cost:', time.time() - t1, 's')
即,每个子进程都返回一个独立的过滤器对象,然后将这些对象取并集。