GIL Introduction
Published:
0x01 什么是 GIL?{#WhatIsGIL}
Python 全局解释器锁,又称为 GIL (Global Interpreter Lock)。
其本质上是一个互斥量。用于保证在同一个时间点,只有一个线程可以调用 Python 解释器。这也意味着,Python 解释器也同一时间节点上只可以在一个线程中运行。
对于单线程程序来说,GIL 是不可见的。但是对于 CPU 依赖的多线程程序,GIL 就有可能成为 Python 的性能瓶颈。
鉴于现在大部分计算机都有多核 CPU 的配置,只支持单线程运行的 GIL 就成为 Python 解释器最为“臭名昭著”的语言特性。
0x02 Python 为什么要用 GIL?{#WhyGILForPy}
GIL 最大的优势就是避免了变量引用计数的抢占问题。
在处理内存管理问题时,Python 解释器使用了引用计数的方式来管理变量,即每个由 Python 解释器创建的对象,都会构建一个引用数属性来跟踪代码中对于该对象的引用次数。当引用数变为 0 时,对象占用的内存空间就会被释放。
下面举个例子来看这种计数方式:
import sys
a = []
b = a
print(sys.getrefcount(a))
代码中对最开始声明的 [] 对象的引用计数是 3,这个对象同时在变量 a,b 和 GetRefCount 的参数三个位置所调用。
引用计数的功能只能在单线程调用时实现。当多线程出现后,引用计数变量的管理上就会出现抢占现象。在不添加 GIL 的情况下,解释器中部分变量所占用的内存可能永远不被释放,又或者在变量仍然在用情况下,解释器就提前释放了变量所在的内存。
0x03 GIL 成为解决方案的原因{#WhyGIL}
在 EuroPython 2016 会议上,Larry Hastings 聊到,在 Python 设计初期,实际上正是 GIL 这个设计的应用才保证了 Python 得以如此流行。
Python 语言早在多线程被大规模运用前的时候就已经在酝酿之中了。Python 设计思路就是变得尽简单易用,先保证尽可能多的开发者能获得更好的开发效率。
鉴于 Python 的许多扩展功能都需要兼容一些 C 语言编写的库,为了避免出现不稳定的不变更,这些 C 语言扩展需要一个线程安全的内存管理方案。
而 GIL 正好提供了这一可能。GIL 易于实现,且易于添加到添加到 Python 当中。此外,由于只有一个线程锁需要管理,GIL 反而为单线程的程序提供了一定的性能提升。
为了更大程度上与硬件的集成,C 语言库本身是不提供线程安全保证的,而这些 C 语言扩展的使用正式 Python 可以被诸多社区轻松接受的原因之一。
正如上文所说,GIL 其实就是一个朴素但是有效的解决方案,为早期 CPython 变得易用,且能支持线程安全。
0x04 多线程对于 Python 程序的影响{#GILInfluence}
现实中大部分程序被分为 CPU 依赖型程序和 IO 依赖型程序,二者是有一定区别的。
CPU 依赖型会大幅提升 CPU 的占用率,包括一些进行数学计算的程序,例如矩阵乘法,目录搜索,图像处理等。
IO 依赖型程序则是将大部分 CPU 时间用于等待来自于用户、文件系统、数据库、网络的 IO 中断。IO 依赖型程序会将大量的运行时间用于等待数据源输入,例如用户打字、数据库检索程序分析索引等。
用一个 CPU 依赖型程序为例:
# single_threaded.py
import time
def countdown(n):
while n > 0:
n -= 1
COUNT = 50000000
start = time.time()
countdown(COUNT)
end = time.time()
print('Time taken in seconds -', end - start)
在4核 CPU 的计算机上运行这个程序,耗时为 6.2s。
$ python single_threaded.py
# Time taken in seconds - 6.20024037361145
使用多线程对这个程序进行优化:
# multi_threaded.py
import time
from threading import Thread
def countdown(n):
while n > 0:
n -= 1
COUNT = 50000000
t1 = Thread(target=countdown, args=(COUNT // 2,))
t2 = Thread(target=countdown, args=(COUNT // 2,))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print('Time taken in seconds -', end - start)
再次执行时,多线程版本程序时间不减反增,达到了 6.9s
$ python multi_threaded.py
# Time taken in seconds - 6.924342632293701
两版程序的运行时间相近,因为多线程版本的程序中,因为Python 解释器的运行会被 GIL 锁住,并不会真正开启额外的线程。
对于一个 CPU 依赖型程序(例如处理图片),在 GIL 的影响下,Python 程序不仅会变为单线程运行,甚至还比单线程运行的程序效率更低。因为在不同线程上获取、释放 GIL 往往会对解释器造成的开销。
不过上面的执行结果是基于 Python2 的,当在 Python3 中运行时,多线程版本又比单线程版本快了 0.2s,因为 Python3 实际上对 GIL 锁做一些优化。
$ python3 single_threaded.py
# Time taken in seconds - 3.01605224609375
$ python3 multi_threaded.py
# Time taken in seconds - 2.8165364265441895
0x05 GIL 无法移除的原因{#WhyNotRemove}
从技术角度看,GIL 绝对是可以移除的,但 Python 必须要承担额外的代价。
事实上,Python 社区的很多开发者都尝试过移除 GIL,但他们无一例外地都破坏了已有的一些 C 语言组件,这些组件都是依赖 GIL 所提供的线程安全特性才能工作。
这时候开发者不得不引入其他解决方案来实现原来由 GIL 提供的线程安全特性,但这些方案都是以牺牲单线程程序和 IO 依赖型多线程程序的性能为代价的。没人会想让自己之前开发过的程序在升级 Python 版本后反而越跑越慢,所以这些方案最终都被废弃了。
Python 的创始人 Guido van Rossum,也是 Python3 最主要的开发者,在 Artima 社区写了一片 Blog 来回复开发者要求清除 GIL 的请求。
“I’d welcome a set of patches into Py3k only if the performance for a single-threaded program (and for a multi-threaded but I/O-bound program) does not decrease.”
而他所提到的不牺牲单线程性能这一标准,到 Python3 发布时仍未有方案可以达成。
0x06 Python3 不移除GIL的原因{#Python3}
Python3 确实是一个重新开始的机会,但这个过程中, GIL的移除需要变更一些 C 扩展库,原有 Python2 的代码就需要通过额外的适配过程才能运行的Python3中,这就导致了 Python3 的一些早期版本被开发者接受得非常缓慢。
但 GIL 在 Python3 中却被保留了下来,因为移除 GIL 将会导致 Python3 运行单线程程序比 Python2 更慢,这种性能降低是不能被开发者所接受的。GIL 对于单线程程序执行效率的支持毋庸置疑。
虽然没有移除 GIL,但 Python3 为 GIL 的执行效率带来了巨大的提升。上面我们讨论过 GIL 对 CPU 依赖型程序和 IO 依赖型程序的影响,但是没有谈到同时受 CPU 和 IO 影响的一类程序。
这类程序中,Python 解释器的性能降低主要来源于执行 IO 依赖型程序的线程无法从执行 CPU 依赖型的线程那里获得 GIL 锁而导致的饿死(stave)现象。
再追溯期根源则在于 Python 解释器内部对 GIL 轮换管理机制,解释器内部会强制线程在获得的 CPU 时间片耗尽时强制释放 GIL,当没有其他线程请求 GIL 时,原来的线程方可继续使用 GIL。
import sys
# The interval is set to 100 instructions:
sys.getcheckinterval()
这种 GIL 轮换机制的问题在于,大多数时间里 CPU 依赖型线程会比其他线程更快获得 GIL。
David Beazely 在 The Python GIL Visualized 这篇博客中,对这种现象做了一个可视化展示。

线程抢占CPU示意图
这个问题在 Python3.2 中被 Antoine Pitrou 修复了,在他编写的 GIL 重构方案中,解释器会先查看目前系统中因未获得 GIL 而挂起的线程数,然后限制原有线程获得 GIL,以保证其他线程有更多机会优先获得 GIL。
0x07 如何在编程中避免 GIL 对性能的限制{#Solution}
多进程代替多线程
目前最常用的解决方案就是使用多进程来代替多线程,每个 Python 解释器进程都会有独立的内存空间,Python 中 multiprocessing 模块就是为此而生的,这里使用多进程模块来重写之前的多线程功能。
from multiprocessing import Pool
import time
COUNT = 50000000
def countdown(n):
while n > 0:
n -= 1
if __name__ == '__main__':
pool = Pool(processes=2)
start = time.time()
r1 = pool.apply_async(countdown, [COUNT // 2])
r2 = pool.apply_async(countdown, [COUNT // 2])
pool.close()
pool.join()
end = time.time()
print('Time taken in seconds -', end - start)
多进程版本的计数程序耗费时间为 4.06s
$ python multiprocess.py
# Time taken in seconds - 4.060242414474487
相比于多线程版本,新版本获得了 2 秒多的提升。
不过双进程版本也并未达到耗时减半的效果,因为进程管理也会有时间损耗,而且进程间切换的耗费比线程间切换要更大,所以某些情况下使用多进程反而会降低程序的执行效率。
使用 CPython 以外的解释器
Python 解释器实际上并非只有 CPython 一个版本,常见的还有由 Java 编写的 Jython,C# 编写的 IronPython,Python 编写的 PyPy。
GIL 只存在于 CPython 这种原生的 Python 实现,如果程序和所需的 Library 能在其他版本的解释器中运行,那 GIL 就不是必须接受的限制。
等待社区的优化
尽管许多 Python 用户依赖着由 GIL 带来的单线程程序的性能提升,多线程用户无需为此烦恼,许多聪明的开发者目前已经在致力于移除 CPython 中的 GIL,其中一个比较有名的解决方案就是 Gilectomy。
结论{#Conclusion}
Python 解释器中 GIL 的存在一直都被当做 Python 语言的问题。但实际上只有在开发 C 语言组件或者编写 CPU 依赖型的多线程程序才会受影响。
如果希望从更底层角度去理解 GIL 内部工作原理,推荐阅读 David Beazley 的 Understading the Python GIL。
