在CPython中实现纯Python函数的真正并行性


CPython 是最常见的 Python 实现,被全球数百万开发人员广泛使用。然而,在 CPython 进程中实现真正的并行性一直是一个难题。在这里,我们将尝试在操作系统和 Python 的背景下更好地理解并行性、并发性。最后,凭借所有这些知识和新的 Python 语言内部结构,我们将研究一种可能的机制,为在单个 CPython 3.12 进程中运行的纯 Python 代码实现真正的并行性。

  • 如果这些概念对您来说很陌生,请不要担心,我们将在上下文部分中介绍所有内容。您可以在看完介绍后重新阅读。
  • 如果您只是想尝试一下 Python 中真正的并行性,请使用此链接

让我们首先简要介绍一些在我们的讨论中会派上用场的主题。请根据您的知识水平随意跳过某些部分。
并发与并行

  • 并行性:当任务实际上同时运行时(例如,为视频游戏创建要显示的下一帧使用多个 GPU 和 CPU 核心同时处理数学问题)
  • 并发:一个或多个任务在重叠的时间段内运行,但不一定在同一时刻运行。这对于等待 I/O 的任务来说是理想的选择,其中一次可能只有一个不同的任务在处理器上运行。 (例如,在等待网络调用时,网络浏览器可以继续读取用户输入)

操作系统中的并发和并行
让我们介绍一下现代操作系统中的基本并行/并发原语:-

  • 进程:进程是程序的执行,包括所有程序代码、数据成员、资源等。不同的进程是独立的,内存、资源等通常不共享。
  • 线程:进程中可以安排执行的实体。进程的不同线程通常在它们之间共享内存,但是它们可以具有不同的执行堆栈。

默认情况下,在大多数现代操作系统中,进程的不同线程可以在不同的处理器上运行(即实际上同时运行)。由于多个线程可以同时运行,因此在多个线程之间共享对象时我们需要更加小心。我们需要使用“锁”确保只有一个线程可以在某一时刻写入共享对象,以防止内存损坏。在这里,每当一个进程/线程尝试获取另一个线程/进程持有的锁时,它必须等待另一个线程/进程放弃锁。这称为锁争用,它是大多数并行系统中的瓶颈。

值得注意的另一点是,并非所有线程/进程都可以在任何给定时间始终在处理器上运行。因此,对于线程和进程,操作系统通常会编排在给定时间在可用处理器上运行哪些任务。在这里,操作系统必须确保每个线程都获得一些 CPU 时间,以确保它们不会“饥饿”,即陷入等待状态。因此,处理器必须不断地从一个线程/进程切换到另一个线程/进程,从而导致“上下文切换”开销。我们的目标应该是尽可能避免这种“上下文切换”开销。此外,在本文中,任何等待用户/网络 I/O 的任务都称为“I/O 密集型任务”,而任何需要 CPU(例如数学运算)进一步处理的任务则称为“CPU- 密集型任务”。绑定任务”。

Python 和多任务处理
在这里,就本博客而言,Python=CPython,因为它是使用最广泛的 Python 发行版。在Python中,我们可以通过使用线程和协程来实现并发(超出了本文的范围)。然而,尽管Python内部使用简单的操作系统线程进行多线程处理,但Python代码实际上无法并行运行。

为什么Python代码不能并行运行?
Python 语言开发人员决定不允许在 CPython 中运行 Python 代码的并行性,以使其实现更简单(内存分配、垃圾收集、引用计数等都大大简化)。这也使得单线程性能更快,因为 CPython 不必担心同步各个线程。

Python 如何防止并行性?
Python 通过引入 GIL(全局解释器锁)的概念来防止并行性。在 Python 解释器中运行任何代码之前需要获取此锁。由于每一行Python代码都在Python解释器中运行(Python是一种解释性语言),因此,在大多数情况下,这本质上禁止Python线程的并行执行,因为在给定时间只有一个线程可以获取GIL。

如果一次只有一个线程可以有GIL,那么,Python线程是如何并发的呢?

Python 在内部实现了类似于操作系统的“上下文切换”范例,一旦发生以下任一事件,它就会释放全局解释器锁(GIL)并允许其他线程运行其操作:-

  • I/O 请求:当执行 Python 代码的线程发出 I/O 请求时,它会在内部释放 GIL,并仅在 I/O 操作完成后尝试重新获取它。
  • 受 CPU 限制的 Python 线程的 100 个“周期”:当运行 Python 代码的线程完成大约 100 个 Python 解释器指令时,它会尝试释放 GIL 以确保其他线程不会“饥饿”。

因此,多个 I/O 请求可以在 Python 中并行运行。但是,对于在 Python 中执行的 CPU 密集型任务,使用多线程并没有提供任何优势。
Python 与并行性

在 Python 中实现并行性的最简单方法是使用multiprocessing模块,它会生成多个单独的 Python 进程,并与父进程进行某种进程间通信。由于生成进程会产生一些开销(并且不是很有趣),因此,出于本文的目的,我们将讨论限制为使用单个 Python 进程可以实现的目标。

运行 Python 代码的进程仍然可以包含非 Python 代码片段,这些代码片段可以并行运行,而不受 GIL 的限制。然而,受 CPU 限制的 Python 代码需要 Python 解释器,并且必须等待 GIL 执行。

因此,像 Numpy 这样的库在内部使用 C/C++ 进行计算,从而消除了解释器开销。这还可以通过在运行非 Python 代码时放弃 GIL 并允许真正的并行线程来实现并行性。请参阅:Superfastpython.com 的 Numpy 多线程并行性

使用 C/C++ 扩展运行真正并行的 CPU 密集型代码
我们可以按照以下文档编写 C/C++ 中的 Python 模块:扩展和嵌入 Python 解释器

在这里,我们可以在内部放弃 GIL 作为我们手写的 C/C++ 函数的一部分,从而允许 C++ 代码从多个线程并行运行,而没有任何 GIL 限制。
例子:

PyObject* py_function(PyObject* self, PyObject* args) {
    auto c_args = /** Code to convert PyObject* args to C++ args */;

    
// Release GIL
    Py_BEGIN_ALLOW_THREADS

    
// Run pure C++ code
    auto c_ret = c_function(c_args);

    
// Re-acquire GIL
    Py_END_ALLOW_THREADS

    return
/** Code to convert c_ret to PyObject* */;
}

上述模式通常由 Python 的 C/C++ 扩展使用,允许多个线程并行运行。此外,这些 C/C++ 函数还可以在内部生成多个线程,以实现更好的性能。请注意,由于 C++ 代码作为构建 C++ 扩展的一部分被编译为机器代码,因此即使不将并行性纳入等式中,C++ 扩展也比在 Python 解释器中运行此代码提供更快的性能。

然而,这种方法只适合并行运行非Python代码(例如C/C++)。

我使用 C++ 扩展编写了一个基本的 Python 函数,该函数在通过 C++ 函数放弃 GIL 后执行一项昂贵的数学任务,如下所示:
https://github.com/RishiRaj22/PythonParallelism/blob/main/pure_cpp_parallelism.cpp

使用子解释器运行真正并行的 CPU 密集型代码
在 Python 3.12 中,Python 内部经历了重大的范式转变。添加了对单个 CPython 进程中多个 GIL 的支持,而不是每个进程有一个 GIL。这允许在单个 CPython 进程中运行的多个 Python 代码线程同时运行。为此,我们可以创建多个 Python 子解释器,每个子解释器都有自己的 GIL。请参阅PEP 0684

这里有几点需要注意:-

  1. 单个进程中的多个 Python 解释器作为一个概念,早在 Python 3.12 之前就已经存在。然而,在单个进程中创建的每个解释器共享一个 GIL。因此,在 Python 3.12 之前的 CPython 进程中,在任何给定时刻都只有一个子解释器运行。因此,多个解释器的概念主要用于实现“隔离”(超出了本文的范围),而不被视为提高性能的方法。
  2. 目前,从 CPython 3.12 开始,无法通过 Python 代码直接与这些 API 交互。

为什么需要子解释器sub-interpreter?

  • 比生成进程便宜,比线程昂贵。
  • 在评估 Python 代码时无需获取 GIL,因此不会出现与获取 GIL 相关的锁争用。
  • 预计将由 Python 3.13 进行标准化,并在多个解释器之间建立适当的通信通道。有关更多详细信息,请参阅PEP 0554 。

使用子解释器实现并行性
为了玩转子解释器,我创建了一个模块 subinterpreter_parallelism,通过使用子解释器实现任意 Python 代码的并行。由于在 Python 3.12 中,子解释器并不是 stdlib 的一部分,所以这个模块是用 C++ 扩展模块实现的,如下所示:

L150>https://github.com/RishiRaj22/PythonParallelism/blob/main/subinterpreter_parallelism.cppL150

from subinterpreter_parallelism import parallel

# Run 3 threads of pure python functions in parallel using sub-interpreters.
result = parallel(['module1', 'func1', (arg11, arg12, arg13),)],
                  ['module2', 'func2', (arg21, arg22)],
                  ['module3', 'func3', tuple()])

通过这种设置,我几乎充分利用了 CPU 的所有处理内核(即在 nproc=20 和 20 个 Python 函数并行运行的系统上,CPU 使用率为 1995%),体现了真正的并行性。此外,使用这种设置的性能比使用多处理模块更快。

子解释器并不都是玫瑰和阳光

  • 在当前状态下,它可能无法与其他各种常用的 Python 库(如 Numpy)很好地协同工作,或者根本无法协同工作。这是因为,默认情况下,所有 C/C++ 扩展模块在初始化时都不支持多解释器。截至 2024 年 4 月,所有使用 Cythonize 创建的模块(如 Numpy)都是如此。这是因为 C 扩展库经常与底层 API(如 PyGIL_*)交互,而众所周知,底层 API 不支持多子解释器。请参阅 Python 文档中的注意事项部分。希望随着这种范式被更多人采用,会有更多库增加对它的支持。
  • 由于 Python 是一种解释型语言,与之相关的开销较少,因此对于 CPU 约束较高的任务,纯 C/C++ 代码的性能应该会好得多。
  • 在我的 hacked together 模块中,解释器之间的共享很少,因此像日志配置、导入等都需要在并行运行的函数中明确提供。

归根结底,这只是一个用来测试的实验项目。

性能数据

多进程(每个 Python 任务 1 个进程) 15.07s
带子解释器的多线程(每个 Python 任务 1 个线程) 11.48s
多线程处理 C++ 扩展,放弃 GIL(每个 C++ 任务 1 个线程) 0.74s

结论
子解释器似乎是一种很有前途的并行 Python 代码机制,它具有显著的优势(在上述简单的基准测试中,性能比多处理提高了 20%),等等。

虽然子解释器对于纯 Python 代码的并行化很有帮助,但它与 Numpy 等基于 C/C++ 扩展的库并不兼容。详情请参考上文提到的第一点。我们还不清楚这种编程模式是否会在 Python 生态系统中得到广泛支持。

如果性能是一个大问题,那么依靠 C/C++ 扩展函数似乎更合适,因为编译后的代码可以提高速度。

随着 Python 3.13 的发布,子解释器将变得更有吸引力,这里编写的代码也将变得多余,因为解释器将成为 stdlib 本身的一部分。不过,看看我们如何在 Python 3.12 中实现类似的结果仍然令人着迷。

如果您对学习并行的子解释器更感兴趣,可以阅读 Anthony Shaw 的博客:Anthony Shaw’s blog.

讨论的整个源代码以及基准测试代码以及​​构建和使用代码的说明可以在github.com/RishiRaj22/PythonParallelism中找到。