如何绕过 GIL 进行并行处理

2024 年 8 月 29 日 | 阅读 12 分钟

在本教程中,我们将通过绕过GIL来了解Python中的并行处理。GIL是Python中的一个重要概念,它阻止了在同一进程中多个线程并行执行Python字节码。这意味着即使在多核处理器上,由于GIL的存在,Python线程也无法完全利用所有可用的CPU核心进行CPU密集型任务。然而,它也有一些局限性。我们还将讨论如何在多个CPU核心上并行运行Python线程,以及如何避免多进程的数据序列化开销。在深入探讨此主题之前,让我们回顾一下并行处理的概念。

并行处理简介

Python中的并行处理是指同时执行多个任务或进程,以提高性能并利用多核处理器。它允许您并发执行多个计算,这可以显著减少完成CPU密集型或耗时任务所需的时间。

各种因素可能会限制并发任务的进展速度。在确定并行处理是否适合您的需求以及如何有效利用它之前,识别这些限制至关重要。

任务执行繁重计算的速度主要由CPU的时钟频率决定。此时钟频率直接与单位时间内完成的机器码指令数量相关。简单来说,处理器运行得越快,它在相同时间内可以完成的工作就越多。当任务的性能受处理器能力的限制时,我们称该任务为CPU密集型任务。

当专门处理CPU密集型任务时,您可以通过在多个处理器核心上并发执行它们来提高性能。然而,这种方法有其局限性。超过某个点,您的任务将争夺有限的可用资源,并且与上下文切换相关的开销将变得有问题。为防止性能下降,建议并发运行的CPU密集型任务数量不要超过可用CPU核心的数量。

想象一个I/O密集型任务,就像和一位对手下棋。你需要走一步,然后让对方也走一步。在他们思考的时间里,你可以选择耐心等待,或者有效利用这段时间。例如,你可以和另一个玩家继续下棋,或者接听一个紧急电话。

因此,I/O密集型任务无需并发执行即可并行运行。这个特性消除了同时任务数量的限制。与受限于物理CPU核心数量的CPU密集型任务不同,I/O密集型任务不受此类限制。您的应用程序可以容纳尽可能多的I/O密集型任务,只要您的可用内存允许。遇到数百甚至数千个此类任务并不少见。

利用多CPU核心的潜力

有几种方法可以展现现代CPU的并行处理能力,每种方法都有其自身的权衡。例如,您可以选择在不同的系统进程中执行部分代码。这种方法提供了强大的资源隔离和数据一致性,尽管缺点是需要昂贵的数据序列化。

进程相对不那么复杂,因为它们通常需要最少的协调或同步。然而,它们的创建和进程间通信(IPC)成本相对较高,因此您应该限制创建的数量以避免收益递减。建议避免在进程之间传输大量数据,因为在这种情况下序列化开销可能会超过并行化的优势。

为了高效执行大量并发任务,您可以使用协程。这些是比线程更轻量级的执行单元。与线程和进程不同,它们通过协作式多任务处理操作,在特定点自愿暂停执行,而不是依赖抢占式任务调度器。这种方法有其自身的优点和缺点。

比较Python和其他语言中的多线程

多线程通常涉及将任务划分,在可用CPU之间共享工作负载,管理单个工作器,确保它们安全地访问共享资源,并组合它们的局部结果。然而,为了本例的说明,我们不会执行这些步骤。为了突出Python中线程的问题,我们将简单地在所有CPU核心上同时调用同一个函数,而不关注返回值。

使用Java线程解决I/O问题

Java线程可用于解决CPU密集型和I/O密集型问题。让我们通过每个示例来说明这一点。

  • 使用Java线程解决CPU密集型问题

CPU密集型问题涉及计算密集型任务。Java线程可以通过在多个CPU核心上并行化这些任务来提供帮助。下面是一个使用Java线程计算大型数字数组和的简单示例。

示例 -

在此示例中,我们将数组分成块并将每个块分配给一个单独的线程。每个线程计算其分配块的部分和,然后将结果组合以获得总和。

  • 使用Java线程解决I/O密集型问题

对于I/O密集型问题,例如同时发出多个网络请求,Java线程也可以用于提高效率。下面是一个使用Java线程并行发出HTTP请求的简单示例。

示例 -

在这个例子中,我们创建了独立的线程来同时向多个URL发出HTTP请求,这可以显著减少从各种来源获取数据所需的时间。

这些例子展示了Java线程如何通过并行化任务和利用多核处理器的能力来解决CPU密集型和I/O密集型问题。

Python线程只解决I/O问题

在Python中,由于全局解释器锁(GIL),线程通常更适合解决I/O密集型问题,而不是CPU密集型问题。下面是一个示例,说明如何使用Python线程解决I/O密集型问题,特别是并行HTTP请求。

示例 -

在此示例中,我们为每个URL创建一个单独的线程,每个线程发出一个HTTP请求。由于发出HTTP请求涉及等待外部响应(I/O密集型),因此使用线程可以让我们并发执行这些操作,从而提高效率。

虽然Python线程可以帮助处理像这样的I/O密集型任务,但对于CPU密集型任务,由于Python中全局解释器锁(GIL)的限制,您可能需要考虑使用`multiprocessing`模块来充分利用多个CPU核心。

Python中的全局解释器锁(GIL)限制了线程的并发执行

Python线程具有独特的特性。它们是由操作系统管理的真实线程,但它们也使用协作式多任务处理,这有点不寻常。相比之下,大多数现代系统通常采用带有抢占式调度程序的分时多任务处理。这种方法确保CPU时间在线程之间公平分配,防止贪婪或设计不佳的线程剥夺其他线程的资源。

在内部,Python解释器依赖于操作系统的线程,这些线程通过POSIX线程等库提供。然而,存在一个关键限制:只有当前持有全局解释器锁(GIL)的活动线程才允许执行。这要求线程定期自愿释放GIL。如前所述,只要发生I/O操作,线程就会自动释放GIL。不涉及此类操作的线程仍将在预定义的时间间隔后释放GIL。

在Python 3.2之前的版本中,解释器有一种机制,在执行一定数量的字节码指令后会释放全局解释器锁(GIL)。这样做是为了让其他线程有机会运行,尤其是在没有待处理的I/O操作时。由于线程的调度由操作系统管理,这通常导致刚刚释放GIL的同一线程立即重新获得它。

这种方法导致了低效且不公平的上下文切换。此外,它还不可预测,因为Python中的单个字节码指令可能对应于可变数量的机器码指令,每个指令都具有不同的相关执行成本。例如,调用C函数可能比打印换行符这样的简单任务花费更多的时间,尽管两者都表示为单个指令。

此后,Python线程采用了不同的方法。它们不再计算字节码,而是在指定的切换间隔(默认为五毫秒)后释放全局解释器锁 (GIL)。值得注意的是,此计时并不精确,并且 GIL 的释放仅在其他线程发出获取 GIL 以供其自身执行的意图时发生。

使用基于进程的并行化代替多线程

在Python中实现并行化的传统方法是通过使用不同的系统进程来执行解释器的多个实例。这种方法相对简单,并且是绕过全局解释器锁(GIL)的一种方式。然而,它确实存在某些局限性,可能使其不适用于特定场景。我们现在将探讨标准库中可以帮助实现这种并行形式的两个模块。

  • 多进程:对进程的底层控制

multiprocessing模块被特意设计为与threading模块非常相似,接受其熟悉的构建块和接口。这种故意的相似性使得将您的代码从基于线程的方法转换为基于进程的方法,反之亦然,变得异常方便。在某些情况下,这些模块可以无缝地相互替换。

  • futures:运行并发任务的高级接口

在Python 3.2中,标准库中引入了一个新模块,其灵感来源于Java API。这个名为`concurrent.futures`的模块借鉴了Java的早期版本,特别是`java.util.concurrent.Future`接口和Executor框架。它的新加入提供了一种统一且用户友好的方式来处理线程池或进程池,简化了后台异步任务的执行。

与multiprocessing模块相比,concurrent.futures中的元素提供了更直接但有些受限的接口。它抽象了管理和协调单个工作器的复杂性。值得注意的是,此包构建在`multiprocessing`之上,但将并发工作的提交与结果的收集分离开来。这些结果由“future”对象表示。使用`concurrent.futures`,不再需要使用队列或管道手动交换数据。

启用Python线程的并行执行

在本节中,我们将深入探讨规避Python全局解释器锁(GIL)的不同方法。您将了解如何利用替代运行时环境、使用NumPy等耐GIL库、创建和利用C扩展模块、利用Cython的力量以及调用外部函数。在每个小节的末尾,我们将提供每种方法的优缺点,帮助您根据您的特定需求做出明智的选择。

Python解释器是负责解释和执行Python代码的底层机制,本质上驱动着您程序的执行。作为解释器的补充,标准库包含一个模块、函数和对象的存储库,通常集成到解释器中。在此库中,您将发现内置函数(如print()),以及促进与主机操作系统交互的模块(如os模块)。

CPython是默认且广泛使用的流行Python解释器,它采用C语言实现。它包含全局解释器锁(GIL)和用于自动内存管理的引用计数。因此,其内部内存结构通过Python/C API对扩展模块可见,确保它们知道GIL的存在。

幸运的是,Python代码不仅限于CPython作为解释器。还有其他实现可以利用外部运行时环境,例如Java虚拟机(JVM)或.NET应用程序的公共语言运行时(CLR)。这些实现使您能够使用Python与各自的标准库交互,使用其本机数据类型,并遵循运行时执行指南。但是,值得注意的是,它们可能缺少某些Python功能。

选择替代CPython的Python解释器通常是规避GIL最简单的方法,通常无需对现有代码库进行任何更改。

让我们编写一个释放GIL的C扩展模块。

为Python创建释放全局解释器锁(GIL)的C扩展模块需要编写明确管理GIL的C代码。释放GIL允许多个线程并发执行Python代码。下面是一个简单的C扩展模块示例,演示如何释放和重新获取GIL。

  • 创建一个C源文件(例如`my_extension.c`)
  • 编译C扩展模块

您可以使用C编译器将C代码编译成共享库。例如,如果您有`my_extension.c`,您可以在类Unix系统上使用以下命令进行编译:

您必须根据您的Python版本和平台调整包含路径和输出文件名。

  • 在Python中使用C扩展模块

现在,您可以在Python代码中使用您的C扩展模块,如下所示:

在此示例中,Py_BEGIN_ALLOW_THREADS 和 `Py_END_ALLOW_THREADS` 宏用于释放和重新获取 GIL,允许 C++ 线程执行 CPU 密集型工作而不会阻塞其他 Python 线程。请记住,在使用此技术时要谨慎,因为它可能会引入线程安全问题。在这种情况下,在线程之间共享数据时使用适当的同步机制至关重要。

结论

本教程探讨了Python中并行处理的概念以及全局解释器锁(GIL)带来的挑战。GIL限制了Python线程在同一进程中的并发执行,从而限制了多核处理器在CPU密集型任务中的利用率。我们讨论了CPU密集型任务和I/O密集型任务之间的区别,强调了并行化对CPU密集型任务的好处。

我们还研究了在Python中启用并行化的各种方法,包括使用multiprocessing模块的基于进程的并行化以及使用concurrent.futures的高级并发处理。此外,我们还了解了Jython和IronPython等替代Python解释器,它们可以绕过GIL并提供对外部运行时环境的访问。

我们还以创建释放和重新获取GIL的C扩展模块的实际示例结束,从而允许并发执行CPU密集型任务。虽然这种技术可以提高性能,但应谨慎对待以确保线程安全。

总而言之,理解GIL并使用适当的并行处理技术可以显著提高Python应用程序的性能,尤其是在处理CPU密集型工作负载时。