当Python不能进行线程时:深入了解GIL的影响

354 阅读11分钟

如果你曾经听说过Python在线程方面很糟糕,原因是Python的全局解释器锁,简称GIL。随着计算机核心速度不像以前那样快速增长,现在的大多数计算机通过增加多个CPU核心来弥补,允许多个线程并行运行计算。而且,即使没有多核,你也可以有并发性,例如,一个线程在磁盘上等待,而另一个线程在CPU上运行代码。使用并行性的能力对于扩展你的应用程序至关重要,或者使你的数据处理完成得更快。

不幸的是,在许多情况下,你的代码一次只能运行一个线程,这是由于Python的全局解释器锁,简称GIL。其他时候,它可以很好地运行多个线程--这完全取决于具体的使用模式。

但是哪些使用模式允许并行,哪些不允许?天真的心理模型会给你不准确的答案。因此,在这篇文章中,你将建立一个关于GIL如何工作的实用心理模型。

  • 首先,我们将通过一系列越来越准确的心理模型来了解GIL的工作原理。
  • 然后,我们将看到我们新的、更精确的心理模型如何帮助你预测哪里会出现并行瓶颈,以及是否会出现。

Python线程何时需要持有GIL?

GIL是CPython解释器的一个实现细节,是一个线程锁:在给定时间内只有一个线程可以获得这个锁。因此,为了理解GIL如何影响Python在线程中并行运行代码的能力,我们需要回答一个关键问题:Python线程何时需要持有GIL?

为了理解这一点,我们将建立一系列越来越精确的关于GIL如何工作的心理模型。

模型#1:一次只能有一个线程运行Python代码

考虑一下下面的代码;它在两个线程中运行函数go()

import threading
import time

def go():
    start = time.time()
    while time.time() < start + 0.5:
        sum(range(10000))

def main():
    threading.Thread(target=go).start()
    time.sleep(0.1)
    go()

main()

当我们使用Sciagraph性能分析器运行它时,下面是执行时间线的样子。

注意线程如何在CPU上的等待和运行之间来回切换:运行中的代码持有GIL,等待中的线程在等待GIL。

如果GIL在5ms内没有被释放(或其他可配置的间隔),Python会告诉当前运行的线程释放GIL。然后,下一个线程可以运行。在两个线程中,我们看到它来回切换;实际显示的时间间隔比5ms长,因为这是一个抽样分析器,每隔47ms左右才取一次样。

这就是我们最初的心理模型。

  1. 一个线程必须持有GIL来运行Python代码。
  2. 其他线程不能获得GIL,因此也不能运行,直到当前运行的线程释放GIL,而这是每5ms自动发生的。

模式#2:不能保证每5ms释放一次GIL

在Python 3.7到3.10中,GIL默认每5ms释放一次(这在未来可能会改变),允许其他线程运行。

>>> import sys
>>> sys.getswitchinterval()
0.005

但这种GIL的释放是尽力而为的,它并不被保证。考虑一个在线程中运行的天真解释器循环的伪代码草图;Python的实现相当不同,但同样的原则适用。

while True:
    if time_to_release_gil():
        temporarily_release_gil()
    run_next_python_instruction()

只要run_next_python_instruction() 还没有完成,temporarily_release_gil() 就不会被调用。大多数时候,这种情况不会发生,因为单个操作(添加两个整数,追加到一个列表,等等)会很快完成。因此,解释器可以经常检查是否到了释放GIL的时候。

也就是说,长时间运行的操作会妨碍GIL的自动释放。让我们写一个小的Cython扩展,这是一种类似Python的语言,被编译成C语言。它调用标准C库中的sleep() 函数

cdef extern from "unistd.h":
    unsigned int sleep(unsigned int seconds)

def c_sleep(unsigned int seconds):
    sleep(seconds)

我们可以用Cython附带的cythonize 工具将其编译为可导入的Python扩展。

$ cythonize -i c_sleep.pyx
...
$ ls c_sleep*.so
c_sleep.cpython-39-x86_64-linux-gnu.so

我们将从一个Python程序中调用它,该程序试图在主线程的CPU计算的同时调用c_sleep()

import threading
import time
from c_sleep import c_sleep

def thread():
    c_sleep(2)

threading.Thread(target=thread).start()

start = time.time()
while time.time() < start + 2:
    sum(range(10000))

下面是运行该程序后的Sciagraph性能分析器输出。

主线程无法运行,直到休眠线程结束;似乎GIL根本就没有被休眠线程释放。这是因为对c_sleep(2) 的调用在2秒内不会返回。在这 2 秒钟结束之前,Python 解释器循环没有运行,因此不会检查它是否应该自动释放 GIL。

下面是我们更新的心理模型。

  1. 一个Python线程必须持有GIL来运行代码。
  2. 其他的Python线程不能获得GIL,因此也不能运行,直到当前运行的线程释放它,这每5ms自动发生一次。
  3. 长时间运行的("阻塞")扩展代码会阻止自动切换。

模式三:非Python代码可以明确释放GIL

如果我们运行time.sleep(3) ,这将在3秒内不做任何事情。我们在上面看到,长期运行的扩展代码可以阻止GIL在线程之间自动切换。那么这是否意味着其他线程不能与time.sleep() 同时运行?

让我们用下面的代码试试,它试图在主线程的5秒计算中并行运行一个3秒的睡眠。

import threading
from time import time, sleep

program_start = time()

def thread():
    sleep(3)
    print("Sleep thread done, elapsed:", time() - program_start)

threading.Thread(target=thread).start()

# Do 5-second calculation in main thread:
calc_start = time()
while time() < calc_start + 5:
    sum(range(10000))
print("Main thread done, elapsed:", time() - program_start)

如果我们运行它,我们会看到。

$ time python gil2.py 
Sleep thread done, elapsed: 3.0081260204315186
Main thread done, elapsed: 5.000330924987793
real    0m5.068s
user    0m4.977s
sys     0m0.011s

如果每次只有一个线程在运行,我们预计程序将花费8秒,3秒用于睡眠,5秒用于计算。这意味着睡眠线程和主线程是并行运行的!

下面是Sciagraph性能分析器的输出。

如果我们深入研究 time.sleep 的实现,我们可以看到发生了什么。

        int ret;
        Py_BEGIN_ALLOW_THREADS
#ifdef HAVE_CLOCK_NANOSLEEP
        ret = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &timeout_abs, NULL);
        err = ret;
#elif defined(HAVE_NANOSLEEP)
        ret = nanosleep(&timeout_ts, NULL);
        err = errno;
#else
        ret = select(0, (fd_set *)0, (fd_set *)0, (fd_set *)0, &timeout_tv);
        err = errno;
#endif
        Py_END_ALLOW_THREADS

如果我们看一下 PY_BEGIN/END_ALLOW_THREADS 的文档,我们会发现这是释放GIL的一种方式。C语言的实现是在调用底层操作系统的睡眠函数时明确地释放GIL的。这是另一种释放GIL的方式,与我们到目前为止看到的每5ms自动切换是分开的。

任何已经释放了GIL并且没有试图获取它的代码--在这种情况下,在睡眠期间--都不会阻碍其他想要GIL的线程。因此,我们可以并行运行任意多的线程,只要它们明确释放了GIL。

这就是我们的新思维模式。

  1. 一个线程必须持有 GIL 来运行 Python 代码。
  2. 其他线程不能获得GIL,因此也不能运行,直到当前运行的线程释放GIL为止,这种情况每5ms自动发生一次。
  3. 长时间运行的("阻塞")扩展代码会阻止自动切换。
  4. 然而,用C语言(或其他低级语言)编写的Python扩展可以明确地释放GIL,允许一个或多个线程与持有GIL的线程并行运行。

模式 #4:对 Python C API 的调用需要 GIL

到目前为止,我们已经说过C代码在某些情况下能够释放GIL,但我们还没有说什么时候。因为需要GIL来保护对CPython解释器内部实现细节的访问,答案差不多是:只要调用CPython C API,就必须持有GIL

例如,假设你想用C语言(或Rust,或Cython)构造一个Pythondict 对象。如果你看一下CPython C的API用于创建字典,你可以看到你会使用这样的函数 PyDict_NewPyDict_SetItem.如果你写的是C语言,你会直接调用它们;如果是Rust和Cython,PyO3库或生成的代码最终会分别调用这些API。

几乎所有的CPython C API都要求调用线程持有GIL,只有一些罕见的例外。

所以,这就是我们最终的心理模型。

  1. 一个线程必须持有GIL来调用CPython C APIs。
  2. 在解释器中运行的Python代码,如x = f(1, 2) ,使用这些API。每一个== 比较,每一个整数加法,每一个list.append :都是对 Python C API 的调用。因此,运行Python代码的线程在运行时必须保持锁的存在。
  3. 其他线程不能获得GIL,因此也不能运行,直到当前运行的线程释放它,这每5ms自动发生一次。
  4. 长时间运行的("阻塞")扩展代码会阻止自动切换。
  5. 然而,用C语言(或其他低级语言)编写的Python扩展可以明确地释放GIL,允许一个或多个线程与保持GIL的线程并行运行。

GIL的并行性影响

那么所有这些对Python在多线程中同时运行代码的能力有什么影响呢?它变化很大,取决于你考虑的是什么代码。

好的情况下。释放GIL的长期运行的C语言API

平行性的最佳情况是长期运行的C/C++/Rust/等代码,这些代码大部分时间不使用CPython C APIs。然后,它可以释放GIL,允许其他线程运行。但即使在这里,也有一些需要注意的限制。

例如,让我们考虑一个用C或Rust编写的扩展模块,让你与PostgreSQL数据库服务器对话。

从概念上讲,用这个库处理一个SQL查询将经历三个步骤。

  1. 从Python反序列化到内部库的表示。因为这将读取Python对象,它需要持有GIL。
  2. 将查询发送到数据库服务器,并等待响应。这不需要GIL。
  3. 将响应转换为 Python 对象。这又需要GIL。

正如你所看到的,你能得到多少并行性取决于在每个步骤中花费多少时间。如果大部分时间花在第2步,你会在那里得到并行性。但是如果,例如,你运行一个SELECT ,并得到大量的行回来,库将需要创建许多Python对象,而第3步将不得不持有GIL一段时间。

不好的情况#1:"纯 "Python代码

"纯 "Python 代码,与内置的 Python 对象如 dicts、整数、列表等进行交互,缺乏对阻塞底层代码的调用,将不断使用 Python C API。

l = []
for i in range(i):
    l.append(i * i)

因此,你不会得到任何并行性。但是你会得到每5ms左右来回切换的线程,所以至少你会在所有线程上取得进展。

糟糕的情况#2。长期运行的C/Rust APIs,但作者忘记了发布GIL

即使有可能释放GIL,低级别的C/Rust/Cython/等库的作者也可能忘记这样做。在这种情况下,你不会得到任何并行性。更糟糕的是,如果代码的运行时间超过5ms,你也不会得到自动切换,所以其他线程不会有任何进展。

如果你怀疑这是一个问题,例如基于剖析输出,你可以通过以下方式识别这类问题。

  • 使用 gil_load工具来弄清GIL是否真的是一个瓶颈。
  • 阅读扩展的源代码,寻找缺乏GIL的释放,或者通过使用文章中描述的技术 追踪Python的GIL.

坏情况#3。普遍使用 Python C API 的低级代码

另一种情况是C/Rust扩展,在这种情况下,Python C API的使用很普遍,你不会得到很多并行性。例如,考虑一个正在读取以下字符串的JSON解析器。

[1, 2, 3]

该分析器将。

  1. 读取几个字节,然后创建一个 Python 列表。
  2. 然后再读几个字节,然后创建一个Python整数并将其追加到列表中。
  3. 这样持续下去,直到它的数据耗尽。

创建所有这些 Python 对象需要使用 CPython C APIs,因此需要持有 GIL。反复开启和关闭GIL,或者按照某种时间表,会有性能上的损失,而大多数JSON文档可以很快被解析出来。鉴于上述情况,JSON解析器的作者的自然选择是永远不释放GIL。

让我们通过观察Python内置的JSON解析器在两个线程中读取两个大文档时对并行性的影响来验证这一假设。这里是代码。

import json
import threading

def load_json():
    with open("large.json") as f:
        return json.load(f)

threading.Thread(target=load_json).start()

load_json()

这是执行时间线。

可悲的是,没有并行性:两个JSON负载基本上没有(也不能)并行运行。

避免GIL

正如你在上面的例子场景中所看到的,即使在可以释放GIL的情况下,当你需要与Python对象交互时,你仍然会遇到并行性的限制。如果你要将SQL响应转换为Python对象,一个大的响应意味着大量的时间没有并行性。如果这成为一个瓶颈,一个选择是切换到多进程。

或者,如果你正在编写你自己的Python低级扩展,你可以采用一种你会在NumPy和Pandas等库中看到的设计模式。尽可能地使用不由Python对象组成的内部数据表示。

一个整数的NumPy数组在很大程度上不是一个包含Python整数的Python列表;它是一种更有效的表示方法,不需要使用Python C API,更不用说使用更少的内存。通过尽量减少与Python对象的交互,并且只暴露一个Python对象(例如NumPy数组),GIL可以被尽可能长时间地释放。