我六岁的时候,有一个音乐盒。我拧紧发条,一个芭蕾舞女演员在盒子顶部旋转,而里面的一个装置则叮叮当当地奏出“一闪一闪亮晶晶”。那东西肯定俗气得要命,但我喜欢那个音乐盒,我想知道它是怎么工作的。不知怎么的,我把它打开了,并看到了一个简单的装置——一个拇指大小的金属圆筒,上面镶满了钉子,当它旋转时,就会拨动钢梳的齿,发出音符。
在程序员的所有特质中,对事物如何运作的好奇心是必不可少的。当我打开我的音乐盒想看看里面时,我表明即使我不能成长为一名伟大的程序员,至少也能成为一个好奇的程序员。
因此,奇怪的是,多年来,我编写 Python 程序,却对全局解释器锁 (GIL) 持有错误的观念,因为我从来没有好奇到去了解它是如何工作的。我遇到过其他人和我一样犹豫不决,也一样无知。现在是我们打开盒子的时候了。让我们阅读 CPython 解释器源代码,找出 GIL 到底是什么,Python 为什么要有 GIL,以及它如何影响你的多线程程序。我将展示一些示例来帮助你理解 GIL。你将学会编写快速且线程安全的 Python,以及如何在线程和进程之间做出选择。
(为了重点突出,我在这里只描述 CPython——而不是 Jython、PyPy 或 IronPython。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 或其他同步对象;将处于该状态的线程也视为“休眠”。
线程何时切换?每当一个线程开始休眠或等待网络 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 已被编译成四个字节码,它们执行四个基本操作
- 将 n 的值加载到堆栈上
- 将常量 1 加载到堆栈上
- 对堆栈顶部的两个值求和
- 将总和存回 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
这一行代码编译成三个字节码
- 将 lst 的值加载到堆栈上
- 将其 sort 方法 加载到堆栈上
- 调用 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。
15 条评论