浅谈Python的高并发实现的基本原理(GIL、Cython、Pypy)

浅谈Python的高并发实现的基本原理(GIL、Cython、Pypy)

​ 随着并发概念的引入,越来越多的语言开始设计并实现基于并发概念的程序执行机制。无论是Python语言还是Java语言,都具备并发概念的实现。无论哪种语言的并发机制,都会提升程序的执行效率,为程序提供支持。在Python语言中引入并发机制无疑是一件好事,但也会带来一些挑战。

一、GIL的性能提升与改进

​ GIL ( Global Interpreter Lock, 全局解释锁 )在Python中广泛使用,是在实现Python解析器(CPython)时所引入的一个概念。官方对GIL的解释是

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

​ 本质上是一个互斥锁。下面将详细分析GIL对Python实现线程的影响。

GIL实现线程的分析

​ 对于单线程来说,程序从开始执行到执行结束,并不会有其他线程来竞争执行的时机和执行的资源。而对于Python的多线程程序,由于程序在执行中会发生多个线程抢占资源的情况,导致程序执行得不到预期的结果。但是由于Python中引入GIL,这一现象不会发生,因为在多线程环境下,GIL使得同一时刻只有一个线程可以执行,其他线程只能等待该线程执行完毕后才能执行。

​ 我们以一段简单的程序测试GIL下线程实现的开销

def readFile():
    print("thread: "+str(threading.current_thread().name)+" start")
    start = time.time()
    with open('gilText.txt','r') as f:
        data = f.read()
        print(data)
    end = time.time()
    print(end - start)

    t1 = Thread(target=readFile)
    t2 = Thread(target=readFile)
    t1.start
    t2.start

使用线程数量246810
执行耗时/us1323334353
占有内存/B1616161616

​ 我们使用一个简单的文件读取程序,手动创建多个线程,通过测试其使用耗时发现:增加的线程数量相较于原始线程数量始终差2,而增加线程后执行上述代码的时间消耗总是比原始线程代码的时间消耗多10us左右。即每增加一个可用线程,就会增加一些调用该程序所消耗的时间。

​ 出现上述规律的主要原因是多线程在执行时受到了GIL 的影响。如果先获取到GIL的线程还没有执行完毕,后续线程就会一直等待,直到先前获取到锁的执行完毕并释放锁后,才会获取到锁并执行程序。如果没有GIL的影响,Python在调用线程时,不会按照我们开启的线程顺序去执行,更不会出现上述时间消耗的规律。线程会毫无顺序的调用,且消耗时间也不会这么稳定,而是有的时间长、有的时间短。

Concurrent模块

​ 通过默认方式开启Python多线程的方法,本质上是同步执行任务,并不是异步执行。这样反而会增加程序的开销。除了threading库外,Python的Concurrent模块提供了很多可以异步执行程序的实现方案。我们可以使用Concurrent模块的函数或方法,这样就可以绕过GIL的影响,大幅提升高多线程的执行效率。

​ 在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函数异步执行完成任务之后的回调函数返回的结果

​ 我们常使用基于Executor积累实现的两个子类——ThreadPoolExecutor 和 ProcessPoolExxecutor。前者实现了线程池的概念,后者实现进程池的概念。下面通过简单的例子来说明如何使用

import concurrent.futures
import time
number_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

def evaluate_item(x):
    result_item = count(x)
    return result_item

def count(number):
    for i in range(0, 100000000):
        i = i + 1
    return i * number

start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(evaluate_item,item) for item in number_list]
    for future in concurrent.futures.as_completed(futures):
        print(future.result())
print("Thread pool execution in "+str(time.time() - start_time),"seconds")

运行结果如下:

运行结果1

​ 上述例子通过一个循环加法和乘法的操作,增加CPU处理这段程序的耗时,并交由ThreadPoolExecutor线程池执行。ThreadPoolExecutor类只接受一个参数max_workers,表示ThreadPoolExecutor线程池允许开启的最大数量。在本例中,该参数的值为5,表示同一时刻ThreadPoolExecutor最多允许开启5个线程执行程序。该程序的运行耗时约为20.6s,如果不使用线程池,而是使用常规方法,代码如下

import time
number_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

def evaluate_item(x):
    result_item = count(x)
    return result_item

def count(number):
    for i in range(0, 100000000):
        i = i + 1
    return i * number

start_time = time.time()
for item in number_list:
    print(evaluate_item(item))
print("Thread pool execution in "+str(time.time() - start_time),"seconds")

执行结果如下

运行结果2

​ 可以清晰的看到,采用传统方式执行程序的耗时约为30.6s。采用对比的方式可以发现,利用线程池执行多线程程序相比于传统方式速度更快、耗时更短,其根本原因在于线程池可以规避一部分GIL的影响。所以编写多线程程序时,可以优先考虑使用concurrent.futures中的相关函数。

替换GIL实现线程

​ 截至作者写这篇文章时,Cpython官方并未给出完全可行的替换GIL的方法。替换GIL的方法活跃在社区中,但并不具备在实际生产中大规模使用的条件。因此,本文只对替换GIL做简单的介绍,至于是否应用于实际项目开发中,应结合实际业务和应用场景综合考虑后再确定。

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

​ 想要安装Nogil解释器,首先要安装Python虚拟环境容器PyEvn,这里不做过多介绍,读者可自行查阅资料。在安装完PyEvn后,我们就可以安装Nogil解释器了。

pyevn install nogil-3.9.10

执行上述命令后,系统会自动安装Nogil解释器。在Nogil解释器中,GIL默认是关闭的状态,我们可以通过以下代码检测GIL是否开启

import sys
print(sys.flags.nogil)

执行上述代码后,如果打印False,则表示当前环境的GIL是开启的,反之如果为True,则表示当前环境的GIL是关闭的。我们可以通过PYTHONGIL 环境变量参数来改变GIL的开启状态。如果令PYTHONGIL=1 则开启GIL,如果令PYTHONGIL =0也可以关闭GIL,以适应不同的开发需求。具体开启代码如下

PYTHONGIL=1 python3 

​ 此外社区中还存在其他替换GIL的方法,如编写C或C++语言扩展放入CPython官方的源码,并重新编译后形成 自己的Python语言。但这类方法目前还未有人实现,难度高,且其可行性也未经证实。

​ 好消息是,Python团队已经官宣将在未来的Python版本中可选择性关闭GIL,在去除GIL这一工作上,为了确保兼容性,其难度不亚于重写整个CPython内核。这只能说这条路任重而道远,需要大量的时间进行摸索,而真正实现可选择性关闭GIL可能需要漫长的时间,短期内无法达到。

python团队对GIL的说法

二、基于Cython的代码执行性能优化

​ Cython与CPython类似,都可以直接使用Python代码编写,都可以处理Pyhton代码。不同的是Cython是一款基于C语言语法、使用Pyrex语言进行开发的Python代码编译器。Pyrex语言是专门为编写Python拓展而研发的, 结合了C 语言和Python语言的语法规范。开发者可以直接使用Pyrex语言, 基于Cython 的Python 编译器编写合适的Python语言拓展模块,以满足不同业务场景对Python语言的拓展需求。我们都知道,CPython是使用C语言实现的Python语言标准, 是一款官方的 Python 解释器或虚拟机。对Python 程序的编译只是 CPython解释器或虚拟机的一部分, 换句话说, CPython解释器或虚拟机中并没有专门针对Python程序进行编译的编译器,而是将编译作为功能的一部分进行同步解释操作。Cython则是一款独立于CPython解释器或虚拟机的专门用于编译Python程序的编译器。Cython更多地被开发者称为Python程序编译器, 而不是 Python解释器或虚拟机。

​ 那么, Cython是通过哪些措施来保证 Python代码的高效运行呢?

​ 首先, Cython 是 Python 程序的编译器, 并不是像CPython 那样作为解释器或虚拟机,在处理Python代码时会直接对Python代码进行编译, 而不会像CPython解释器或虚拟机那样先解释判断,再进行编译。在这个过程中, Cython 编译器比CPython解释器或虚拟机少一个环节。假定开发者编写的Python代码非常复杂且比较长,同时使用Cython 编译器和CPython解释器或虚拟机进行处理,可能就会出现在CPython虚拟机或解释器刚开始编译 Python代码, Cython 编译器就已经编译完毕的情况。

​ 其次, Cython在对Python代码进行处理时, 并不会将Python源代码文件编译成Python 语言类型的字节码文件, 而是将Python的源代码文件直接编译成pyx文件, 开发者可以自行尝试解析pyx文件。pyx 文件是结合C语言和Python语言语法而生成的二进制文件,而计算机可以直接识别和读取二进制文件。所以,由Cython 编译生成的pyx文件较CPython解释器或虚拟机编译生成的pyc文件更加节省内存,因为 pyx 文件直接在内存中存储二进制码,而 pyc文件在内存中存储的是 Python字节码。由于计算机可以直接识别二进制码,不需要其他的转换工作,所以pyx文件要比pyc文件运行速度至少快2~4倍,因为在运行过程中,计算机省去了将Python字节码转换成二进制码的时间消耗。

三、基于Pypy的Python代码执行性能优化

​ CPython解释器或虚拟机在处理Python代码时, 只能从 Python 源代码文件开始运行时进行处理,而不能在Python源代码文件运行期间进行处理,即Python源代码文件正在运行,但此时修改了Python源代码文件中的代码内容,CPython解释器或虚拟机并不会及时对修改了的内容进行编译,只能重新运行该Python源代码文件,这样对Python代码的修改才能生效。这种处理机制使得开发者在每次修改Python代码后都需要手动重新执行,并不能做到类似于热部署。针对该现状,Python生态社区决定开发一款具有即时编译的Python编译器,以满足更高性能的需要,于是 Pypy 即时编译器就诞生了。

​ Pypy 是一款可以即时编译Python代码的编译器, 并不是像CPython那样的解释器或虚拟机。研发Pypy的目的只有一个,那就是即时执行Python代码,从而更准确地管理Python 代码执行所占的内存和所花的时间。Pypy编译器内部存在一个即时编译器,可以确保Python代码被即时编译,即Pypy会定时重新编译已经存在于即时编译器中的Python代码,并且会重复编译多次,以确定Python代码有没有被使用、有无增删、是否已经停止运行。Pypy编译器识别到某些Python代码已经不再被使用,就会对这些Python代码进行回收,以释放所占用的内存空间。关于Pypy中的垃圾回收机制,笔者在后续会进行介绍。Pypy即时编译器识别到 Python 源代码文件中的Python代码有了增删改动,则会自动重新编译这些Python代码,以使这些Python代码的改动立即生效。Pypy即时编译器识别到Python 代码已经停止运行,就会立即停止编译工作和重复编译工作,直到Python 源代码文件再次被执行才唤醒。对于某一具体的Python源代码文件,Pypy编译器会重复上述过程,直到执行完所有的Python代码。

​ Pypy 即时编译和重复编译的特性,可以使正在运行的Python项目代码得到优化,具体表现在对于同一使用Python实现的功能,经过Pypy即时编译器的重复编译,可以省略一些不必要的Python代码,并忽略掉和功能无关、无用的代码,从而达到节省Python项目所用内存空间、提升Python代码运行速度的目的。
但是,使用Pypy编译器来提高Python代码性能需要牺牲一定的兼容性,这是由于CPython 解释器或虚拟机是基于Python标准实现,而在这个标准实现中并不存在即时编译器这一概念,所以,Pypy即时编译器并不能很好地规避这一点的影响,导致有些基于CPython的函数库或第三方库无法在Pypy即时编译器中进行编译和识别。如果有不支持的Python函数库或第三方库存在于需要即时编译的Python源代码文件中,Pypy即时编译器并不会报错或终止运行,而是会为这些不支持的Python函数库或第三方库申请一个固定的内存,并将这些不支持的Python函数库或第三方库进行存储。

​ 综上所述,我们在使用Pypy即时编译器时一定要清楚地知道,Python源代码文件中是否存在Pypy无法进行编译的基于CPython的函数库或第三方库,如果存在,就要考虑是否可以用其他库进行替换,或者是否可以不使用这些库,从而省去不必要的内存开销。

  • 13
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值