使用并发使 Python 程序运行更快2025年3月17日 | 阅读 12 分钟 在本教程中,我们将学习并发以及如何使用并发来加速 Python 程序。我们将理解并发方法与 asyncio 模块的比较。我们还将讨论什么是并行性以及一些并发方法。 什么是并发?并发是指同时发生。同时发生的事务被称为不同的名称,如线程、任务和进程。然而,在高级层面,它们都指的是一个按顺序运行的指令序列。 可以随时暂停每个进程或想法,允许处理器(无论是 CPU)切换到另一个任务。每个进程的进度都会被保存,以便能够从停止的确切点恢复。 有人可能会问 Python 为什么使用不同的术语来表示相似的概念。然而,仔细观察就会发现,线程、任务和进程在宏观层面上确实有相似之处。在深入研究细节时,每个概念都体现了细微的差别。随着你通过示例的深入,你将更好地理解它们的差异。 现在,让我们关注定义中的“同时”方面。然而,需要注意的是,仔细观察会发现,只有多进程才能真正地并发执行这些思想流。线程和 asyncio 在单个处理器上运行,因此一次只有一个进程在运行。然而,它们巧妙地轮流执行以提高整体性能,即使它们并没有同时执行多个思想流。尽管如此,我们仍然称之为并发。 什么是并行性?Python 中的并行性是指同时执行多个任务或进程的能力。它涉及使用单个机器上的多个处理器或分布在网络上的多个处理器来并行执行任务,从而提高整体处理速度和效率。并行性可以通过多进程或多线程来实现,并且通常用于计算密集型任务,如科学计算、机器学习和数据分析。 Python 使用 multiprocessing 创建新进程。进程本身可以被认为是一个独立的程序,尽管它通常被描述为一组资源,如内存和文件句柄。一种类比是将每个进程视为在自己的独立 Python 解释器中运行。 由于多进程程序中的每个思想流作为一个独立的进程运行,它们可以在不同的核心上运行,从而能够真正地并发执行,这是有利的。尽管这种方法可能存在一些复杂性,但 Python 可以有效地处理它们。 何时使用并发?并发有助于解决两种问题,即 CPU 密集型和 I/O 密集型。 I/O 密集型问题可能导致程序性能 sluggish,因为它通常需要等待来自外部源的输入/输出 (I/O)。当程序与比 CPU 慢的资源交互时,这类问题很常见。 ![]() 让我们看看 I/O 密集型进程和 CPU 密集型进程之间的区别。
如何加速 I/O 密集型程序在本节中,我们将介绍 I/O 密集型程序和常见问题。我们以通过网络下载内容为例。 同步版本我们将从该任务的非并发版本开始。我们将使用 requests 模块。 示例 - 该程序很简洁,正如 download_all_sites_from_network() 函数所示,该函数从 URL 下载内容并显示其大小。值得注意的是,使用了 requests 模块的 Session 对象。虽然可以直接使用 requests 模块的 get() 函数,但创建 Session 对象可以解锁高级网络功能,从而显著提高性能。 该函数 download_all_sites() 建立一个 Session,然后遍历网站列表,逐一下载它们。完成后,它将显示该过程的持续时间,让您可以在以下示例中欣赏并发的程度。 同步版本的优点 此代码版本的主要优点之一是其简单性。开发和故障排除相对容易,并且更具可读性。代码遵循线性的逻辑流程,这使得更容易预测下一步及其预期行为。 同步版本的缺点 同步版本的主要缺点是与其他解决方案相比速度较慢。 虽然速度较慢有时可能是一个小问题,但如果程序运行不频繁且同步版本仅需 2 秒即可完成,则无需添加并发。但是,如果程序经常执行或需要数小时才能完成,那么探索并发就变得至关重要。因此,让我们通过修改此程序来使用 threading。 Threading 版本我们将使用 threading 来编写上述程序。让我们看下面的示例。 此版本利用 threading 来提高多个站点下载速度。download_all_sites 函数现在使用 ThreadPoolExecutor 来创建一个线程池来执行 download_site() 函数。max_workers 参数指定要使用的最大线程数。 get_session() 函数为每个线程创建一个 requests.Session 对象,确保每个线程都有自己的会话。 该程序下载同一网站的 80 个副本,并测量完成任务所需的时间。最后,它打印出下载的站点总数和过程的持续时间。 ThreadPoolExecutor 可以分解为 Thread + Pool + Executor。我们知道线程部分。池创建线程池,每个线程都可以并发工作。Executor 部分负责池中的线程如何运行。它将执行池中的请求。幸运的是,标准库将 ThreadPoolExecutor 作为上下文管理器包含在内。您可以使用语法来处理线程池的创建和释放。 当我们拥有 ThreadPoolExecutor 时,我们可以使用 map() 方法。此方法在列表中的每个站点上执行传递的函数。它的优点在于,该函数由其管理的线程池自动并发执行。 我们示例中的另一个值得注意的修改是,每个线程必须创建一个 requests.Session() 实例。虽然查看 requests 文档可能需要一些时间才能显现,但在审查了这个问题之后,就会发现每个线程都需要一个特殊的会话。 threading 最有趣和最具挑战性的方面之一是,由于操作系统控制着任务何时被中断以及何时开始另一个任务,因此在线程之间共享的任何数据都必须受到保护并使其成为线程安全的。然而,不幸的是,requests.Session() 本身不是线程安全的。 根据数据及其用法,有多种方法可以确保数据访问的线程安全。其中一种策略涉及使用线程安全的数据结构,例如 Python 的 Queue 模块。 另一种可在此场景中采用的可行策略称为线程本地存储。通过使用 threading.local(),可以创建一个对象,该对象看起来像一个全局对象,但对于每个线程来说都是唯一的。此方法通过 thread_local 和 get_session() 在您的示例中得到应用。 threading 模块中提供了 local() 函数来专门解决此问题。虽然它可能看起来不寻常,但您只需要创建此对象的一个实例,而不是为每个线程创建一个实例。该对象本身负责将来自不同线程的数据访问分离到不同的数据中。 调用 get_session() 后,它检索的会话对于当前执行的线程是唯一的。因此,每个线程将在第一次调用 get_session() 时生成一个会话,并在其生命周期内的所有后续调用中继续使用该会话。 使用线程的优点 线程的优点是速度快。当我们运行上述程序时,我们会得到以下输出。 输出 - Downloaded 80 in 0.010993480682373047 seconds 使用线程的缺点 线程之间的交互可能很复杂且难以识别。此类交互可能导致竞态条件,这通常会产生随意、间歇性的错误,这些错误尤其难以定位。如果您不熟悉竞态条件,请阅读下面的部分以获取更多信息。 Asyncio 版本在深入研究 asyncio 之前,让我们先了解 asyncio 的工作原理。其核心是,asyncio 围绕一个 Python 对象——事件循环——进行,该对象管理每个任务的执行方式和时间。事件循环知道每个任务并了解其当前状态。虽然任务可能有几种潜在状态,但让我们暂时考虑一个简化的事件循环,它只有两种状态。就绪状态表示任务已准备好运行并有工作要做,而等待状态表示任务正在等待外部操作(如网络调用)完成。 事件循环选择一个就绪的任务并执行它,直到它完成或进入等待状态。然后,任务将控制权交还给事件循环,事件循环会选择另一个就绪的任务来执行。 在运行的任务将控制权移交给事件循环后,事件循环会将该任务置于就绪列表或等待列表中。然后,它会检查等待列表中的所有任务,以确定它们是否由于 I/O 操作已完成而变为就绪状态。事件循环知道就绪列表中的任务仍然就绪,因为它们尚未运行。 事件循环会遍历等待列表以检查是否有任何任务已准备好运行。毕竟,任务会再次被分类到正确的列表中;事件循环会根据任务的状态选择下一个要运行的任务。在这种情况下,简化的事件循环会选择等待时间最长的任务并运行它。这个过程将一直持续到事件循环完成所有任务的执行。 在 asyncio 中,任务不会在中途被中断,并且仅在它们明确选择这样做时才会放弃控制。这种特性使得共享资源比在 threading 中更容易,因为在 asyncio 中不需要考虑线程安全。 async 和 await async 关键字用于定义协程函数,协程函数是一种可以在等待 I/O 操作完成时暂停和恢复的函数。协程函数返回一个协程对象,该对象表示正在进行的 I/O 操作。 await 关键字用于等待协程完成。当 await 关键字在协程函数内部使用时,该函数将暂停,直到被 await 的协程完成其操作。在此期间,事件循环可以继续运行其他协程。一旦被 await 的协程完成,暂停的函数将使用协程操作的结果恢复。 使用 async 和 await 使开发人员能够编写比传统基于回调的代码更易于阅读和维护的异步代码。管理回调链和错误处理,协程可以更线性地编写,使代码更具可读性且更易于推理。 让我们使用 async 和 await 来实现上面的示例。 示例 - 上面的代码可能看起来比前两个复杂。它具有类似的结构,但在设置任务方面比创建 ThreadPoolExecutor 要多一些工作。 我们可以共享所有任务之间的会话,因此会话在此处作为上下文管理器创建。任务可以共享会话,因为它们都在同一个线程上运行,并且在会话处于不良状态时任务不会被中断。给定的上下文管理器使用 asyncio.ensure_future() 生成一个任务列表,该函数负责启动它们。在生成所有任务后,该函数使用 asyncio.gather() 来维护会话上下文,直到所有任务都执行完毕。 threading 代码执行类似的操作,但使用 ThreadPoolExecutor 进行简化,后者负责处理细节。目前,没有 AsyncioPoolExecutor 类可用。 与 threading 相比,asyncio 在可扩展性方面具有显著优势。创建每个 asyncio 任务所需的资源和时间少于一个线程,因此可以轻松创建和执行多个任务。在此示例中,每个站点都是使用单独的任务下载的,这非常有效。 asyncio 的优点 它比线程版本更快。可扩展性问题也是此场景中的一个重要因素。执行为每个站点指定一个线程的 threading 示例会导致速度明显下降,与仅使用几个线程相比。相反,执行数百个任务的 asyncio 示例不会对其速度产生任何影响。 输出 - Downloaded 80 sites in 1.6838884353637695 seconds asyncio 的缺点 目前,asyncio 有一些问题。要充分利用 asyncio 的优势,您需要特定库的异步版本。如果使用 requests 下载站点,由于 requests 未设计为告知事件循环它被阻塞,因此该过程将显着变慢。然而,随着越来越多的库拥抱 asyncio,这个问题正逐渐变得不那么严重。 多进程版本多进程版本充分利用了多个 CPU。让我们理解下面的例子。 示例 - 虽然下面的代码片段比 asyncio 的代码短,但它与 threading 的示例相似。在检查代码之前,让我们先探讨使用多进程的好处。 标准库中的 multiprocessing 模块旨在通过利用多个 CPU 来消除在单个 CPU 上运行代码的限制。它通过创建 Python 解释器的每个新实例在每个 CPU 上运行,并将您的程序的一部分分配给它来执行。 然而,启动一个新的 Python 解释器是一个耗时的过程,与在当前解释器中创建新线程不同。这是一个资源密集型的操作,会带来一些挑战和限制,但它可以显著提高特定问题的性能。 多进程版本的优点 使用 multiprocessing 模块的这个示例设置起来很方便,并且不需要大量额外的代码。它也非常高效,并充分利用了计算机的 CPU 功率。 多进程版本的缺点 与之前的示例相比,此版本需要一些额外的设置,并且使用全局会话对象可能看起来很奇怪。仔细考虑每个进程将访问哪些变量至关重要,这需要一些思考和规划。 我们可以清楚地看到,它比 asyncio 版本慢。 Downloaded 80 in 11.359532356262207 seconds 结论本教程介绍了 Python 中可用的基本类型的并发 - threading、asyncio 和 multiprocessing。通过从这些示例中获得的知识,您可以就为特定问题选择哪种并发方法或并发是否必要做出明智的决定。此外,您还对使用并发时可能出现的问题有了更深入的理解。 下一主题Python 中的方法重写 |
我们请求您订阅我们的新闻通讯以获取最新更新。