Grok the 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 日在俄勒冈州波特兰举行。请参加他在 5 月 19 日星期五的演讲,Grok the GIL: Write Fast and Thread-Safe Python

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

15 条评论

Hi 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 代码中被放弃,这就有道理了,从你的其他示例来看,GIL 似乎只会被依赖 IO 的代码有意识地放弃,我从你写的内容中理解到情况就是这样。

回复 作者 emptysquare

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

类似地,具有自定义 `__hash__` 方法的对象可以使单字节码操作变得可中断,其方式有时令人惊讶。反汇编不是可靠的指示器,可以指示哪些东西被 GIL 原子化;它需要更深入地了解这些字节码到底在做什么。

回复 作者 emptysquare

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

回复 作者 Ben Darnell (未验证)

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

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

回复 作者 Wladimir Mutel (未验证)

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

此外,你正在使用 2.7 作为示例,你将来计划使用 3.x 例如吗?

Hi 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.