python并发与并行(十二) ———— 考虑用concurrent.futures实现真正的并行计算

本文主要是介绍python并发与并行(十二) ———— 考虑用concurrent.futures实现真正的并行计算,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

有些Python程序写到一定阶段,性能就再也上不去了。即便优化了代码,程序的执行速度可能还是达不到要求。考虑到现在的计算机所装配的CPU核心数量越来越多,所以我们很自然地就想到用并行方式来解决这个问题。那么接下来就必须思考,如何将代码所要执行的计算任务划分成多个独立的部分并在各自的核心上面平行地运行。

Python的全局解释器锁(global interpreter lock,GIL)导致我们没办法用线程来实现真正的并行,所以先把这种方案排除掉。另一种常见的方案,是把那些对性能要求比较高的(performance-critical)代码用C语言重写成扩展模块。C语言比Python更接近底层硬件,因此运行速度要比Python快,这样的话,有些任务可能根本就不需要做并行,而是单单用C语言重写一遍就好。另外,C扩展还可以启动原生线程(native thread),这种线程不受Python解释器制约,也不必考虑GIL的问题,它们能够平行地运行,从而发挥出多核CPU的优势。Python里面针对C扩展而设计的那些API,有详细的文档可以参考,所以这是个很好的备选方案。大家在开发扩展模块的时候,还可以借助SWIG(https://github.com/swig/swig) 与CLIF(https://github.com/google/clif) 等工具。

然而,用C语言重写Python代码,代价是比较高的。因为有些代码在Python之中很简洁,但是改写成C代码之后,就变得特别难懂、特别复杂了。在移植过程中,我们还必须做大量的测试,以确保移植过去的那些代码跟原来的Python代码效果相同,并且不会引入bug。有的时候,这些工作确实很有意义,所以Python行业里面出现了大量的C扩展模块,用来迅速执行各种任务,例如文本解析、图像合成、矩阵运算等。另外还有Cython(https://cython.org/) 与Numba(https://numba.pydata.org/) 这样的开源工具帮我们顺利地向C语言移植。

问题是,在大多数情况下,我们不能只把整个程序里的一小段代码移植到C语言,因为程序的性能之所以缓慢,通常是由多个因素造成的,而不是说只要消除了其中某一个主要因素,整个程序的性能就会大幅提升。要想把C语言在底层硬件与线程方面的优势发挥出来,必须把程序里的许多代码都迁移过去,这会让测试量激增,而且容易引入新的bug。所以,还是得想想有没有什么好办法,能够在Python语言自身的范围内,解决这种复杂的并行计算问题。

Python内置的multiprocessing模块提供了多进程机制,这种机制很容易通过内置的concurrent.futures模块来使用,这可能就是我们要找的理想方案。这种方案可以启动许多条子进程(child process),这些进程是独立于主解释器的,它们有各自的解释器与相应的全局解释器锁,因此这些子进程可以平行地运行在CPU的各个核心上面。每条子进程都能够充分利用它所在的这个核心来执行运算。这些子进程都有指向主进程的链接,用来接收所要执行的计算任务并返回结果。

例如,现在要用Python来执行某种计算量很大的工作,而且想把CPU里的各个核心充分利用起来。笔者写了下面这个计算最大公约数的函数,来模拟刚才讲的那种工作。在实际程序中,我们要写的可能是一种运算量更大的算法(例如用纳维-斯托克斯方程(Navier-Stokes equation)研究流体动力学)。

def gcd(pair):a, b = pairlow = min(a, b)for i in range(low, 0, -1):if a % i == 0 and b % i == 0:return iassert False, 'Not reachable'

如果把有待求解最大公约数的那些元组按照先后顺序交给这个函数去执行,那么程序花费的总时间就会随着元组的数量呈正比例上升,因为我们根本就没有做平行计算。

import timedef gcd(pair):a, b = pairlow = min(a, b)for i in range(low, 0, -1):if a % i == 0 and b % i == 0:return iassert False, 'Not reachable'NUMBERS = [(1963309, 2265973), (2030677, 3814172),(1551645, 2229620), (2039045, 2020802),(1823712, 1924928), (2293129, 1020491),(1281238, 2273782), (3823812, 4237281),(3812741, 4729139), (1292391, 2123811),
]def main():start = time.time()results = list(map(gcd, NUMBERS))end = time.time()delta = end - startprint(f'Took {delta:.3f} seconds')if __name__ == '__main__':main()

output:

Took 0.494 seconds

直接把这种代码分给多条Python线程去执行,是不会让程序提速的,因为它们全都受制于同一个Python全局解释器锁(GIL),无法真正平行地运行在各自的CPU核心上面。现在就来演示这一点。笔者使用concurrent.futures模块里面的ThreadPoolExecutor类,并允许它最多可以启用两条工作线程。


from concurrent.futures import ThreadPoolExecutor
import timedef gcd(pair):a, b = pairlow = min(a, b)for i in range(low, 0, -1):if a % i == 0 and b % i == 0:return iassert False, 'Not reachable'NUMBERS = [(1963309, 2265973), (2030677, 3814172),(1551645, 2229620), (2039045, 2020802),(1823712, 1924928), (2293129, 1020491),(1281238, 2273782), (3823812, 4237281),(3812741, 4729139), (1292391, 2123811),
]def main():    start = time.time()pool = ThreadPoolExecutor(max_workers=2)results = list(pool.map(gcd, NUMBERS))end = time.time()delta = end - startprint(f'Took {delta:.3f} seconds')if __name__ == '__main__':main()

output:

Took 0.518 seconds

由于要启动线程池并和它通信,这种写法比单线程版本还慢。
但是请注意,只需要变动一行代码就能让程序出现奇效,也就是把ThreadPoolExecutor改成concurrent.futures模块里的ProcessPoolExecutor。这样一改,程序立刻就快了起来。


from concurrent.futures import ProcessPoolExecutor
import timedef gcd(pair):a, b = pairlow = min(a, b)for i in range(low, 0, -1):if a % i == 0 and b % i == 0:return iassert False, 'Not reachable'NUMBERS = [(1963309, 2265973), (2030677, 3814172),(1551645, 2229620), (2039045, 2020802),(1823712, 1924928), (2293129, 1020491),(1281238, 2273782), (3823812, 4237281),(3812741, 4729139), (1292391, 2123811),
]def main():start = time.time()pool = ProcessPoolExecutor(max_workers=2)  # The one changeresults = list(pool.map(gcd, NUMBERS))end = time.time()delta = end - startprint(f'Took {delta:.3f} seconds')if __name__ == '__main__':main()

output:

Took 0.295 seconds

程序变得比原来快多了。这是为什么呢?因为ProcessPool-Executor类会执行下面这一系列的步骤(当然,这实际上是由multiprocessing模块里的底层机制所推动的)。

1)从包含输入数据的NUMBERS列表里把每个元素取出来,以便交给map。

2)用pickle模块对每个元素做序列化处理,把它转成二进制形式。

3)将序列化之后的数据,从主解释器所在的进程经由本地socket复制到子解释器所在的进程。

4)在子进程里面,用pickle模块对数据做反序列化处理,把它还原成Python对象。

5)引入包含gcd函数。

6)把刚才还原出来的那个对象交给gcd函数去处理,此时,其他子进程也可以把它们各自的那份数据交给它们各自的gcd函数执行。

7)对执行结果做序列化处理,把它转化成二进制形式。

8)将二进制数据通过socket复制到上级进程。

9)在上级进程里面对二进制数据做反序列化处理,把它还原成Python对象。

10)把每条子进程所给出的结果都还原好,最后合并到一个list里面返回。

从开发者这边来看,这个过程似乎很简单,但实际上,multiprocessing模块与ProcessPoolExecutor类要做大量的工作才能实现出这样的并行效果。同样的效果,假如改用其他语言来做,那基本上只需要用一把锁或一项原子操作就能很好地协调多个线程,从而实现并行。但这在Python里面不行,所以我们才考虑通过ProcessPoolExecutor来实现。然而这样做的开销很大,因为它必须在上级进程与子进程之间做全套的序列化与反序列化处理。

这个方案对那种孤立的而且数据利用度较高的任务来说,比较合适。所谓孤立(isolated),这里指每一部分任务都不需要跟程序里的其他部分共用状态信息。所谓数据利用度较高(high-leverage),这里指任务所使用的原始材料以及最终所给出的结果数据量都很小,因此上级进程与子进程之间只需要互传很少的信息就行,然而在把原始材料加工成最终产品的过程中,却需要做大量运算。刚才那个求最大公约数的任务就属于这样的例子,当然还有很多涉及其他数学算法的任务,也是如此。

如果你面对的计算任务不具备刚才那两项特征,那么使用ProcessPoolExecutor所引发的开销可能就会盖过因为并行而带来的好处。在这种情况下,我们可以考虑直接使用multiprocessing所提供的一些其他高级功能,例如共享内存(shared memory)、跨进程的锁(cross-process lock)、队列(queue)以及代理(proxy)等。但是,这些功能都相当复杂,即便两个Python线程之间所要共享的进程只有一条,也是要花很大工夫才能在内存空间里面将这些工具安排到位。假如需要共享的进程有很多条,而且还涉及socket,那么这种代码理解起来会更加困难。

总之,不要刚一上来,就立刻使用跟multiprocessing这个内置模块有关的机制,而是可以先试着用ThreadPoolExecutor来运行这种孤立且数据利用度较高的任务。把这套方案实现出来之后,再考虑向ProcessPoolExecutor方案迁移。如果ProcessPoolExecutor方案也无法满足要求,而且其他办法也全都试遍了,那么最后可以考虑直接使用multiprocessing模块里的高级功能来编写代码。

总结:

▪ 把需要耗费大量CPU资源的计算任务改用C扩展模块来写,或许能够有效提高程序的运行速度,同时又让程序里的其他代码依然能够利用Python语言自身的特性。但是,这样做的开销比较大,而且容易引入bug。
▪ Python自带的multiprocessing模块提供了许多强大的工具,让我们只需要耗费很少的精力,就可以把某些类型的任务平行地放在多个CPU核心上面处理。
▪ 要想发挥出multiprocessing模块的优势,最好是通过concurrent.futures模块及其ProcessPoolExecutor类来编写代码,因为这样做比较简单。
▪ 只有在其他方案全都无效的情况下,才可以考虑直接使用multiprocessing里面的高级功能(那些功能用起来相当复杂)。

这篇关于python并发与并行(十二) ———— 考虑用concurrent.futures实现真正的并行计算的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1116316

相关文章

一文教你Python如何快速精准抓取网页数据

《一文教你Python如何快速精准抓取网页数据》这篇文章主要为大家详细介绍了如何利用Python实现快速精准抓取网页数据,文中的示例代码简洁易懂,具有一定的借鉴价值,有需要的小伙伴可以了解下... 目录1. 准备工作2. 基础爬虫实现3. 高级功能扩展3.1 抓取文章详情3.2 保存数据到文件4. 完整示例

使用Python实现IP地址和端口状态检测与监控

《使用Python实现IP地址和端口状态检测与监控》在网络运维和服务器管理中,IP地址和端口的可用性监控是保障业务连续性的基础需求,本文将带你用Python从零打造一个高可用IP监控系统,感兴趣的小伙... 目录概述:为什么需要IP监控系统使用步骤说明1. 环境准备2. 系统部署3. 核心功能配置系统效果展

基于Python打造一个智能单词管理神器

《基于Python打造一个智能单词管理神器》这篇文章主要为大家详细介绍了如何使用Python打造一个智能单词管理神器,从查询到导出的一站式解决,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1. 项目概述:为什么需要这个工具2. 环境搭建与快速入门2.1 环境要求2.2 首次运行配置3. 核心功能使用指

Python实现微信自动锁定工具

《Python实现微信自动锁定工具》在数字化办公时代,微信已成为职场沟通的重要工具,但临时离开时忘记锁屏可能导致敏感信息泄露,下面我们就来看看如何使用Python打造一个微信自动锁定工具吧... 目录引言:当微信隐私遇到自动化守护效果展示核心功能全景图技术亮点深度解析1. 无操作检测引擎2. 微信路径智能获

Java并发编程之如何优雅关闭钩子Shutdown Hook

《Java并发编程之如何优雅关闭钩子ShutdownHook》这篇文章主要为大家详细介绍了Java如何实现优雅关闭钩子ShutdownHook,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起... 目录关闭钩子简介关闭钩子应用场景数据库连接实战演示使用关闭钩子的注意事项开源框架中的关闭钩子机制1.

Python中pywin32 常用窗口操作的实现

《Python中pywin32常用窗口操作的实现》本文主要介绍了Python中pywin32常用窗口操作的实现,pywin32主要的作用是供Python开发者快速调用WindowsAPI的一个... 目录获取窗口句柄获取最前端窗口句柄获取指定坐标处的窗口根据窗口的完整标题匹配获取句柄根据窗口的类别匹配获取句

利用Python打造一个Excel记账模板

《利用Python打造一个Excel记账模板》这篇文章主要为大家详细介绍了如何使用Python打造一个超实用的Excel记账模板,可以帮助大家高效管理财务,迈向财富自由之路,感兴趣的小伙伴快跟随小编一... 目录设置预算百分比超支标红预警记账模板功能介绍基础记账预算管理可视化分析摸鱼时间理财法碎片时间利用财

在 Spring Boot 中实现异常处理最佳实践

《在SpringBoot中实现异常处理最佳实践》本文介绍如何在SpringBoot中实现异常处理,涵盖核心概念、实现方法、与先前查询的集成、性能分析、常见问题和最佳实践,感兴趣的朋友一起看看吧... 目录一、Spring Boot 异常处理的背景与核心概念1.1 为什么需要异常处理?1.2 Spring B

Python中的Walrus运算符分析示例详解

《Python中的Walrus运算符分析示例详解》Python中的Walrus运算符(:=)是Python3.8引入的一个新特性,允许在表达式中同时赋值和返回值,它的核心作用是减少重复计算,提升代码简... 目录1. 在循环中避免重复计算2. 在条件判断中同时赋值变量3. 在列表推导式或字典推导式中简化逻辑

python处理带有时区的日期和时间数据

《python处理带有时区的日期和时间数据》这篇文章主要为大家详细介绍了如何在Python中使用pytz库处理时区信息,包括获取当前UTC时间,转换为特定时区等,有需要的小伙伴可以参考一下... 目录时区基本信息python datetime使用timezonepandas处理时区数据知识延展时区基本信息