共计 4545 个字符,预计需要花费 12 分钟才能阅读完成。
概述
如今我也是使用 Python 写代码好多年了,但是我却很少关心 GIL 的内部机制,导致在写 Python 多线程程序的时候。今天我们就来看看 CPython 的源代码,探索一下 GIL 的源码,了解为什么 Python 里要存在这个 GIL,过程中我会给出一些示例来帮助大家更好的理解 GIL。
GIL 概览
有如下代码:
static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */
这行代码位于 Python2.7 源码 ceval.c 文件里。在类 Unix 操作系统中,PyThread_type_lock 对应 C 语言里的 mutex_t 类型。在 Python 解释器开始运行时初始化这个变量
void
PyEval_InitThreads(void)
{interpreter_lock = PyThread_allocate_lock();
PyThread_acquire_lock(interpreter_lock);
}
所有 Python 解释器里执行的 c 代码都必须获取这个锁,作者一开始为求简单,所以使用这种单线程的方式,后来每次想移除时,都发现代价太高了。
GIL 对程序中的线程的影响很简单,你可以在手背上写下这个原则:“一个线程运行 Python,而另外一个线程正在等待 I / O.”Python 代码可以使用 threading.Lock 或者其他同步对象,来释放 CPU 占用,让其他程序得以执行。
什么时候线程切换?每当线程开始休眠或等待网络 I / O 时,另一个线程都有机会获取 GIL 并执行 Python 代码。CPython 还具有抢先式多任务处理:如果一个线程在 Python 2 中不间断地运行 1000 个字节码指令,或者在 Python 3 中运行 15 毫秒,那么它就会放弃 GIL 而另一个线程可能会运行。
协作式多任务
每当运行一个任务,比如网络 I /O,持续的时间很长或者无法确定运行时间,这时可以放弃 GIL,这样另一个线程就可以接受并运行 Python。这种行为称为协同多任务,它允许并发; 许多线程可以同时等待不同的事件。
假设有两个链接 socket 的线程
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 文件里):
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 锁,这时,当前现在有可能会卡住,等待其他现在释放 GIL 锁。
优先权式多任务
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 这行代码,编译出了 4 个字节码:
- 将 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 个线程中的每一个都增加了 1。但有时你会看到 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()
翻译成字节码如下:
>>> dis.dis(foo)
LOAD_GLOBAL 0 (lst)
LOAD_ATTR 1 (sort)
CALL_FUNCTION 0
可以看出,sort() 函数被翻译成了一条指令,执行过程不会被打断。
- 将 lst 的值加载到堆栈上
- 将其排序方法加载到堆栈上
- 调用排序方法
即使 lst.sort()需要几个步骤,sort 调用本身也是一个字节码,因此不会被打断。我们可以得出结论,我们不需要锁定 sort()。或者,请遵循一个简单的规则:始终锁定共享可变状态的读写。毕竟,获取 Python 中的 threading.Lock 花销很低。
虽然 GIL 不能免除锁的需要,但它确实意味着不需要细粒度的锁定。在像 Java 这样的自由线程语言中,程序员努力在尽可能短的时间内锁定共享数据,以减少线程争用并允许最大并行度。但是,由于线程无法并行运行 Python,因此细粒度锁定没有任何优势。只要没有线程在休眠时持有锁,I / O 或其他一些 GIL 丢弃操作,你应该使用最粗糙,最简单的锁。无论如何,其他线程无法并行运行。
并发提供更好的性能
在诸如网络请求等 I / O 型的场景中,使用 Python 多线程可以带来很高的性能提升,因为在 I / O 场景中,大多数线程都在等待 I / O 以进行接下来的操作,所以即使单 CPU,也能大大提高性能。比如下面这样的代码:
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 来说,多线程是不合适的,这种情况下,你得使用多进程,因为每个进程都是单独的运行环境,并且可以使用多核,但这会带来更高的性能开销。下面的代码就是使用多进程来运行任务,每个进程里只有一个线程。
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)
因为每个进程都拥有单独的 GIL,所以这段代码可以在多核 CPU 上并行执行。
总结
由于 Python GIL 的存在,导致 Python 中一个进程下的多个线程无法并行执行,在 I / O 密集型的场景中,多线程依然能带来比较好的性能,但是在 CPU 密集型的场景中,多线程无法带来性能的提升。但同时也是由于 GIL 的存在,我们在单进程中,线程安全也比较容易达到。