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

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

Geralt. CC0.

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

music box parts

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

因此,奇怪的是,多年来,我编写 Python 程序,却对全局解释器锁 (GIL) 持有错误的观念,因为我从来没有好奇到去了解它是如何工作的。我遇到过其他人和我一样犹豫不决,也一样无知。现在是我们打开盒子的时候了。让我们阅读 CPython 解释器源代码,找出 GIL 到底是什么,Python 为什么要有 GIL,以及它如何影响你的多线程程序。我将展示一些示例来帮助你理解 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 上发表演讲,PyCon 2017 将于 5 月 17 日至 25 日在俄勒冈州波特兰举行。请观看他在 5 月 19 日星期五的演讲,理解 GIL:编写快速且线程安全的 Python

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

15 条评论

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

文章写得不错,但有一些问题。(a) 扩展模块也可以在纯 CPU 操作期间释放 GIL。最值得注意的是,numpy 在你对大型矩阵执行操作(如 dot、+ 甚至 sort!)时会这样做。如果你正在做一些计算密集型的事情,无论如何你都应该使用类似 numpy 这样的库,因为核心代码是用 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 (未验证)

嗨,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.