我们在前面的几节课里讲了Python的并发编程的特性,也了解了多线程编程。事实上,Python的多线程有一个非常重要的话题——GIL(Global Interpreter Lock)。我们今天就来讲一讲这个GIL。

一个不解之谜

我们先来看一看这个例子:

def CountDown(n):
while n>0:
n -= 1

现在,我们假设有个很大的数字n=100000000,我们来试试单线程的情况下 执行这个函数,然后看看怎么执行的

import time
def main():
start_time = time.perf_counter()
n = 100000000
CountDown(n)
end_time = time.perf_counter()
print('take {} seconds'.format(end_time-start_time)) if __name__ == '__main__':
main() ##########运行结论##########
take 8.2776216 seconds

这还是在我的第八代i7的笔记本上运行的结论,这时候我们想要用多线程的方式来加速,比如下面的操作

from threading import Thread
n = 100000000
t1 = Thread(target=CountDown,args=[n//2])
t2 = Thread(target=CountDown,args=[n//2])
t1.start()
t2.start()
t1.join()
t2.join()

运行一下,发现时间变成了13.39秒,可以再加两个线程试一下,发现和两个线程的结论基本一样。是怎么回事呢?是机器出问题了么?

我们可以找一个单核CPU的机器来跑一下上面的代码,可以发现在单核CPU的电脑上,单线程的运行时间和多线程的时间基本一致,虽然不像前面的那个,多线程反而比单线程更慢,但这两次的结论几乎是一样的啊!

这么看来就不是电脑出问题了,那就是Python的线程失效了,并没有起到并行计算的作用。那就可以在推一下:Python的线程是不是假的线程呢?

Python的线程,的确封装了底层的操作系统线程,在Linux系统里是Pthread(全称为POSIX Thread),而在Windows里是Windows Thread。另外,Python的线程,也完全受操作系统的管理,比如协调合适执行,管理内存资源,管理中断等待。

所以,虽然Python的线程和C++的线程本质上是不同的抽象,但是他们的底层并没什么不同。

为什么有GIL

看来并不是电脑出了问题或者是线程失效或者Python线程失效两个问题,那么谁才是“罪魁祸首”呢?这就引出了今天的主角——GIL,导致了Python线程并不想我们希望的那样。

GIL是最流行的Python解释器CPython中的一个技术用语,他的意思是全局解释器锁,本质上类似于操作系统的Mutex,每一个Python线程,在CPython解释器中执行时,都会先锁住自己的线程,组织别的线程执行。

当然,CPython会做一些小把戏,轮流执行Python线程,这样一来,用户就看到伪并行的效果——Python程序在交错的执行,来模拟出来并行的线程。

那么,为什么CPython需要GIL呢?其实这和CPython的实现有关,我们下一节会将Python的内存管理机制,今天就先点一下。

CPython使用引用计数器来管理内存,所有的Python脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有0时,则会自动释放内存。

我们可以看看下面的例子

>>> a = []
>>> b = a
>>> c = a
>>>
>>> import sys
>>> sys.getrefcount(a)

在上面的例子中,a的引用计数是4,因为有a,b,c和作为参数传递的getrefcount这几个地方,都引用了一个空裂波。

这样一来,如果有两个Python线程同时引用了a,就会造成引用计数的race condition,引用计数可能最终只增加1,这样就会造成内存污染。因为第一个线程结束的时候,会把引用结束减少1,这时候可能达到条件释放内存,当第二个线程再试图访问a时,就找不到有效的内存了。

所以说,CPython引入GIL其实就是这么两个原因:

一是设计者为了规避类似内存管理这样的复杂的竞争风险问题(race condition)

二是因为CPython会大量使用C语言库,但大部分C语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。

GIL是如何工作的?

可以看一看下面这张图,就是一个GIL在Python程序中的工作示例。其中,线程1、2、3轮流执行,每一个线程在执行是,都会锁住GIL,以阻止别的线程执行;同样的,每一个线程执行一段后,会释放GIL,以允许别的线程开始利用资源。

仔细看一下就可能发现一个问题:为什么Python线程会主动释放GIL呢?毕竟,如果仅仅是要求Python线程在开始的时候锁住GIL而不去释放GIL,那别的线程就都没有运行的机会了。

所以CPython中还有另外一个机制:check_interval,意思是CPython解释器会去轮询检查线程GIL的锁住情况,每隔一段时间,Python解释器就会强制当前线程去释放GIL,这样别的线程才能有执行的机会。

不同版本的Python中,check_interval的实现方式是不一样的,早起的Python是100个ticks,大概对应了1000个bytecodes;而Python3以后,interval是15ms。当然,我们不必喜酒具体多久会强制释放GIL,这不该成为我们设计程序的依赖条件,我们只需明白,CPython解释器会在一个合理的时候释放GIL就可以了。

整体来说,每一个Python的线程都是类似这样循环的封装,我们可以看看下面的代码

for(;;){
if(--ticker < 0)
/*Give another thread a chance*/
PyThread_release_lock(interpreter_lock); /*Other threads may run now*/ PyThread_acquire_lock(interpreter_lock,1);
} bytecode = *next_instr++
switch (bytecode){
/*execute the next instruction...*/
}

从上面的代码可以看出来,每个Python线程都会先检查ticker计数。只有ticker大于0的时候,线程才会去执行自己的bytecode。

Python的线程安全

不过,即便是有了GIL也不意味着我们Python编程这就不用去考虑线程安全了,计时我们知道,GIL仅允许一个Python线程执行,但前面我们也讲到了,Python还有check interval这样的抢占机制。我们看一段下面的代码

import threading

n = 0

def foo():
global n
n += 1 threads = [] for i in range(100):
t = threading.Thread(target=foo)
threads.append(t) for t in threads:
t.start() for t in threads:
t.join() print(n)

我们执行一下,会发现大部分打印结论都是100,但偶尔还是能输出一个98或99的。

这是因为n+=1这句代码让线程并不安全,如果我们去查foo这个函数的bytecode的话,就会发现他是由下面四行bytecode组成

import dis
dis.dis(foo) ##########输出##########
[Running] python -u "d:\python\Python核心技术实战\GIL.py"
47 0 LOAD_GLOBAL 0 (n)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_GLOBAL 0 (n)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE

而这四行bytecode中间是有可能被打断的。

所以,不要想着有了GIL以后程序就可以高枕无忧了,我们仍然需要注意线程安全。正如开头说的,GIL的设计,主要是为了方便CPython解释器层面的编写者,而不是Python应用层面的程序员。作为Python的作者,我们还是需要lock等工具,来确保线程安全。就像下面的例子

n = 0
lock = threading.Lock() def foo():
global n
with lock
n += 1

如何绕过GIL?

看到这里,可能很多的Python使用者就会觉得自己好像被废掉了武功一样,其实大可不必,Python的GIL,是通过CPython的解释器加的限制,如果我们的代码不需要通过CPython解释器来执行,就不再受GIL的限制。

事实上,许多高性能的应用场景都已经拥有了C实现的Python库,例如NumPy的矩阵运算,就都是通过C来实现的,并不受到GIL的影响。所以,大多数情况下,我们是不用过多的考虑GIL的。因为如果多线程的计算成为性能瓶颈,往往已经有别的Python来解决这个问题了。

换句话说,如果我们的应用对性能有着超级严格的要求,比如100μs就对应用有着非常大的影响,那只能说明Python已经不是一个最优的选择了。

但是,我们可以理解的是,我们难以避免有些时候需要临时的摆脱GIL,例如在深度学习的应用里,大部分代码都是Python的,这时候如果我们想自己定义一个微分算子,或者一个特定的硬件加速器,那我们就不得不把这些关键性能(Performance-critical)的代码在C++中实现,然后在提供一个Python调用的接口。

总得来说,若图哦想绕过GIL就是这两种思路:

1.绕过CPython,使用JPython等别的解释器实现

2.把关键性能的代码放在别的语言中(一般常用的都是C++)中实现

总结

今天我们通过一个实际的例子,了解了GIL对应用的影响,之后我们有剖析的GIL的实现原理,我们不用深究原理上的一些细节,只要明白主要机制和存在的隐患就可以了。

最后还提出了两种绕过GIL的思路,不过还是正如前面讲的,大多数时候我们都不必过多纠结GIL的影响。

思考题

最后还是留个思考题:

1.为什么在处理第一个例子中类似cpu-bound任务的时候,为什么使用多线程反而比单线程还要买一些呢?

2.GIL到底是一个好的设计么?事实上,在Python3之后,有很多关于GIL改进或是取消的讨论,我们的看法是什么?

  由于cpu-bound属于计算密集型操作,用多线程运行时,每个线程在开始执行的时候都会锁住GIL,执行完毕后会释放GIL,并且在进行线程切换的时候都会对上下文进行保存和读取,这都是占用CPU资源的操作。相比而言单线程就没有这些资源损耗,所以能更快的执行程序。

  返回到Python诞生的时代,由于那个时代的CPU都是单核单线程的,GIL就是合理而且有效率的。并且为多线程的程序提供了性能上的提升。至于具体的作用还可以参照以前写的博客——Python之线程与进程,里面还给出了GIL的官方文档的连接。可以看一看!

Python核心技术与实战——十九|一起看看Python全局解释器锁GIL的更多相关文章

  1. 人生苦短之我用Python篇(线程/进程、threading模块:全局解释器锁gil/信号量/Event、)

    线程: 有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元.是一串指令的集合.线程是程序中一个单一的顺序控制流程.进程内一个相对独立的.可调度的执行单元,是 ...

  2. python 线程队列、线程池、全局解释器锁GIL

    一.线程队列 队列特性:取一个值少一个,只能取一次,没有值的时候会阻塞,队列满了,也会阻塞 queue队列 :使用import queue,用法与进程Queue一样 queue is especial ...

  3. python 什么是全局解释器锁GIL

    什么是全局解释器锁GIL Python代码的执行由Python 虚拟机(也叫解释器主循环,CPython版本)来控制,Python 在设计之初就考虑到要在解释器的主循环中,同时只有一个线程在执行,即在 ...

  4. Python全局解释器锁 -- GIL

    首先强调背景: 1.GIL是什么?GIL的全称是Global Interpreter Lock(全局解释器锁),来源是python设计之初的考虑,为了数据安全所做的决定. 2.每个CPU在同一时间只能 ...

  5. python 多线程编程之使用进程和全局解释器锁GIL

    本文主要介绍如何在python中使用线程. 全局解释器锁: python代码的执行是由python虚拟机(又名解释器主循环)进行控制的.python中,主循环中同时只能有一个控制线程在执行,就像单核C ...

  6. Python如何规避全局解释器锁(GIL)带来的限制

    编程语言分类概念介绍(编译型语言.解释型语言.静态类型语言.动态类型语言概念与区别) https://www.cnblogs.com/zhoug2020/p/5972262.html Python解释 ...

  7. Python核心技术与实战——十六|Python协程

    我们在上一章将生成器的时候最后写了,在Python2中生成器还扮演了一个重要的角色——实现Python的协程.那什么是协程呢? 协程 协程是实现并发编程的一种方式.提到并发,肯很多人都会想到多线程/多 ...

  8. Python核心技术与实战——十八|Python并发编程之Asyncio

    我们在上一章学习了Python并发编程的一种实现方法——多线程.今天,我们趁热打铁,看看Python并发编程的另一种实现方式——Asyncio.和前面协程的那章不太一样,这节课我们更加注重原理的理解. ...

  9. Python核心技术与实战——十五|深入了解迭代器和生成器

    我们在前面应该写过类似的代码 for i in [1,2,3,4,5]: print(i) for in 语句看起来很直观,很便于理解,比起C++或Java早起的 ; i<n;i++) prin ...

随机推荐

  1. Linux中编译C文件

    C/C++程序编译的过程 预处理,展开头文件,宏定义,条件编译处理等.通过gcc -E source.c -o source.i或者cpp source.c生成. 编译.这里是一个狭义的编译意义,指的 ...

  2. 【Linux开发】linux设备驱动归纳总结(十):1.udev&misc

    linux设备驱动归纳总结(十):1.udev&misc xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...

  3. Kafka的知识总结

    前言 转自(https://www.cnblogs.com/zhuifeng523/p/12081204.html) Kafka是最初由Linkedin公司开发,是一个分布式.支持分区的(partit ...

  4. java导入导出Excel文件

    package poi.excel; import java.io.IOException; import java.io.InputStream; import java.io.OutputStre ...

  5. # 「银联初赛第一场」自学图论的码队弟弟(dfs找环+巧解n个二元一次方程)

    「银联初赛第一场」自学图论的码队弟弟(dfs找环+巧解n个二元一次方程) 题链 题意:n条边n个节点的连通图,边权为两个节点的权值之和,没有「自环」或「重边」,给出的图中有且只有一个包括奇数个结点的环 ...

  6. 蚂蚁分类信息商家发布文章、商品外链及远程图片自动添加nofollow属性

    蚂蚁商户发布文章.商品是可以添加外链或者直接用外部图片,但是这对分类网站运营不利. 所以要对外链进行过滤,演示网站保洁,蚂蚁分类的源码. 下面就说下怎么处理自动给外链自动加上nofollow属性. 1 ...

  7. python-day31(正式学习)

    一.单机架构 应用领域: 植物大战僵尸 office 二.CS架构 应用领域: QQ 大型网络游戏 计算机发展初期用户去取数据,直接就去主机拿,从这里开始就分出了客户端和服务端. 客户端:用户安装的软 ...

  8. pycharm 快捷键练习 和基本英语单词练习

    通过练习 一下快捷键 打代码的速度得到提升 pycharm以下 快捷键+快捷键意义 ctrl+a 全选 ctrl+c 复制(不选中默认复制一行) ctrl+v 粘贴 ctrl+x 剪切 ctrl+f ...

  9. EM 算法(一)-原理

    讲到 EM 算法就不得不提极大似然估计,我之前讲过,请参考我的博客 下面我用一张图解释极大似然估计和 EM 算法的区别 EM 算法引例1-抛3枚硬币 还是上图中抛硬币的例子,假设最后结果正面记为1,反 ...

  10. selenium与页面交互

    selenium提供了许多API方法与页面进行交互,如点击.键盘输入.打开关闭网页.输入文字等. 一.webdriver对浏览器提供了很多属性来对浏览器进行操作,常用的如图: get(url).qui ...