多线程、多进程,还是异步?-- Python 并发 API 如何选择

如何选择正确的 Python 并发 API模块 ?
Python 标准库提供了三种并发 API , 如何知道你的项目应该使用哪个 API?

在本教程将带逐步了解各API的特性、区别以及各自应用场景,指导你选择最合适的并发 API。

多线程、多进程,还是异步?-- Python 并发 API 如何选择

Python 标准库提供了三种在程序中并发执行任务的方法。分别是 :

  • multiprocessing 模块用于基于进程的并发,
  • threading 模块用于基于线程的并发,
  • asyncio 模块用于基于协程的并发。

实际上还有1个选择,在 Python标准库里,基于 threading 与 multiprocessing 提供了1个异步执行多任务的高阶API: concurrent.futures 模块,由于其使用 Pool Executor 执行器API, 因此,本文后面称之为Executors API.

很多人为选择哪个感到头痛:

  • 例如,在选择了一个模块之后,你应该使用工作池还是应该自己编写并发任务的代码?
  • 如果你选择使用工作池,你应该使用 multiprocessor Pools API 还是 Executors API?

即使是经验丰富的 Python 开发人员也对这些选项感到困惑。

你应该为你的项目使用哪个 Python 并发 API?

你需要一些经验法则来指导选择最合适的并发 API。

选择哪个 Python 并发 API?

你想在 Python 程序中使用并发。只有一个问题。你应该使用哪个 API? 这是我见到的最常见的问题之一。

这就是我写这本指南的原因。

首先,有三个主要的 Python 并发 API,它们是:

  • 基于协程,由 “asyncio” 模块提供。
  • 基于线程,由 “threading” 模块提供。
  • 基于进程,由 “multiprocessing” 模块提供。

在这三个之间选择相对直接。我将在下一节中向你展示如何做到这一点。

问题在于,还有更多的决定要做。你需要考虑是否应该使用可重用的工作池。

例如:

  • 如果你决定需要基于线程的并发,你应该使用线程池还是以某种方式使用 Thread 类?
  • 如果你决定需要基于进程的并发,你应该使用进程池还是以某种方式使用 Process 类?

然后,如果你决定使用可重用的工作池进行并发,你有多个选项可供选择。

例如:

  • 如果你决定需要线程池,你应该使用 ThreadPool 类还是 ThreadPoolExecutor
  • 如果你决定需要进程池,你应该使用 Pool 类还是 ProcessPoolExecutor

下图总结了这些决策点。

在这里插入图片描述

你如何弄清楚这一切?

你如何选择适合你项目的 Python 并发 API?

选择 Python 并发 API 的过程

你可以使用的一种方法,也许是最常见的方法,是临时选择一个 API。许多开发决策都是这样做出的,你的程序可能会很好地工作。但也许不会。

我建议在选择适合你项目的 Python 并发 API 时,采用 3 步过程判断:

步骤如下:

  1. 第 1 步:任务是基于CPU 绑定 vs IO 绑定(多进程 vs 线程)
  2. 第 2 步:需要并发执行许多临时任务 vs 只是一个复杂任务?
  3. 第 3 步:用池(Pool) vs 执行器(executor)?

我还把决策分析树绘成了一个方便的图片,如下:

在这里插入图片描述

接下来,让我们仔细看看每个步骤并增加一些细微差别。

第 1 步:CPU 绑定任务 vs IO 绑定任务?

选择要使用的 Python 并发 API 的第一步是考虑你想要执行的任务或任务的限制因素。

任务主要是 CPU 绑定还是 IO 绑定?

如果你正确理解这部分,你需要做的其他决定就变得不那么重要了。

让我们分别仔细看看每个方面。

CPU 绑定任务

CPU 绑定任务是一种涉及执行计算而不涉及 IO 的任务类型。这些操作只涉及内存中的数据,并对该数据执行计算。因此,这些操作的限制是 CPU 的速度。这就是为什么我们称它们为 CPU 绑定任务。

示例包括:

  • 估计圆周率。
  • 分解质数。
  • 解析 HTML、JSON 等文档。
  • 处理文本。
  • 运行模拟。

CPU 非常快,我们通常有一个以上的 多核CPU。我们希望执行我们的任务并充分利用现代硬件中的多个 CPU 核心。

现在我们已经熟悉了 CPU 绑定任务,让我们仔细看看 IO 绑定任务。

IO 绑定任务

IO 绑定任务是一种涉及从设备、文件或套接字连接中读取或写入的任务类型。这些操作涉及输入和输出 (IO),这些操作的速度受到设备、硬盘或网络连接的限制。这就是为什么这些任务被称为 IO 绑定的原因。

CPU 真的很快。现代 CPU,像 4GHz,每秒可以执行 40 亿条指令,你的系统中可能有一个以上的 CPU。与 CPU 的速度相比,做 IO 非常慢。与设备交互、读写文件和套接字连接涉及调用操作系统中的指令(内核),它将等待操作完成。如果这个操作是你的 CPU 的主要焦点,比如在你的 Python 程序的主线程中执行,那么你的 CPU 将等待很多毫秒,甚至很多秒,什么都不做。那是潜在的数十亿次操作,它被阻止执行。

IO 绑定任务的示例包括:

  • 从硬盘读取或写入文件。
  • 读取或写入标准输出、输入或错误(stdin、stdout、stderr)。
  • 打印文档。
  • 下载或上传文件。
  • 查询服务器。
  • 查询数据库。
  • 拍照或录像。
  • 等等。

现在我们已经熟悉了 CPU 绑定和 IO 绑定任务,让我们考虑我们应该使用哪种类型的 Python 并发 API。

选择 Python 并发 API

回想一下,multiprocessing 模块提供基于进程的并发,threading 模块提供进程内的基于线程的并发。

通常,如果你有 CPU 绑定任务,你应该使用基于进程的并发。

如果你有 IO 绑定任务,你应该使用基于线程的并发。

  • CPU 绑定任务:使用 “multiprocessing” 模块进行基于进程的并发。
  • IO 绑定任务:使用 “threading” 模块进行基于线程的并发。

multiprocessing 模块适用于主要关注计算或计算某物的任务,这些任务之间共享的数据相对较少。由于计算开销的增加,以及所有进程之间共享的数据都必须序列化,multiprocessing 不适用于任务之间发送或接收大量数据。

多进程可以让你可以利用每1个 CPU 核心或每个物理 CPU 核心来运行任务,并最大化利用底层硬件资源。

threading 模块适用于主要关注从 IO 设备读取或写入的任务,计算相对较少。由于全局解释器锁 (GIL) 阻止一次执行多个 Python 线程,threading 不适用于执行大量 CPU 计算的任务。GIL 通常只在执行阻塞操作时释放,比如 IO,或者在一些第三方 C 库中特别如此,比如 NumPy。

你可以执行数十、数百或数千个基于线程的任务,因为 IO 大部分时间都在等待。

下图总结了在线程模块的多线程并发和 multiprocessing 模块的进程并发之间进行选择的决策点。
在这里插入图片描述

你可以参考本人关于 threading 和 multiprocessing 的文章:

接下来,让我们考虑 AsyncIO 是否合适。

第 1.1 步 在线程和 AsyncIO 之间进行选择

如果你的任务主要是 IO 绑定的,你有另一个决策点。你必须在 “threading” 模块和 “asyncio” 模块之间进行选择。回想一下,threading 模块提供基于线程的并发,asyncio 模块提供线程内的基于协程的并发。

通常,如果你有很多套接字连接(或者更喜欢异步编程),你应该使用基于协程的并发。否则,你应该使用基于线程的并发。

  • 多个套接字连接:使用 “asyncio” 模块进行基于协程的并发。
  • 否则:使用 “threading” 模块进行基于线程的并发。

asyncio 模块专注于套接字连接的并发非阻塞 IO。例如,如果你的 IO 任务是基于文件的,那么 asyncio 可能不是合适的选择,至少仅因为这一点。

原因是协程比线程更轻量级,因此一个线程可以托管比进程可以管理的线程多得多的协程。例如,asyncio 可能允许成千上万,甚至更多的协程用于基于套接字的 IO,而 threading API 可能只有几百到低数千个线程。

另一个考虑是你可能会想要或需要在开发程序时使用异步编程范式,例如 async/wait。因此,这个要求将覆盖任务所施加的任何要求。同样,你可能对异步编程范式有反感,因此这种偏好将覆盖任务所施加的任何要求。

第 2 步:并发执行许多临时任务 vs 一个复杂任务?

第二步是考虑你是否需要执行独立的临时任务或一个大型复杂任务。

我们在这一点上考虑的是,你是否需要发出一个或多个可能从可重用工作池中受益的临时任务。或者,你是否需要一个单一任务,其中可重用工作池将是多余的。

另一种思考方式是,你是否有一个或几个不同但复杂的任务,如监视器、调度程序或类似的任务,可能会持续很长时间,例如程序的持续时间。这些将不是临时任务,并且可能不会从可重用工作池中受益。

  • 短暂和/或许多临时:使用线程或进程池。
  • 长期和/或复杂任务:使用 ThreadProcess 类。

在你选择基于线程的并发的情况下,选择是在线程池或使用 Thread 类之间。

在你选择基于进程的并发的情况下,选择是在进程池或使用 Process 类之间。

一些额外的考虑包括:

  • 异构 vs 同质任务:池可能更适合一组不同的任务(异构),而 Process/Thread 类适合一种类型任务(同质)。
  • 重用 vs 一次性使用:池适合重用并发的基础,例如重用线程或进程执行多个任务,而 Process/Thread 类适合一次性任务,可能是一个长期任务。
  • 多个任务 vs 单个任务:池自然支持多个任务,可能以多种方式发出,而 Process/Thread 类只支持一种类型的任务,一次配置或覆盖。

让我们通过一些例子来具体化:

  • 一个 for 循环,每次迭代调用一个函数,每次使用不同的参数,可能适合线程池,因为可以自动重用工作线程完成每个任务。
  • 一个监视资源的后台任务可能适合 Thread/Process 类,因为它是一个长期运行的单一任务,可能有很多复杂和专业的功能,可能分散在许多函数调用中。
  • 一个下载许多文件的脚本可能适合工作池,因为每个任务持续时间很短,可能有更多的文件而不是工人,允许重用工人和排队任务以完成。
  • 一个维护内部状态并与主程序交互的一次性任务可能适合 Thread/Process 类,因为类可以被覆盖以使用实例变量进行状态管理,使用方法进行模块化功能。

下图可能有助于在工作池和 ThreadProcess 类之间进行选择。
在这里插入图片描述

第 3 步:池 vs 执行器?

第三步是考虑要使用的工作者池的类型。

有两种主要类型,它们是:

  • multiprocessing.pool.Pool 和支持线程的类的移植 multiprocessing.pool.ThreadPool
  • 执行器concurrent.futures.Executor 类和两个子类 ThreadPoolExecutorProcessPoolExecutor

两者都提供工作者池。相似之处众多,差异很少且微妙。

例如,相似之处包括:

  • 两者都有线程和基于进程的版本。
  • 两者都可以执行临时任务。
  • 两者都支持同步和异步任务执行。
  • 两者都提供检查状态和等待异步任务的支持。
  • 两者都支持异步任务的回调函数。

选择一个而不是另一个对你的程序不会有太大影响。主要区别在于每个提供的 API,特别是在任务处理的重点或方式上的微小差异。

例如:

  • 执行器提供取消已发布任务的能力,而池则没有。
  • 执行器提供处理不同类型任务集合的能力,而池则没有。
  • 执行器没有强制终止所有任务的能力,而池有。
  • 执行器没有提供多个并行版本的 map() 函数,而池有。
  • 执行器提供访问任务中引发的异常的能力,而池则没有。

我认为,关于池(Pool)真正重要是,池专注于使用许多不同版本的 map() 函数进行并发 for 循环,可将函数应用于一个可迭代参数中的每个参数。

执行器具有这种能力,但重点更多地是发布临时任务并异步管理任务集合。

下图有助于总结池和执行器之间的差异。
在这里插入图片描述

结束语

现在你知道如何在不同的 Python 并发 API 之间进行选择。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值