Python 中 concurrent.futures 模块的综合指南,比较 ThreadPoolExecutor 和 ProcessPoolExecutor 以实现并行任务执行,并提供实用示例。
解锁 Python 中的并发:ThreadPoolExecutor 与 ProcessPoolExecutor
Python 是一种通用且广泛使用的编程语言,但在真正的并行性方面存在一定的局限性,这是因为存在全局解释器锁 (GIL)。concurrent.futures
模块为异步执行可调用对象提供了一个高级接口,提供了一种规避其中一些限制并提高特定类型任务性能的方法。该模块提供了两个关键类:ThreadPoolExecutor
和 ProcessPoolExecutor
。本综合指南将探讨这两者,重点介绍它们的差异、优势和劣势,并提供实用示例来帮助您为您的需求选择合适的执行器。
理解并发与并行
在深入了解每个执行器的细节之前,至关重要的是要理解并发和并行的概念。这些术语经常互换使用,但它们具有不同的含义:
- 并发: 处理同时管理多个任务。它涉及构建您的代码以处理看似同时发生的多个事情,即使它们实际上是在单个处理器核心上交错进行的。 可以将其视为厨师管理单个炉子上的几个锅——它们并非都在完全同一时刻沸腾,但厨师正在管理所有这些锅。
- 并行: 涉及同时实际执行多个任务,通常是通过利用多个处理器核心。 这就像有多个厨师,每个厨师同时处理膳食的不同部分。
当使用线程时,Python 的 GIL 在很大程度上阻止了 CPU 密集型任务的真正并行性。这是因为 GIL 只允许一个线程在任何给定时间控制 Python 解释器。但是,对于 I/O 密集型任务,其中程序的大部分时间都花在等待外部操作(如网络请求或磁盘读取)上,线程仍然可以通过允许其他线程在等待时运行来提供显着的性能改进。
介绍 `concurrent.futures` 模块
concurrent.futures
模块简化了异步执行任务的过程。它提供了一个用于处理线程和进程的高级接口,抽象出了直接管理它们的许多复杂性。核心概念是“执行器”,它管理提交任务的执行。两个主要的执行器是:
ThreadPoolExecutor
: 利用线程池来执行任务。适用于 I/O 密集型任务。ProcessPoolExecutor
: 利用进程池来执行任务。适用于 CPU 密集型任务。
ThreadPoolExecutor:利用线程处理 I/O 密集型任务
ThreadPoolExecutor
创建一个工作线程池来执行任务。由于 GIL 的存在,线程并不适合从真正并行性中受益的计算密集型操作。但是,它们在 I/O 密集型场景中表现出色。让我们探讨如何使用它:
基本用法
以下是使用 ThreadPoolExecutor
并发下载多个网页的简单示例:
import concurrent.futures
import requests
import time
urls = [
"https://www.example.com",
"https://www.google.com",
"https://www.wikipedia.org",
"https://www.python.org"
]
def download_page(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
print(f"Downloaded {url}: {len(response.content)} bytes")
return len(response.content)
except requests.exceptions.RequestException as e:
print(f"Error downloading {url}: {e}")
return 0
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
# Submit each URL to the executor
futures = [executor.submit(download_page, url) for url in urls]
# Wait for all tasks to complete
total_bytes = sum(future.result() for future in concurrent.futures.as_completed(futures))
print(f"Total bytes downloaded: {total_bytes}")
print(f"Time taken: {time.time() - start_time:.2f} seconds")
说明:
- 我们导入必要的模块:
concurrent.futures
、requests
和time
。 - 我们定义要下载的 URL 列表。
download_page
函数检索给定 URL 的内容。使用 `try...except` 和 `response.raise_for_status()` 包括错误处理,以捕获潜在的网络问题。- 我们创建一个最多 4 个工作线程的
ThreadPoolExecutor
。max_workers
参数控制可以并发使用的最大线程数。将其设置得太高可能并不总是能提高性能,尤其是在网络带宽通常是瓶颈的 I/O 绑定任务上。 - 我们使用列表推导式使用
executor.submit(download_page, url)
将每个 URL 提交给执行器。这将为每个任务返回一个Future
对象。 concurrent.futures.as_completed(futures)
函数返回一个迭代器,该迭代器在 future 完成时产生 future。这避免了在处理结果之前等待所有任务完成。- 我们迭代已完成的 future,并使用
future.result()
检索每个任务的结果,从而对下载的总字节数进行求和。`download_page` 中的错误处理可确保个别故障不会使整个过程崩溃。 - 最后,我们打印下载的总字节数和所花费的时间。
ThreadPoolExecutor 的优点
- 简化的并发: 提供了一个干净且易于使用的界面来管理线程。
- I/O 绑定性能: 非常适合花费大量时间等待 I/O 操作的任务,例如网络请求、文件读取或数据库查询。
- 减少开销: 与进程相比,线程通常具有较低的开销,这使得它们对于涉及频繁上下文切换的任务来说更有效。
ThreadPoolExecutor 的局限性
- GIL 限制: GIL 限制了 CPU 密集型任务的真正并行性。一次只能有一个线程执行 Python 字节码,从而否定了多个内核的优势。
- 调试复杂性: 由于竞争条件和其他与并发相关的问题,调试多线程应用程序可能具有挑战性。
ProcessPoolExecutor:释放多进程处理 CPU 密集型任务
ProcessPoolExecutor
通过创建工作进程池来克服 GIL 限制。每个进程都有自己的 Python 解释器和内存空间,从而可以在多核系统上实现真正的并行性。这使其非常适合涉及大量计算的 CPU 密集型任务。
基本用法
考虑一个计算密集型任务,例如计算大范围内数字的平方和。以下是如何使用 ProcessPoolExecutor
并行化此任务:
import concurrent.futures
import time
import os
def sum_of_squares(start, end):
pid = os.getpid()
print(f"Process ID: {pid}, Calculating sum of squares from {start} to {end}")
total = 0
for i in range(start, end + 1):
total += i * i
return total
if __name__ == "__main__": #Important for avoiding recursive spawning in some environments
start_time = time.time()
range_size = 1000000
num_processes = 4
ranges = [(i * range_size + 1, (i + 1) * range_size) for i in range(num_processes)]
with concurrent.futures.ProcessPoolExecutor(max_workers=num_processes) as executor:
futures = [executor.submit(sum_of_squares, start, end) for start, end in ranges]
results = [future.result() for future in concurrent.futures.as_completed(futures)]
total_sum = sum(results)
print(f"Total sum of squares: {total_sum}")
print(f"Time taken: {time.time() - start_time:.2f} seconds")
说明:
- 我们定义一个函数
sum_of_squares
,该函数计算给定范围的数字的平方和。我们包括 `os.getpid()` 以查看哪个进程正在执行每个范围。 - 我们定义范围大小和要使用的进程数。创建
ranges
列表是为了将总计算范围划分为较小的块,每个进程一个块。 - 我们创建一个具有指定数量的工作进程的
ProcessPoolExecutor
。 - 我们使用
executor.submit(sum_of_squares, start, end)
将每个范围提交给执行器。 - 我们使用
future.result()
从每个 future 收集结果。 - 我们将来自所有进程的结果相加,得到最终总计。
重要提示: 使用 ProcessPoolExecutor
时,尤其是在 Windows 上,您应该将创建执行器的代码括在 if __name__ == "__main__":
块中。这样可以防止递归进程生成,从而导致错误和意外行为。这是因为该模块在每个子进程中都会重新导入。
ProcessPoolExecutor 的优点
- 真正的并行性: 克服了 GIL 限制,从而可以在多核系统上为 CPU 密集型任务实现真正的并行性。
- CPU 密集型任务的性能改进: 对于计算密集型操作,可以实现显着的性能提升。
- 健壮性: 如果一个进程崩溃,它不一定会导致整个程序崩溃,因为进程彼此隔离。
ProcessPoolExecutor 的局限性
- 更高的开销: 与线程相比,创建和管理进程具有更高的开销。
- 进程间通信: 进程之间共享数据可能更复杂,并且需要进程间通信 (IPC) 机制,这会增加开销。
- 内存占用: 每个进程都有自己的内存空间,这会增加应用程序的总体内存占用。在进程之间传递大量数据可能会成为瓶颈。
选择正确的执行器:ThreadPoolExecutor 与 ProcessPoolExecutor
在 ThreadPoolExecutor
和 ProcessPoolExecutor
之间进行选择的关键在于了解任务的性质:
- I/O 密集型任务: 如果您的任务大部分时间都花在等待 I/O 操作(例如,网络请求、文件读取、数据库查询)上,则
ThreadPoolExecutor
通常是更好的选择。在这些情况下,GIL 的限制较小,并且线程的较低开销使其效率更高。 - CPU 密集型任务: 如果您的任务是计算密集型的并且利用多个内核,那么
ProcessPoolExecutor
是不二之选。它绕过了 GIL 限制并允许真正的并行性,从而显着提高了性能。
以下是一个总结主要区别的表格:
特征 | ThreadPoolExecutor | ProcessPoolExecutor |
---|---|---|
并发模型 | 多线程 | 多进程 |
GIL 影响 | 受 GIL 限制 | 绕过 GIL |
适用于 | I/O 密集型任务 | CPU 密集型任务 |
开销 | 较低 | 较高 |
内存占用 | 较低 | 较高 |
进程间通信 | 不需要(线程共享内存) | 共享数据需要 |
健壮性 | 健壮性较差(崩溃会影响整个进程) | 健壮性较好(进程相互隔离) |
高级技术与注意事项
使用参数提交任务
两个执行器都允许您将参数传递给正在执行的函数。这是通过 submit()
方法完成的:
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(my_function, arg1, arg2)
result = future.result()
处理异常
在执行的函数中引发的异常不会自动传播到主线程或进程。您需要在检索 Future
的结果时显式处理它们:
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(my_function)
try:
result = future.result()
except Exception as e:
print(f"An exception occurred: {e}")
使用 `map` 处理简单任务
对于您想要将相同函数应用于一系列输入的简单任务,map()
方法提供了一种简洁的提交任务的方式:
def square(x):
return x * x
with concurrent.futures.ProcessPoolExecutor() as executor:
numbers = [1, 2, 3, 4, 5]
results = executor.map(square, numbers)
print(list(results))
控制工作进程数
ThreadPoolExecutor
和 ProcessPoolExecutor
中的 max_workers
参数控制可以并发使用的最大线程数或进程数。为 max_workers
选择正确的值对于性能非常重要。一个好的起点是系统上可用的 CPU 核心数。但是,对于 I/O 密集型任务,您可能会受益于使用比核心更多的线程,因为线程可以在等待 I/O 时切换到其他任务。通常需要进行实验和分析才能确定最佳值。
监控进度
concurrent.futures
模块不提供用于直接监控任务进度的内置机制。但是,您可以使用回调或共享变量来实现自己的进度跟踪。可以集成 `tqdm` 等库来显示进度条。
实际示例
让我们考虑一些可以有效应用 ThreadPoolExecutor
和 ProcessPoolExecutor
的实际场景:
- 网络抓取: 使用
ThreadPoolExecutor
并发下载和解析多个网页。每个线程都可以处理不同的网页,从而提高整体抓取速度。请注意网站服务条款,避免使他们的服务器过载。 - 图像处理: 使用
ProcessPoolExecutor
将图像滤镜或变换应用于大量图像。每个进程都可以处理不同的图像,从而利用多个内核来加快处理速度。考虑使用 OpenCV 等库来实现高效的图像操作。 - 数据分析: 使用
ProcessPoolExecutor
对大型数据集执行复杂计算。每个进程都可以分析数据的子集,从而减少总体分析时间。Pandas 和 NumPy 是 Python 中用于数据分析的常用库。 - 机器学习: 使用
ProcessPoolExecutor
训练机器学习模型。一些机器学习算法可以有效地并行化,从而缩短训练时间。scikit-learn 和 TensorFlow 等库提供对并行化的支持。 - 视频编码: 使用
ProcessPoolExecutor
将视频文件转换为不同的格式。每个进程都可以编码不同的视频片段,从而加快整体编码过程。
全局注意事项
为全球受众开发并发应用程序时,务必考虑以下事项:
- 时区: 在处理时间敏感操作时,请注意时区。使用
pytz
等库来处理时区转换。 - 区域设置: 确保您的应用程序正确处理不同的区域设置。使用
locale
等库根据用户的区域设置格式化数字、日期和货币。 - 字符编码: 使用 Unicode (UTF-8) 作为默认字符编码来支持多种语言。
- 国际化 (i18n) 和本地化 (l10n): 设计您的应用程序以便于国际化和本地化。使用 gettext 或其他翻译库为不同的语言提供翻译。
- 网络延迟: 在与远程服务通信时,请考虑网络延迟。实施适当的超时和错误处理,以确保您的应用程序能够应对网络问题。服务器的地理位置可能会大大影响延迟。考虑使用内容交付网络 (CDN) 来提高不同地区用户的性能。
结论
concurrent.futures
模块提供了一种强大而便捷的方式,可将并发性和并行性引入您的 Python 应用程序。通过了解 ThreadPoolExecutor
和 ProcessPoolExecutor
之间的差异,并通过仔细考虑任务的性质,您可以显着提高代码的性能和响应能力。请记住分析您的代码并尝试不同的配置,以找到适合您的特定用例的最佳设置。此外,还要注意 GIL 的限制以及多线程和多进程编程的潜在复杂性。通过仔细的计划和实施,您可以释放 Python 中并发性的全部潜力,并为全球受众创建健壮且可扩展的应用程序。