理解 GIL:如何编写快速且线程安全的 Python

我们探索 Python 的全局解释器锁,并了解它如何影响多线程程序。
879 位读者喜欢这篇文章。
Pixelated globe

Geralt。CC0。

当我六岁的时候,我有一个音乐盒。我拧紧发条,一个小芭蕾舞女演员在盒子上旋转,而里面的一个装置叮叮当当地奏出“一闪一闪亮晶晶”。这东西肯定俗不可耐,但我喜欢那个音乐盒,我想知道它是如何工作的。不知怎么的,我把它打开了,看到一个简单的装置——一个拇指大小的金属圆筒,上面布满了钉子,当它旋转时,它会拨动钢梳的齿,发出音符。

music box parts

在程序员的所有特质中,对事物如何工作的好奇心是必不可少的。当我打开我的音乐盒看看里面时,我表明即使我不能成长为一名伟大的程序员,至少也可以成为一个好奇的程序员。

奇怪的是,多年来,我一直在编写 Python 程序,同时对全局解释器锁 (GIL) 持有错误的观念,因为我从来没有好奇到去看看它是如何工作的。我遇到过其他人和我一样犹豫,一样无知。现在是我们撬开盒子的时候了。让我们阅读 CPython 解释器源代码,找出 GIL 到底是什么,Python 为什么要有它,以及它如何影响你的多线程程序。我将展示一些示例来帮助你理解 GIL。你将学会编写快速且线程安全的 Python,以及如何在线程和进程之间进行选择。

(为了专注于重点,我在这里只描述 CPython——而不是 JythonPyPyIronPython。CPython 是工作程序员绝大多数使用的 Python 实现。)

看,这就是全局解释器锁

它就在这里

static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */

这行代码位于 ceval.c,在 CPython 2.7 解释器的源代码中。Guido van Rossum 的注释“这就是 GIL”是在 2003 年添加的,但锁本身可以追溯到 1997 年他的第一个多线程 Python 解释器。在 Unix 系统上,PyThread_type_lock 是标准 C 锁 mutex_t 的别名。它在 Python 解释器启动时初始化

void
PyEval_InitThreads(void)
{
    interpreter_lock = PyThread_allocate_lock();
    PyThread_acquire_lock(interpreter_lock);
}

解释器中的所有 C 代码在执行 Python 时都必须持有此锁。Guido 最初以这种方式构建 Python 是因为它很简单,并且每次 尝试从 CPython 中删除 GIL 都使单线程程序的性能损失过大,以至于不值得为多线程带来的收益而牺牲。

GIL 对程序中线程的影响非常简单,你可以将原理写在手背上:“一个线程运行 Python,而 N 个其他线程休眠或等待 I/O。” Python 线程也可以等待来自 threading 模块的 threading.Lock 或其他同步对象;将处于该状态的线程也视为“休眠”。

hand with writing

线程何时切换?每当一个线程开始休眠或等待网络 I/O 时,另一个线程就有机会获取 GIL 并执行 Python 代码。这就是协作式多任务处理。CPython 也有抢占式多任务处理:如果一个线程在 Python 2 中不间断地运行 1000 条字节码指令,或者在 Python 3 中运行 15 毫秒,那么它会放弃 GIL,另一个线程可能会运行。可以把它想象成分时,就像在古代我们有很多线程但只有一个 CPU 一样。我将详细讨论这两种多任务处理。

将 Python 视为一台旧式大型机;许多任务共享一个 CPU。

协作式多任务处理

当线程开始一项任务(例如网络 I/O)时,该任务持续时间长或不确定,并且不需要运行任何 Python 代码,线程会放弃 GIL,以便另一个线程可以获取它并运行 Python。这种礼貌的行为称为协作式多任务处理,它允许并发;许多线程可以同时等待不同的事件。

假设两个线程各自连接一个套接字

def do_connect():
    s = socket.socket()
    s.connect(('python.org', 80))  # drop the GIL

for i in range(2):
    t = threading.Thread(target=do_connect)
    t.start()

这两个线程中一次只能有一个执行 Python,但是一旦线程开始连接,它就会释放 GIL,以便另一个线程可以运行。这意味着两个线程都可以并发地等待它们的套接字连接,这是一件好事。它们可以在相同的时间内完成更多的工作。

让我们撬开盒子,看看 Python 线程在等待建立连接时,实际上是如何在 socketmodule.c 中释放 GIL 的

/* s.connect((host, port)) method */
static PyObject *
sock_connect(PySocketSockObject *s, PyObject *addro)
{
    sock_addr_t addrbuf;
    int addrlen;
    int res;

    /* convert (host, port) tuple to C address */
    getsockaddrarg(s, addro, SAS2SA(&addrbuf), &addrlen);

    Py_BEGIN_ALLOW_THREADS
    res = connect(s->sock_fd, addr, addrlen);
    Py_END_ALLOW_THREADS

    /* error handling and so on .... */
}

Py_BEGIN_ALLOW_THREADS 宏是线程释放 GIL 的地方;它被简单地定义为

PyThread_release_lock(interpreter_lock);

当然,Py_END_ALLOW_THREADS 重新获取锁。线程可能会在此处阻塞,等待另一个线程释放锁;一旦发生这种情况,等待的线程会重新获取 GIL 并恢复执行你的 Python 代码。简而言之:当 N 个线程被网络 I/O 阻塞或等待重新获取 GIL 时,一个线程可以运行 Python。

下面,请看一个完整的示例,该示例使用协作式多任务处理来快速获取许多 URL。但在那之前,让我们将协作式多任务处理与另一种多任务处理进行对比。

抢占式多任务处理

Python 线程可以自愿释放 GIL,但也可以被抢占式地夺走 GIL。

让我们退一步,谈谈 Python 是如何执行的。你的程序分两个阶段运行。首先,你的 Python 文本被编译成一种更简单的二进制格式,称为字节码。其次,Python 解释器的主循环,一个名字优美的函数 PyEval_EvalFrameEx(),读取字节码并逐条执行其中的指令。

当解释器逐步执行你的字节码时,它会定期释放 GIL,而无需征求正在执行其代码的线程的许可,以便其他线程可以运行

for (;;) {
    if (--ticker < 0) {
        ticker = check_interval;
    
        /* Give another thread a chance */
        PyThread_release_lock(interpreter_lock);
    
        /* Other threads may run now */
    
        PyThread_acquire_lock(interpreter_lock, 1);
    }

    bytecode = *next_instr++;
    switch (bytecode) {
        /* execute the next instruction ... */ 
    }
}

默认情况下,检查间隔为 1000 个字节码。所有线程都运行相同的代码,并以相同的方式定期被夺走锁。在 Python 3 中,GIL 的实现更加复杂,检查间隔不是固定的字节码数,而是 15 毫秒。但是,对于你的代码来说,这些差异并不显着。

Python 中的线程安全

将多个线程编织在一起需要技巧。

如果一个线程可能在任何时刻失去 GIL,你必须使你的代码线程安全。然而,Python 程序员对线程安全的看法与 C 或 Java 程序员不同,因为许多 Python 操作是原子的。

原子操作的一个示例是在列表上调用 sort()。线程不会在排序过程中被中断,其他线程永远不会看到部分排序的列表,也不会看到列表排序前的陈旧数据。原子操作简化了我们的生活,但也有一些令人惊讶的地方。例如,+= 看起来比 sort() 更简单,但 += 不是原子的。你如何知道哪些操作是原子的,哪些不是呢?

考虑以下代码

n = 0

def foo():
    global n
    n += 1

我们可以使用 Python 的标准 dis 模块查看此函数编译成的字节码

>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)

一行代码 n += 1 已被编译为四个字节码,它们执行四个原始操作

  1. 将 n 的值加载到堆栈上
  2. 将常量 1 加载到堆栈上
  3. 将堆栈顶部的两个值相加
  4. 将总和存储回 n

请记住,每 1000 个字节码,线程都会被解释器夺走 GIL 而中断。如果线程运气不好,这可能会发生在它将 n 的值加载到堆栈上和将其存储回去之间。这如何导致更新丢失很容易理解

threads = []
for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)

for t in threads:
    t.start()

for t in threads:
    t.join()

print(n)

通常,此代码打印 100,因为 100 个线程中的每一个都递增了 n。但有时你会看到 99 或 98,如果其中一个线程的更新被另一个线程覆盖了。

因此,尽管有 GIL,你仍然需要锁来保护共享的可变状态

n = 0
lock = threading.Lock()

def foo():
    global n
    with lock:
        n += 1

如果我们使用像 sort() 这样的原子操作会怎么样?

lst = [4, 1, 3, 2]

def foo():
    lst.sort()

此函数的字节码表明 sort() 不会被中断,因为它是原子的

>>> dis.dis(foo)
LOAD_GLOBAL              0 (lst)
LOAD_ATTR                1 (sort)
CALL_FUNCTION            0

这一行代码编译为三个字节码

  1. lst 的值加载到堆栈上
  2. 将其 sort 方法 加载到堆栈上
  3. 调用 sort 方法

即使 lst.sort() 这行代码需要几个步骤,但 sort 调用本身是一个单一的字节码,因此在调用期间没有机会从线程中夺走 GIL。我们可以得出结论,我们不需要围绕 sort() 加锁。或者,为了避免担心哪些操作是原子的,请遵循一个简单的规则:始终围绕共享的可变状态的读取和写入加锁。毕竟,在 Python 中获取 threading.Lock 是很廉价的。

尽管 GIL 并不能免除我们对锁的需求,但它确实意味着不需要细粒度锁。在像 Java 这样的自由线程语言中,程序员会努力尽可能短的时间锁定共享数据,以减少线程争用并允许最大的并行性。然而,由于线程不能并行运行 Python,因此细粒度锁没有任何优势。只要没有线程在休眠、执行 I/O 或某些其他 GIL 释放操作时持有锁,你就应该使用最粗粒度、最简单的锁。无论如何,其他线程都无法并行运行。

通过并发更快地完成

我敢打赌,你真正来这里是为了使用多线程优化你的程序。如果你的任务可以通过同时等待多个网络操作来更快地完成,那么即使只有一个线程可以一次执行 Python,多个线程也会有所帮助。这就是并发,线程在这种情况下运行良好。

此代码使用线程运行得更快

import threading
import requests

urls = [...]

def worker():
    while True:
        try:
            url = urls.pop()
        except IndexError:
            break  # Done.

        requests.get(url)

for _ in range(10):
    t = threading.Thread(target=worker)
    t.start()

正如我们在上面看到的,这些线程在等待涉及通过 HTTP 获取 URL 的每个套接字操作时会释放 GIL,因此它们比单个线程更快地完成工作。

并行性

如果你的任务只有通过同时运行 Python 代码才能更快地完成呢?这种扩展称为并行性,而 GIL 禁止它。你必须使用多个进程,这可能比线程更复杂,并且需要更多内存,但它将利用多个 CPU。

这个例子通过 fork 10 个进程比只用一个进程更快地完成,因为这些进程在多个核心上并行运行。但是,使用 10 个线程不会比使用一个线程运行得更快,因为一次只能有一个线程执行 Python

import os
import sys

nums =[1 for _ in range(1000000)]
chunk_size = len(nums) // 10
readers = []

while nums:
    chunk, nums = nums[:chunk_size], nums[chunk_size:]
    reader, writer = os.pipe()
    if os.fork():
        readers.append(reader)  # Parent.
    else:
        subtotal = 0
        for i in chunk: # Intentionally slow code.
            subtotal += i

        print('subtotal %d' % subtotal)
        os.write(writer, str(subtotal).encode())
        sys.exit(0)

# Parent.
total = 0
for reader in readers:
    subtotal = int(os.read(reader, 1000).decode())
    total += subtotal

print("Total: %d" % total)

由于每个 fork 进程都有一个单独的 GIL,因此该程序可以分配工作并在多个核心上同时运行多个计算。

(Jython 和 IronPython 提供单进程并行性,但它们远未完全兼容 CPython。带有 软件事务内存 的 PyPy 可能有一天会很快。如果你好奇,可以尝试这些解释器。)

结论

既然你已经打开了音乐盒并看到了简单的机制,你就知道编写快速、线程安全的 Python 所需的一切。使用线程进行并发 I/O,使用进程进行并行计算。这个原理足够简单,你甚至可能不需要把它写在手背上。

A. Jesse Jiryu Davis 将在 PyCon 2017 上发表演讲,该会议将于 5 月 17 日至 25 日在俄勒冈州波特兰举行。请观看他在 5 月 19 日星期五的演讲 理解 GIL:编写快速且线程安全的 Python

标签
User profile image.
我是纽约市 MongoDB 的一名 Staff 工程师。我编写了 Motor,异步 MongoDB Python 驱动程序,并且我是 MongoDB C 驱动程序的首席开发人员。我为 PyMongo、asyncio、Python 和 Tornado 做出贡献。我在国际摄影中心学习,并在 Village Zendo 练习。

15 条评论

嗨,Jesse,这是我读过的关于 GIL 和 Python 中的多线程编程的最棒的文章。非常感谢 ;)

文章写得很好,但有一些问题。(a) 扩展模块也可以在纯 CPU 操作期间释放 GIL。最值得注意的是,numpy 在你对大型矩阵执行操作时会这样做,例如点积、+ 甚至排序!如果你正在做一些计算密集型的事情,你应该使用类似这样的东西,因为核心代码是用 C 编写的,并且运行速度将比手写的 Python 循环快几个数量级。这是关于 GIL 的主要误解,很遗憾这篇文章传播了它。(b) 虽然你提到你只写关于 CPython 的内容,但我认为你没有明确说明关于单个操作是原子性的内容(如排序)仅适用于 CPython。此外,在今天的 Python 版本中,一个操作码可能在以后的版本中是多个操作码。所以你使用 dis 的讨论在学术上很有趣,但你真的应该在这样的情况下手动加锁。

文章写得不错,你包含了 CPython 代码作为背景,这非常有帮助。

但是,我认为你关于 list.sort() 是原子性的说法是不准确的。在具有多个线程的可抢占场景中,没有什么可以阻止线程 1 开始就地对列表进行排序,耗尽分配给它的 1000 个字节码或 15 毫秒,然后另一个线程成为当前运行的线程并向其追加一个项目。这将是一个问题。需要一个外部锁定机制来保证 list.sort() 函数不会被中断。

来自文档

“CPython 实现细节:当列表正在排序时,尝试修改甚至检查列表的效果是未定义的。Python 的 C 实现使列表在持续时间内显示为空,并且如果它可以检测到列表在排序期间已被修改,则会引发 ValueError。”

https://docs.pythonlang.cn/3/library/stdtypes.html?highlight=list#list

谢谢 Nate。查看字节码:list.sort() 是一个单一的字节码,因此它不会被中断。你引用的文档描述了当 C 扩展正在运行一个不持有 GIL 的线程时,C 扩展必须如何与列表交互。你的 Python 代码在运行时始终持有 GIL,因此它永远不会看到另一个线程正在排序的列表。

回复 作者 Nate Guerin

感谢澄清。只要 GIL 不会在 CPython 代码中被放弃,这就很有道理,从你的其他示例来看,GIL 似乎只会被依赖于 IO 的代码有意识地放弃,我从你写的内容中了解到情况就是这样。

回复 作者 emptysquare

`list.sort()` 如果它包含数字,则是不可中断的。但是如果它包含任意 python 对象,这些对象可能具有自己的任意 `__cmp__` 方法,则 sort *可以* 被中断。

同样,具有自定义 `__hash__` 方法的对象可能会使单字节码操作变得可中断,这有时会令人惊讶。反汇编不是可靠地指示 GIL 使事物原子化的指标;它需要更深入地了解这些字节码到底在做什么。

回复 作者 emptysquare

谢谢 Ben,这绝对是正确的!我最近想到了这一点,并且我已经更新了我个人网站上的文本。

回复 作者 Ben Darnell (未验证)

.pop() 方法是线程安全的吗?(原子性的?)
即,它可以安全地应用于共享列表 (urls) 吗?

是的,与 Ben Darnell 在此处的评论中指出的注意事项相同。

回复 作者 Wladimir Mutel (未验证)

嗨,Jesse,感谢你的精彩文章。虽然网站标记为 CC-BY-SA,但我想问你,我可以将你的文章翻译成繁体中文并在我的博客上分享吗?(https://blog.louie.lu),谢谢!

另外,你使用 2.7 作为示例,你将来计划使用 3.x 作为示例吗?

嗨,Louie,是的,请翻译吧!

我选择 2.7 是因为它的 GIL 实现更简单,更容易理解 Python 3 的 GIL。我没有计划更改示例。

回复 作者 Louie Lu (未验证)

在锁定部分,你做了如下声明:“只要没有线程在休眠、执行 I/O 或某些其他 GIL 释放操作时持有锁,你就应该使用最粗粒度、最简单的锁。无论如何,其他线程都无法并行运行。”

是什么阻止了在持有锁时抢占式释放 GIL 的情况发生?

嗨!没有什么可以阻止线程在持有锁时抢占式释放 GIL。我们称之为线程 A,并且假设还有一个线程 B。如果线程 A 持有锁并被抢占,那么线程 B 可能会代替线程 A 运行。

如果线程 B 正在等待线程 A 持有的锁,那么线程 B *不是* 在等待 GIL。在这种情况下,线程 A 在释放 GIL 后立即重新获取 GIL,线程 A 继续运行。

如果线程 B 没有等待线程 A 持有的锁,那么线程 B 可能会获取 GIL 并运行。

然而,我关于粗粒度锁的观点是:由于 GIL 的存在,永远不可能有两个线程并行执行 Python。因此,使用细粒度锁并不能提高吞吐量。这与 Java 或 C 等语言形成对比,在这些语言中,细粒度锁允许更大的并行性,从而获得更高的吞吐量。

回复 作者 Josh

感谢你的快速回复!

如果我理解正确的话,我引用的声明的意图是避免围绕外部操作使用锁,如果你们都依赖于该锁,那么可能会阻塞多个线程。

对于抢占式示例,线程 A 没有被任何外部事物阻塞,因此处理只是像协作式多任务处理一样来回进行。

我理解对了吗?

回复 作者 emptysquare

Creative Commons License本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.