• Python 与 GIL 究竟是怎样的关系


    what's the GIL

      GIL 全称:全局解释器锁(Global Interpreter Lock),是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行。即便在多核处理器上,使用 GIL 的解释器也只允许同一时间执行一个线程,常见的使用 GIL 的解释器有 CPython 与 Ruby MRI。GIL 并不是 Python 独有的特性,是解释型语言处理多线程问题的一种机制而非语言特性。

      GIL 本质是一把互斥锁,与其他所有互斥锁的本质一样,都是将并发运行变成串行,以此来控制同一时间内共享数据只能被一个任务所修改,进而保证数据安全。可以肯定的一点是:保护不同的数据的安全,就应该加不同的锁。

    GIL 与 Python 的爱恨纠缠

      Python 是一门解释器语言,代码通过解释器执行,Python 存在多种解释器,分别基于不同语言开发,每个解释器有不同的特点。

    • CPython:主流版本的解释器,使用C语言编写的,是使用最为广泛的解释器,可以方便地和 C/C++ 的类库进行交互,因此也是最受关注的解释器。
    • Jython:由 java 语言编写的 Python 解释器,是将 Python 编译成 Java 字节码然后执行的一种解释器,可以方便地和 Java 的类库进行交互。
    • IronPython:将 Python 代码解释为 .Net 平台上运行的字节码进行执行,类似 Jython 解释器,可以方便的和 .Net 平台上的类库进行交互。简称 IPython,在交互效果上有所增强,但执行过程和功能方面和 CPython 是一样的。
    • PyPy:使用 JIT(just-in-time) 技术的编译器,专注于执行速度,对 Python 代码进行动态编译,从而提高 Python 的执行速度。PyPy 在处理 Python 代码的过程中,一小部分功能的处理和 CPython 的执行结果是有差异的,如果项目中要使用 PyPy 来进行执行效率的提升的话,一定要事先了解下 PyPy 和 CPython 的区别。

    GIL 产生的背景

      Python 是 Guido van Rossum(吉多.范罗苏姆,被称为 Python 之父) 在1989年发布的,那个时候计算机的主频还没有达到 1G,程序全部都是运行在单核计算机上面,直到 2005 年多核处理器才被 Intel 开发出来。戈登·摩尔 1965 年预测,每个集成电路的元件数量每 18 到 24 个月就会翻一倍,它的适用性预计会持续到 2015-2020 年。摩尔定律未失效前软件系统可以单纯借助硬件的进步来获得性能的提升或者只需少量改进,就可以坐享性能飞跃。

      然而从 2005 年开始,时钟速率的增长和晶体管数量的增长已不再同步。由于处理器材料的物理性质限制,时钟速率已停止增长甚至下降,处理器制造商开始将更多执行单元核心封装到单个芯片中。这一趋势给应用程序开发和编程语言设计带来越来越大的压力。

      程序员和编程语言决策者不得不考虑如何快速适应多核硬件,来提高软件性能和编程语言的市场占有率,Python 也不例外受到冲击。

      在单核时代,崇尚优美、清晰、简单的 Python 之父选择在解释器层面实现了一把全局互斥锁,来保护 Python 对象从而实现对单核 CPU 的使用率,这种做法在单核时代很奏效。倘若在单核时未选择 GIL,那么开发者就需要自己实现任务的管理,这样做对于 CPU 的利用率提高无法做到极致。

      但是随着多核时代的到来,高效地利用 CPU 核心的有效方法就是使用并行性,多线程是充分实现并行的好方法,但是 CPython 的 GIL 却阻碍了对多核 CPU 的利用。

     

    多核时代难去难从的 GIL

      CPython 的 GIL 给使用者带来了便利,并且在 GIL 的基础上开发了许多重要的 Package 和语言功能。但是多核 CPU 的普适和其他语言对 Python 的冲击,让 GIL 显得原始而粗暴,无法有效利用多核处理器成为了弊端。

    GIL 与并发

      要搞清楚 GIL 对多线程程序的影响就要了解 GIL 的运行基本原理。

    • 单核CPU情况

        CPython 的 Pthread 是通过操作系统调度算法调度执行。

        Python 解释器每执行一定数量的字节码,或遇到系统 IO 时,会强制释放 GIL,然后触发一次操作系统的线程调度,实现单核 CPU 的充分利用,并且在单核上释放和重新执行的时间间隔非常短。

    • 多核CPU情况

        多核情况下多线程执行时,一个线程在 CPU-A 执行完之后释放 GIL,其他 CPU上 的线程都会进行竞争,但 CPU-A 可能又马上获取到了 GIL。这就导致其他 CPU 上被唤醒的线程只能眼巴巴地看着 CPU-A 上的线程再次执行,而自己只能等待,直到又被切换到待调度的状态。这就会产生多核 CPU 频繁进行线程切换,消耗着资源,但只有一个线程能够拿到 GIL 真正执行 Python 代码,从而导致多线程在多核 CPU 情况下,效率还不如单线程执行效率高。

        这种情况非常类似于网络编程中的多个线程监听同一端口造成的惊群现象,只不过是CPU级别的,造成的浪费更加奢侈。

    GIL 与 Python

      首先确定一点:每次执行 Python 程序,都会产生一个独立的进程。例如执行命令 python test.py,python aaa.py,python bbb.py 会产生 3 个不同的 Python 进程

      以 python test.py 距离,在这个 Python 进程内,不仅有 test.py 的主线程或(者由该主线程开启的其他线程),还有解释器开启的垃圾回收等解释器级别的线程,所有线程都运行在这一个进程内。该进程内所有数据都是共享的,这其中,代码作为一种数据也是被所有线程共享的(test.py的所有代码以及 Cpython 解释器的所有代码) 例如:test.py 定义一个函数 work(代码内容如下图),在进程内所有线程都能访问到 work 的代码。所有线程的任务,都需要将任务的代码当做参数传给解释器的代码去执行,即所有的线程要想运行自己的任务,首先需要解决的是能够访问到解释器的代码。

      如果多个线程的 target=work,那么执行流程是:多个线程先访问到解释器的代码,即拿到执行权限,然后将 target 的代码交给解释器的代码去执行。解释器的代码是所有线程共享的,所以垃圾回收线程也可能访问到解释器的代码而去执行,这就导致了一个问题:对于同一个数据 100,可能线程 1 执行 x=100 的同时,而垃圾回收执行的是回收 100 的操作,解决这种问题没有什么高明的方法,就是加锁处理,如下图的 GIL,保证 Python 解释器同一时间只能执行一个任务的代码

    综上所述,由于 GIL 的存在,同一时刻同一进程中只有一个线程被执行。这就造成了一个困惑:进程可以利用多核,但是开销大,而 Python 的多线程开销小,但却无法利用多核优势,貌似 Python 没用了……

    要解决这个问题,需要在几个点上达成一致:

    1. CPU 是用来做计算的,还是用来做I/O的?
    2. 多 CPU,意味着可以有多个核并行完成计算,所以多核提升的是计算性能
    3. 每个 CPU 一旦遇到 I/O 阻塞,仍然需要等待,所以多核对 I/O 操作没什么用处

      一个工人相当于 CPU,此时计算相当于工人在干活,I/O 阻塞相当于为工人干活提供所需原材料的过程,工人干活的过程中如果没有原材料了,则工人干活的过程需要停止,直到等待原材料的到来。如果工厂干的大多数任务都要有准备原材料的过程(I/O密集型),那么有再多的工人,意义也不大,还不如一个人,在等材料的过程中让工人去干别的活。反过来讲,如果工厂原材料都齐全,那当然是工人越多,效率越高(计算密集型,也称 CPU 密集型)

    结论:

    • I/O 密集型:在单核 CPU上执行多线程时由解释器实现了有效的切换,这一点是很有益处的。在 I/O 密集型的(如网络爬虫等)类型的程序即使使用 GIL 控制下的多线程程序性能也不会像你想象中那么糟糕。
    • CPU 密集型:对于 CPU 密集型的计算类程序 GIL 就有比较大的问题,因为 CPU 密集型的程序本身没有太多等待,不需要解释器介入并且所有任务只能等待 1 个核心,其他核心空闲也无法使用,这么看对多核的使用确实很糟糕。

      对计算来说,CPU 越多越好,但是对于 I/O 来说,再多的 CPU 也没用

      当然对运行一个程序来说,随着 CPU  的增多执行效率肯定会有所提高(不管提高幅度多大,总会有所提高),这是因为一个程序基本上不会是纯计算或者纯 I/O,所以只能相对的去看一个程序到底是计算密集型还是 I/O 密集型,从而进一步分析 Python 的多线程到底有无用武之地

    抛弃和优化 GIL

      GIL 一直备受争议,为此 PEP 也多次尝试删除或者优化 GIL,但是解释器本身的复杂性和众多 GIL 下的类库都让 GIL 移除成为遥不可及的想法。

      在 1999 年针对 Python 1.5,一个 free threading 补丁已经尝试实现了这个想法,该补丁来自 Greg Stein。在这个补丁中,GIL 被完全的移除,且用细粒度的锁来代替。然而,GIL 的移除给单线程程序的执行速度带来了一定的代价。当用单线程执行时,速度大约降低了40%。使用两个线程展示出了在速度上的提高,但除了这个提高,这个收益并没有随着核数的增加而线性增长。由于执行速度的降低,这一补丁被拒绝了,并且几乎被人遗忘。

      1999 年多核还是个幻想,但是在现今移除 GIL 也异常困难,真的移除效果如何也是未知的,只能说回头太难。

      2009 年 Antoine Pitrou 在Python 3.2 中实现了一个新的 GIL,并且带着一些积极的结果。这是 GIL 的一次最主要改变,旧的 GIL 通过对 Python 指令进行计数来确定何时放弃 GIL。单条 Python 指令将会包含大量的工作,在新的 GIL 实现中,用一个固定的超时时间来指示当前的线程以放弃这个锁,使得线程间的切换更加可预测。

    GIL 缺陷的解决方法

      Python 作为生命力极强的热门语言,绝对不会在多核时代坐以待毙。即便有 GIL 的限制,仍然有许多方法让程序拥抱多核。

    • 多进程:Python2.6 引入了 MultiProcess 库来弥补 Threading 库中 GIL 带来的缺陷,基于此开发多进程程序,每个进程有单独的 GIL,避免多进程之间对 GIL 的竞争,从而实现多核的利用,但是也带来一些同步和通信问题,这也是必然会出现的。
    • Ctypes:CPython 的优势就是与 C 模块的结合,因此可以借助 Ctypes 调用 C 的动态库来实现将计算转移,C 动态库没有 GIL 可以实现对多核的利用。
    • 协程:协程也是一个很好的手段,在 Python3.4 之前没有对协程的支持,存在一些三方库的实现,比如 gevent 和 Tornado。Python3.4 之后就内置了 asyncio 标准库真正实现了协程这一特性。

    GIL 仍然是 Python 语言里最困难的技术挑战,GIL 问题并不是编程语言的本身问题,换做其他语言只是将问题转移到了用户层面,相反 Python 的作者尝试将这种问题转移到解释器给使用者呈现一个优雅的语言。

    虽然多核时代的到来暴露了 GIL 的缺陷,但是 Python 决策者和社区开发者已经做出了许多其他措施来拥抱多核,无知地诟病 GIL 是不明智的做法。如同生产关系要适应生产力的发展一样,抛开历史背景谈机制的优劣,都是有失偏颇的,所以对待 GIL 要辩证看待。

    参考:https://www.cnblogs.com/linhaifeng/articles/7449853.html

      https://mp.weixin.qq.com/s?__biz=MzI0OTc0MzAwNA==&mid=2247491714&idx=1&sn=304399c327c149aaf5882f35ee523f30

  • 相关阅读:
    【数据分析】6 点发的文章在博客园阅读量最高?
    canvas api 速记
    leetcode 315. Count of Smaller Numbers After Self 两种思路
    一道闭包题题解
    一道经典JS题(关于this)
    HTML5 — 让拖放变的流行起来
    你应该知道的25道Javascript面试题
    关于base64编码的原理和实现
    Python中的None
    Python字符编码和转码
  • 原文地址:https://www.cnblogs.com/zhuminghui/p/14107127.html
Copyright © 2020-2023  润新知