理解 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 线程在等待建立连接时是如何实际释放 GIL 的,在 socketmodule.c 中

/* 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 上发表演讲,PyCon 2017 将于 5 月 17 日至 25 日在俄勒冈州波特兰举行。观看他的演讲,理解 GIL:编写快速且线程安全的 Python,在 5 月 19 日星期五。

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

15 条评论

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

文章不错,但有一些问题。(a) 扩展模块也可以在纯 CPU 操作期间释放 GIL。最值得注意的是,numpy 在你对大型矩阵执行操作时会这样做,例如 dot、+ 甚至 sort!。如果你正在做一些计算密集型的事情,你无论如何都应该使用类似的东西,因为核心代码是用 C 编写的,并且运行速度比用 Python 手写的循环快几个数量级。这是关于 GIL 的主要误解,很遗憾这篇文章还在传播它。(b) 虽然你提到你只写关于 CPython 的内容,但我不认为你清楚地说明了关于单个操作是原子性的(如 sort)内容仅适用于 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 扩展在排序列表时,如果 C 扩展正在运行不持有 GIL 的线程,必须如何与列表交互。你的 Python 代码在运行时始终持有 GIL,因此它永远不会看到另一个线程正在排序的列表。

回复 作者 Nate Guerin

感谢你的澄清。只要 GIL 不会在 CPython 代码中被放弃,这就有道理了,从你的其他例子来看,似乎只有 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 等语言形成对比,在 Java 或 C 中,细粒度锁允许更大的并行性,从而提高吞吐量。

回复 作者 Josh

感谢你的快速回复!

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

对于抢占示例,线程 A 没有被任何外部因素阻塞,因此处理过程就像协作式多任务处理一样来回切换。

我理解对了吗?

回复 作者 emptysquare

© . All rights reserved.