购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.4 GIL的性能提升与改进方式

GIL(Global Interpreter Lock,全局解释锁)不只存在于Python中,还存在于其他语言,例如Ruby语言。CPython在实现Python时引入了GIL概念,并在Python 2.X版本中广泛使用,而在Python 3.X版本中更新和改进。本节主要介绍GIL在Python 2和Python 3版本(这里所说的Python 2和Python 3版本指的均是本书中所约定的版本,在下文中如果没有特殊说明,则均采用本书约定版本)中的不同表现形式和不同性能体现,以及在Python 3版本中的性能提升与改进方式。

2.4.1 GIL实现线程安全与性能分析

对于GIL来说,它实际上并不涉及线程安全,或者说,线程安全并不会在GIL中体现,因为GIL在多线程环境下无时无刻不是线程安全的。

在Python中,对于单线程来说,程序从开始执行直到执行完毕,并不会有其他线程来竞争执行的时机和执行的资源,所以,Python中的单线程程序执行永远是安全的。而对于Python中的多线程程序,由于程序在执行过程中会发生多个线程抢占资源的情况,因此多线程在执行时会发生不是每个线程都执行过一遍程序的情况,导致程序执行结果错误,不能得到预期结果。但是,由于Python中引入了GIL,这一现象不会发生,因为在多线程环境下,GIL的引入使得在同一时刻只有一个线程可以执行,其他线程只能等这一线程执行完毕才能执行,以此往复,直到所有Python线程都执行完毕为止。这种方式保证了Python在多线程环境下的安全。

我们以一段简单的Python程序为例,来测试一下在GIL加持的环境下实现线程安全所需要的性能开销,如下所示。

上述代码中,笔者使用一个简单的文件读取程序,来测试在GIL环境中多线程调用所需要的时间和内存开销。测试记录如表2-3所示。

表2-3 GIL环境下多线程调用所需要的时间与内存开销统计

由于Python语言脚本化的编写方式,我们只能通过手动创建多个线程的方式来模拟上述代码段的过程,直到模拟到10个线程结束。从表2-3中可以看出,由于程序本身并不会发生任何改动,所以每一个线程执行该段程序所占用的内存空间都是固定的,即16B。每增加一个可用的Python线程,就会增加一次调用该程序所消耗的时间,所以这个时间的消耗一定是叠加的。通过对不同数量线程所调用该段程序所消耗的时间进行计算,我们可以推导出一个大致的规律:增加的线程数量较原始线程数量始终差值为2,那么增加后的线程执行上述代码的时间消耗总是比原始线程执行上述代码片段的时间消耗多10μs左右。

出现上述规律的主要原因就是多线程在执行代码时受到了Python中GIL的影响。GIL保证了在同一时刻只允许一个Python线程获取锁并最终执行程序,如果先前获取到锁的线程还没有执行完成程序,那么后续线程就会一直等待,直到先前获取到锁的线程执行完毕并释放锁之后,才会获取到锁并执行程序。这个过程会重复执行,直到所有Python线程均执行完毕。如果没有GIL的影响,Python线程在调用程序时,不会按照我们开启Python线程的顺序去执行,更不会出现上述时间消耗的规律,因为Python线程会毫无顺序地调用Python程序,且时间消耗也不会那么稳定,有时消耗的时间长,有时消耗的时间短。

2.4.2 Concurrent模块的引入

通过前文我们知道,在开启Python多线程时,在同一时刻GIL只允许一个线程来执行程序,其他线程只能等待正在执行程序的线程执行完之后才能执行。这就是同步执行任务的表现。通过默认方式来开启Python多线程去执行程序的方法,本质上是同步执行任务,并不是异步执行,这样反而增加了程序执行的时间开销,这在某些业务场景下并不适用。那么,除了threading库之外,Python还提供了哪些方式来实现程序的异步执行?答案是Concurrent模块。

Python的Concurrent模块提供了很多可以异步执行程序的实现方案,当需要异步执行程序时,我们可以使用Concurrent模块提供的方法或函数,这样就可以绕过Python中GIL的影响,大幅提高多线程执行程序的效率,缩短执行程序的时间消耗。

在Concurrent模块中,常用的是futures模块,即concurrent.futures模块中的相关函数。concurrent.futures模块中常用的函数如下。

● concurrent.futures.Executor:这是一个虚拟基类,提供了异步执行的方法。

● submit(function, argument):调度函数(可调用的对象),将argument作为参数传入。

● map(function, argument):将argument作为参数传入,以异步的方式去执行相关任务。

● shutdown(Wait=True):发出让执行者释放所有资源的信号的函数。

● concurrent.futures.Future: Future对象是submit函数到executor的实例,即submit函数异步执行完任务之后的回调函数返回结果。

在concurrent.futures模块中,我们经常使用的是基于Executor基类实现的两个子类——ThreadPoolExecutor和ProcessPoolExecutor,前者实现了Python中线程池的概念,后者实现了Python中进程池的概念。这两个实现类在我们编写多线程异步任务时经常被使用。下面通过一个简单的例子来说明如何使用ThreadPoolExecutor和ProcessPoolExecutor。

上述程序的执行结果如下:

上述例子通过一个循环的加法和乘法操作,来增加CPU执行这段程序的耗时,并将这一耗时的程序交给ThreadPoolExecutor线程池执行。与Java语言不同,在Python中ThreadPoolExecutor类只接收一个参数,那就是max_workers,表示允许ThreadPoolExecutor线程池管理器开启的最大线程数量。在本例中,该参数的值为5,表示在同一时刻,允许ThreadPoolExecutor类最多开启5个线程去执行程序。我们可以看到采用线程池的方式来异步执行上述程序所消耗的时间约为7.63s。如果不使用ThreadPoolExecutor线程池来执行上述程序,而是使用常规的方式去执行,代码如下。

上述程序的执行结果如下:

我们可以清楚地看到,采用传统方式执行上述程序的耗时约为8.94s。通过对比两个执行结果我们可以清楚地看到,采用线程池的方式比采用传统方式执行速度更快,耗时更短。出现这一现象的根本原因就是采用线程池的方式可以规避GIL的一部分影响,直接让CPU去执行,所以,当我们需要编写Python多线程执行程序时,可优先考虑使用concurrent.futures中的函数,因为这样执行效率更高,耗时更短。

2.4.3 替换GIL实现线程安全与性能分析

截至本书完稿时,CPython官方并没有明确给出百分百可行的替换Python中GIL的方法,替换GIL实现线程安全的方法活跃在Python社区,但其不具备在真实的生成环境中大批量使用的条件。所以,本节内容只是为了让广大读者对替换GIL实现线程安全的方法有所了解,如果有读者想要将其用在生成环境中,应该结合实际项目的具体业务场景综合考虑之后再确定。

在Python中,目前主流的替换GIL的方法是使用Nogil解释器来替换原有的Python解释器。Nogil解释器从根本上去除了Python中的GIL,支持直接使用CPU去执行程序。

使用Nogil解释器,首先需要安装Python的虚拟环境容器PyEnv,具体安装PyEnv的方法这里不再展开,大家可自行查阅相关资料。在安装完成PyEnv之后,我们就可以安装Nogil解释器了,相关实现代码如下所示。

执行完上述命令之后,系统即可自动下载并安装Nogil解释器,等待安装完成即可。

在Nogil解释器中,GIL默认是关闭的,我们可以通过如下代码来检查GIL是否开启:

执行上述代码片段中的sys.flags.nogil代码,并使用print函数打印结果,如果打印结果为False,则表示当前Python环境中的GIL是开启的;如果打印结果为True,则表示当前Python环境中的GIL是关闭的。我们可以使用PYTHONGIL=1环境变量参数来修改Nogil解释器中GIL的开启状态。如果想开启GIL,我们可以使用以下命令。

上述代码在启动Python 3的解释器的同时,向Python 3的解释器(即Nogil解释器)声明一个环境变量参数——PYTHONGIL, PYTHONGIL的值是1。执行完上述命令之后,即可在当前Python环境中开启GIL。如果当前Python环境中的GIL是开启的,我们也可以通过上述方式将GIL关闭,以适应不同环境所需。

那么,我们使用Nogil这个不具备GIL的解释器来执行多线程程序,性能会怎样呢?让我们继续以上述示例为例,测试一下使用Nogil解释器来多线程执行相同代码时所消耗的时间,代码如下所示。

出于方便考虑,这里还是采用了上述ThreadPoolExecutor线程池的方式进行测试,只不过所使用的Python解释器换成了完全去除GIL的Nogil解释器,具体的执行结果如图2-7所示。

图2-7 换成去除GIL的Nogil解释器的执行结果

通过图2-7所示的测试结果我们可以清楚地看到,使用Nogil解释器执行相同的ThreadPoolExecutor线程池代码,要比使用Python解释器花费更少的时间。使用Nogil解释器只花费了约5.24s,而使用Python解释器花费了约7.63s,可见GIL对程序执行的影响还是比较大的。

除了使用Nogil解释器来替换Python解释器,还有一个方法也可以实现替换GIL,只不过还没有人完全实现,只是提出了一些相应的概念,且采用这个方法的人少之又少,以至于很多人都在质疑其可行性、准确性和安全性,笔者这里只是简单介绍,如果读者想深入了解,可以自行查阅相关资料。该方法是编写C或C++语言的拓展文件,然后将该拓展文件放入CPython官方的源码,并最终整合成仅属于自己的Python语言。在这个C或C++语言拓展文件中,核心的内容是通过PyObject对象来获取当前Python语言的上下文环境和配置参数,在获取到的配置参数中,含有一个GIL标志的参数。我们可以直接通过手动干预的方式来改变这个参数的值,或者直接禁用这个参数。在修改了这个标志参数之后,我们还需要将修改结果同步到获取到的Python语言的上下文环境中,这样才能达到禁用GIL的目的。不过,关于这个含有GIL标志的参数,CPython官方并没有做出详细的介绍,这就使得个人开发者在编写C或C++拓展文件时无法下手,不知道哪一个参数和GIL有关,而且即使找到了这一参数,在将这个参数修改之后,还需要重新编译CPython源码,以使得修改在整个Python语言的上下文环境中生效,这会花费不少的时间和人力。

上述两种方式是替换Python中GIL的两大思路和方向,这里笔者建议广大读者朋友采用第一种Nogil解释器替换的方式,因为相对于第二种方式来说,这种方式花费的时间最短、所要求开发者的知识储备最少、见效最快。 5tVsiQS+/qv3USiPyKrhA1TgJClCHx3aZASYYPUDTFxiiBhZQeGNyAJLeTb+2pOt

点击中间区域
呼出菜单
上一章
目录
下一章
×