Python——使用多进程并行化CPU受限任务

作者: Eli Bendersky

原文链接:https://eli.thegreenplace.net/2012/01/16/python-parallelizing-cpu-bound-tasks-with-multiprocessing

更新(2017-01-31):本文的同时在Python 23上工作的完整代码样例已经发布到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个线程,然后等待它们完成。

我运行了顺序基准,对比248线程的多线程因式分解器。这个基准是因式分解大数的一组常量集合,最小化由于随机选择带来的差异。所有的测试运行在我的Ubuntu 10.04手提电脑上,CPUIntel Core i7-2820MQ4个物理核,超线程)。

下面是结果:

水平轴是时间(秒),因此越短的条意味着越快的执行。是的,将计算分解到几个线程实际上比顺序实现还要慢,使用的线程越多,得到的速度越慢。

如果你不熟悉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那样容易。在同步进程间对象时,比线程间同步要多做点工作,但其他代码非常类似。如果你问我,更复杂的对象同步是一件好事,因为共享的对象越少越好。这是为什么多进程编程通常被认为比多线程编程更安全、更不容易出错的主要原因。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值