GIL 作为 Python 开发者心中永远的痛,在最近即将到来的更新中,终于要彻底解决了,整个 Python 社群都沸腾了
什么是GIL?
GIL是英文学名global interpreter lock的缩写,中文翻译成全局解释器锁。GIL需要解决的是线程竞争(thread racing)的问题,所以需要理解GIL的作用机制,我们需要了解一些简单的背景知识,包括程序在计算机上运行的机制,线程和进程的区别,还有并行和并发的概念。
程序在计算机上是如何运行的?
我们的程序代码是存储在硬盘上,当启动程序时,cpu会为应用程序启动进程(process),小应用程序只需要一条进程,但大的应用程序如Google Chrome在后台会启动多条进程,对应不同的服务。
每条进程都是独立的运行单元,计算机会为每条进程分配内存空间,并提供必要的计算资源。我们的程序代码在启动进程时会被加载到这个特定的内存区块里作为数据资源供计算机cpu调用。
为了更好地理解,这里我们可以把进程想象成高速路上的某条车道,那线程(thread)自然就是车道上跑的汽车。一个程序可以以单线程的方式运行,就像只有一辆车在车道上跑,当然也可以以多线程的方式运行,这就像很多车在同一车道上跑。
进程之间是相对独立运行的,而在同一进程上运行的线程之间是共享内存的。这是进程和线程最大的区别,这个区别意味着开启多个进程会消耗更多的内存,但开启多个线程不会额外增加内存负担。
另外,如果一条进程挂了,其它进程继续照常运行,但如果一条线程挂了,相应的进程也会挂掉,运行在该进程上的所有线程自然也会跟着挂掉。
这就好比,在高速路上,如果车道1(进程的比照)出现交通事故,那整条车道都将不能使用,但这并不影响其它车道(其它进程继续运行)的正常运行。同样的道理,如果在车道1上有一辆车(线程的比照)发生故障停在车道上不能动了(线程挂了),那其它车辆也不能行驶了,这条车道暂停服务进入交通管制状态(进程挂了),直到故障车被拉出车道,管制消除,车道继续畅通运行(进程重启)。
并行和并发的区别?
理解了进程和线程,并行和并发就很好理解了。
并行和并发是计算机处理任务的两种不同模式。
在并行模式(parallelism)下,多任务可以同时运行,提高了计算机的性能。这里要注意一点,要真正实现并行,程序必须在多核机器上运行,并行任务同时运行在不同的cpu上,在时间片轮上是可以重叠的,如下图中Task 1和Task 3。但我们来看Task 1和Task 2是跑在同一个cpu上的,而且它们是交替进行,这种模式就是并发模式。还有一点很重要,这个问题曾经让我困惑很久。那就是并行模式理论上可以通过多线程并行也可以通过多进程并行。
多进程并行就是在每个cpu上启动新的进程,每个进程都需要分配独立内存空间,而多线程并行是在每个cpu中启动新的线程,该线程共享主线程的内存空间。由于Python GIL的限制,在Python环境中,并行只能通过多进程的模式,而其它语言如Java,C等可以通过多线程的方式进行并行运算。
并发模式(concurrency)基于线程机制,计算机将多个任务分配给在同一进程中的不同线程进行运行,不过在并发模式下多线程不能同时处理任务,但也不是像单线程一样,一个接着一个处理,而是通过交替循环。如上图所示,假设有两个任务Task 1和Task 2分别运行在线程A和B上,在并发模式下,线程A运行一小段时间后cpu把它挂起,并切换到线程B,线程B又运行一小段时间后被挂起,同时切换回线程A,如此循环,直到任务完成。
在实践中,并行和并发不是非此即彼,根据任务的不同程序的运行可以被合理配置,最大程度地利用计算机上的cpu资源。还是用上面的图来举例,我们将任务分成两个平行的任务单元,并将这两个任务单元分配给两个cpu进行并行运算,同时在每个cpu里,任务单位再细分为两个更小的任务(Task 1和Task 2,以及Task 3 和Task 4),这两个小任务将在各个cpu里两个线程中并发运行。
除了这种任务配置模式,还可以有其它的配置方案,比如可以让每个任务单元在某些cpu上进行单线程运行,在某些cpu上开多线程并发运行,这取决于可支配的cpu资源、需要完成的任务的类型以及任务之间的耦合性,需要具体问题具体分析。
那并行和并发运行模式分别适合于什么样的场景呢?
首先计算机所处理的任务大致可以分为计算密集型和IO密集型任务。
计算密集型顾名思义需要大量的cpu算力,比如人工智能模型里大型矩阵运算,图形处理等;而IO密集型任务,是指磁盘IO、网络IO占主要的任务,计算量很小,不需要消耗太多计算资源,大部分在等待的状态,比如向服务器请求数据,线程要等待服务器的响应。
如果我们的任务都是计算密集型的,通过多线程并发模式运行程序并不能带来性能的提高,可能反而比单线程下所花的时间更长,因为首先并发不是同时处理任务,而是短时轮流循环执行子任务,并且在线程切换时需要额外消耗计算机资源,这样情况适合多任务并行运算。
如果计算机需要处理的是IO密集型任务,这时如果用并行模式,我们会浪费很多cpu资源,因为处理IO密集型任务不需要太多计算资源,大部分时间是在等待,所以这种情况适合用单cpu并发模式,好处是只用一个cpu资源,通过交替执行任务,可以在IO任务中的等待时间区间里切换线程去执行其它IO任务,从而更充分地利用了cpu资源。
还有一种情况,如果我们需要处理的任务既有计算密集型任务又有IO密集型任务,那我们可以考虑同时用并行和并发,在分配任务单元时,将计算密集型和IO密集型任务进行混合,如此单个cpu上既有有计算密集型又有IO密集型任务,那采用多线程并发就会极大地提高计算机运行效率,原因是计算机在处理IO密集任务时不需要浪费等待的时间,在IO阻塞的情况,切换线程到计算密集任务,这就用IO等待时间来处理其它计算密集型任务,从而提高程序运行效率。
Python语言中的GIL
Python的解释器是CPython,CPython本身并不确保线程安全(thread safe),也就是解释器不会对多个线程对同一个python对象的操作行为进行约束。这里我们来举个例子。在这个例子中,我们考虑两个线程(1和2)共享同一个变量a,a的初始值为1。我们可以允许两个线程以任何顺序对变量a进行两种写操作,其中,在线程1中,我们修改a的值 a= a+2 ,在线程2中,我们也修改a的值 a = a*2 。取决于两个写操作的顺序,我们可以有以下几种不同情况。
情况一, a= a+2 —> a = a*2 a的最终值为6
情况二, a= a * 2 —> a = a+2 这种情况下的a的最终值为4
情况三,线程1和线程2同时获取变量a,那结果就取决于哪条线程最后修改a的值,如果线程1后修改a的值,那a的最终值就是3,如果线程2后修改a的值,那a的最终值是2。
这三种情况都有可能发生,所以每次运行程序前我们都无法预知a的最终值,这是由于线程竞争带来的side effect,虽然程序员在正常情况下都不会去做线程不安全的操作,即两个线程可以同时以任何顺序修改一个共有变量,但如果对多线程的任务执行顺序不加限制,这种错误在理论上是可能发生的,特别是当业务代码量很大又有多个程序员同时维护开发代码时,犯这种错误的可能性变大。
要避免这种线程不安全的编程行为,要么对程序员的编程行为进行约束(比如Java中有多线程并行编程的一系列安全规范),要么在编程语言层面上设计一种机制杜绝这种不安全的线程行为,Python中的GIT就是提供了这种机制。
GIT的概念很简单,GIT是个全局变量,被所有线程共享。并且,GIT在特定的时刻只允许被一条线程获取,获取GIT锁的线程就拥有执行任务的权限,而其它线程必须先获取GIT锁才可以执行线程任务。
这样一来,Python就断了多线程并行的路子。在Python里,多线程只能并发。如果需要并行,只能通过多进程的模式,具体实现通过内置库multiprocessing。
删除 GIL
现在,Python 团队已经正式接受了删除 GIL 的这个提议,并将其设置为可选模式,可谓是利好广大开发者。
做出这一贡献的是一位来自 Meta 的名叫 Sam Gross 的软件工程师,他花费了四年多的时间才完成这一工程。
在得知这一消息后,大家纷纷叫好,深度学习三巨头之一的 Yann LeCun 发文祝贺:没有了 GIL,现在,Python 代码可以自由的执行多线程了。
CPython 核心开发者 Thomas Wouters 撰文描述了 Python 中的无 GIL 细节,并对未来发展做了展望。
原文翻译如下:
非常感谢所有人对无 GIL 提议的反馈,整体上都持积极的支持态度。指导委员会打算接受无 GIL 提议,并就以下具体细节与大家分享。
我们的基本设想是:
- 长期来看(大约 5 年以上),no-GIL 构建应是唯一的构建;
- 我们希望非常谨慎地对待向后兼容。我们不希望出现另一个 Python 3 的情况,所有适应 no-GIL 构建所需的第三方代码更改应只适用于 with-GIL 构建(尽管仍要解决更老 Python 版本的向后兼容性问题)。这不适用于 Python 4。我们仍在考虑对这两个构建的 ABI 兼容性和其他细节的要求,以及对向后兼容性的影响;
- 在我们承诺完全转向 no-GIL 之前,希望看到社区的支持。我们不能只是更改默认设置,更希望社区弄清自己做什么工作来给予我们支持。我们核心开发团队需要获得新构建模式及相关所有内容的经验。我们要整理现有代码中的线程安全性,需要弄明白新的 C API 和 Python API。我们在获得这些洞见时还需要传达给 Python 社区的其他人,并确保自身想要做出的更改以及希望其他人做出的更改是可取的;
- 在我们默认 no-GIL 设置之前的任何时候,如果事实证明了,它的破坏性太大导致收益太少,我们希望能够改变主意。这也就意味着我们会回滚所有工作,因此在我们确定要将 no-GIL 设为默认方式之前,特定于 no-GIL 的代码在某种程度上应是可识别的。
目前,我们认为未来的道路分为以下三个阶段:
- 短期内,我们会将 no-GIL 构建作为一种实验性构建模式,大概会在 3.13 版本(也有可能推迟到 3.14 版本)可用。之所以是实验性的,是因为我们核心开发团队虽然支持这一构建模式,但不期望整个社区都会支持它。我们需要时间理清自己要做什么,至少在 API 设计以及打包和分发方面,从而得到社区的支持。我们也不鼓励 distributor 将实验性 no-GIL 构建作为默认解释器发布。
- 中期来看,在我们确信得到足够的社区支持并使 no-GIL 的生产使用可行后,我们将支持 no-GIL 构建,但不是默认方式,而是在某个目标日期或某个 Python 版本中使它成为默认方式。具体的时间将取决于很多因素,比如 API 更改最终的兼容性如何、社区认为他们仍然需要做多少工作等。我们预计这至少需要一至两年的时间。一旦我们宣布支持,预计将有一些 distributor 会开始默认发布 no-GIL。
- 长期来看,我们希望 no-GIL 成为默认方式,并删除 GIL 的所有痕迹(但不会不必要地破坏向后兼容性)。我们不希望等太长时间,毕竟两种常用的构建模式同时存在会给社区造成很大的负担(比如需要双倍测试资源和 debug 场景)。但是我们也不能急于求成。我们认为这一过程将需要花费五年的时间。
当然在整个过程中,我们整个开发团队将需要实时评估进程并对时间线进行调整。
评论区的小伙伴们,你们对 GIL 成为可选是什么看法呢?