作者: Eli Bendersky
原文链接:https://eli.thegreenplace.net/2012/01/16/python-parallelizing-cpu-bound-tasks-with-multiprocessing
更新(2017-01-31):本文的同时在Python 2与3上工作的完整代码样例已经发布到GitHub上;它还处理了某些人遭遇的、平台特定的尴尬问题(pickling issue)。
在之前关于Python线程的博文中,我简短地提到线程不适用于CPU受限任务,而是应该使用多进程。这里,我希望使用基准数据(benchmark number)来展示这,并说明在Python中创建多个进程与创建多个线程一样简单。
首先,让我们选择一个简单的计算用于该基准。我不希望它是完全人为的,因此我将使用因式分解的一个简化版本——将一个数字分解为其质因数。下面是一个接受一个数字并返回一组因数的非常幼稚的、未优化的函数:
def factorize_naive(n):
""" A naive factorization method. Take integer 'n', return list of
factors.
"""
if n < 2:
return []
factors = []
p = 2
while True:
if n == 1:
return factors
r = n % p
if r == 0:
factors.append(p)
n = n / p
elif p * p >= n:
factors.append(n)
return factors
elif p > 2:
# Advance in steps of 2 over odd numbers
p += 2
else:
# If p == 2, get to 3
p += 1
assert False, "unreachable"
现在,作为基准的基础,我将使用以下的顺序(单线程)因式分解器,它接受一组要因式分解的数字,返回一个将数字映射到其因数列表的字典:
def serial_factorizer(nums):
return {n: factorize_naive(n) for n in nums}
接下来是线程化版本。它也接受一组要因式分解的数字,以及要创建的线程数。然后它把该列表分成块,将每块分配给一个独立的线程:
def threaded_factorizer(nums, nthreads):
def worker(nums, outdict):
""" The worker function, invoked in a thread. 'nums' is a
list of numbers to factor. The results are placed in
outdict.
"""
for n in nums:
outdict[n] = factorize_naive(n)
# Each thread will get 'chunksize' nums and its own output dict
chunksize = int(math.ceil(len(nums) / float(nthreads)))
threads = []
outs = [{} for i in range(nthreads)]
for i in range(nthreads):
# Create each thread, passing it its chunk of numbers to factor
# and output dict.
t = threading.Thread(
target=worker,
args=(nums[chunksize * i:chunksize * (i + 1)],
outs[i]))
threads.append(t)
t.start()
# Wait for all threads to finish
for t in threads:
t.join()
# Merge all partial output dicts into a single dict and return it
return {k: v for out_d in outs for k, v in out_d.iteritems()}
注意主线程与工作者线程间的接口非常简单。每个工作者线程有一定量的工作要完成,之后它就返回了。因此,主线程要做的事就是使用合适的实参发动nthreads个线程,然后等待它们完成。
我运行了顺序基准,对比2、4及8线程的多线程因式分解器。这个基准是因式分解大数的一组常量集合,最小化由于随机选择带来的差异。所有的测试运行在我的Ubuntu 10.04手提电脑上,CPU是Intel Core i7-2820MQ(4个物理核,超线程)。
下面是结果:
水平轴是时间(秒),因此越短的条意味着越快的执行。是的,将计算分解到几个线程实际上比顺序实现还要慢,使用的线程越多,得到的速度越慢。
如果你不熟悉Python线程实现方式以及GIL(全局解析器锁),这可能令你有点吃惊。为了理解为什么这会发生,没有比读Dave Beazley关于这个话题的文章与演示更好的途径了。他的工作是如此全面且容易理解,我没有必要在这里重复(除了结论)。
现在让我们做一样的事情,只是使用进程代替线程。Python优秀的multiprocessing模块使得进程的启动与管理与线程一样简单。事实上,它提供了非常类似于threading模块的API。下面是多进程因式分解器:
def mp_factorizer(nums, nprocs):
def worker(nums, out_q):
""" The worker function, invoked in a process. 'nums' is a
list of numbers to factor. The results are placed in
a dictionary that's pushed to a queue.
"""
outdict = {}
for n in nums:
outdict[n] = factorize_naive(n)
out_q.put(outdict)
# Each process will get 'chunksize' nums and a queue to put his out
# dict into
out_q = Queue()
chunksize = int(math.ceil(len(nums) / float(nprocs)))
procs = []
for i in range(nprocs):
p = multiprocessing.Process(
target=worker,
args=(nums[chunksize * i:chunksize * (i + 1)],
out_q))
procs.append(p)
p.start()
# Collect all results into a single result dict. We know how many dicts
# with results to expect.
resultdict = {}
for i in range(nprocs):
resultdict.update(out_q.get())
# Wait for all worker processes to finish
for p in procs:
p.join()
return resultdict
对比线程的解决方案,这里仅有的差别是输出从工作者传递回主线程/进程的方式。使用multiprocessing,我们不能简单地把一个字典传递给子进程,并期望其改动在另一个进程里可见。有几个方法解决这个问题。一个是使用来自multiprocessing.managers.SyncManager的同步字典。我选择创建一个Queue,让每个工作者进程放入结果字典。然后mp_factorizer将所有结果收集到一个字典中,接着join进程(注意,如在multiprocessing文档里提及的,应该在进程写入Queue里的所有结果被消费完后,才调用join)。
我运行了相同的基准,在图表中加入了mp_factorizer的运行时间:
正如你看到的,存在良好的加速。最快的多进程版本(分解为8个进程)比顺序版本快3.1倍。尽管我的CPU只有4个物理核(每个核里的硬件“线程”对共享许多执行资源),8进程版本运行得更快,这可能是由于OS没有在“重负荷”任务间最优分配CPU。加速离4x有点远的另一个原因是,工作没有在子进程间平均分配。某些数字的因式分解显著比其他数字快,目前没有关注工作者之间任务的负载均衡。
这些都是值得探讨的有趣话题,但超出了本文的范畴。对于我们的需要,最好的建议是运行基准,根据结果确定最好的并行策略。
本文的目标是两方面的。其一,提供Python线程对加速CPU受限计算如何不利的一个简单演示(它们确实对减慢这些计算相当在行),而multiprocessing如预期,确实以一个并行的方式使用多核CPU。其二,展示multiprocessing制作并行代码像使用threading那样容易。在同步进程间对象时,比线程间同步要多做点工作,但其他代码非常类似。如果你问我,更复杂的对象同步是一件好事,因为共享的对象越少越好。这是为什么多进程编程通常被认为比多线程编程更安全、更不容易出错的主要原因。