深入理解 python 虚拟机:GIL 源码分析——天使还是魔鬼?

深入理解 python 虚拟机:GIL 源码分析——天使还是魔鬼?

在目前的 CPython 当中一直有一个臭名昭著的问题就是 GIL (Global Interpreter Lock ),就是全局解释器锁,他限制了 Python 在多核架构当中的性能,在本篇文章当中我们将详细分析一下 GIL 的利弊和 GIL 的 C 的源代码。

选择 GIL 的原因

GIL 对 Python 代码的影响

简单来说,Python 全局解释器锁或 GIL 是一个互斥锁,只允许一个线程保持 Python 解释器的控制权,也就是说在同一个时刻只能够有一个线程执行 Python 代码,如果整个程序是单线程的话,这也无伤大雅,但是如果你的程序是多线程计算密集型的程序的话,这对程序的影响就很大了。

因为整个虚拟机都有一把大锁进行保护,所以虚拟的代码就可以认为是单线程执行的,因此不需要做线程安全的防护,直接按照单线程的逻辑就行了。不仅仅是虚拟机,Python 层面的代码也是这样,对于有些 Python 层面的多线程代码也可以不用锁保护,因为本身就是线程安全的:

import threadingdata = []def add_data(n):for i in range(n):data.append(i)if __name__ == '__main__':ts = [threading.Thread(target=add_data, args=(10,)) for _ in range(10)]for t in ts:t.start()for t in ts:t.join()print(data)print(len(data))print(sum(data))

在上面的代码当中,当程序执行完之后 len(data) 的值永远都是 100,sum(data) 的值永远都是 450,因为上面的代码是线程安全的,可能你会有所疑惑,上面的代码启动了 10 个线程同时往列表当中增加数据,如果两个线程同时增加数据的时候就有可能存在线程之间覆盖的情况,最终的 len(data) 的长度应该小于 100 ?

上面的代码之所以是线程安全的原因是因为 data.append(i) 执行 append 只需要虚拟机的一条字节码,而在前面介绍 GIL 时候已经谈到了,每个时刻只能够有一个线程在执行虚拟机的字节码,这就保证了每个 append 的操作都是原子的,因为只有一个 append 操作执行完成之后其他的线程才能够执行 append 操作。

我们来看一下上面程序的字节码:

  5           0 LOAD_GLOBAL              0 (range)2 LOAD_FAST                0 (n)4 CALL_FUNCTION            16 GET_ITER>>    8 FOR_ITER                14 (to 24)10 STORE_FAST               1 (i)6          12 LOAD_GLOBAL              1 (data)14 LOAD_METHOD              2 (append)16 LOAD_FAST                1 (i)18 CALL_METHOD              120 POP_TOP22 JUMP_ABSOLUTE            8>>   24 LOAD_CONST               0 (None)26 RETURN_VALUE

在上面的字节码当中 data.append(i) 对应的字节码为 (14, 16, 18) 这三条字节码,而 (14, 16) 是不会产生数据竞争的问题的,因为他只是加载对象的方法和局部变量 i 的值,让 append 执行的方法是字节码 CALL_METHOD,而同一个时刻只能够有一个字节码在执行,因此这条字节码也是线程安全的,所以才会有上面的代码是线程安全的情况出现。

我们再来看一个非线程安全的例子:

import threading
data = 0
def add_data(n):global datafor i in range(n):data += 1if __name__ == '__main__':ts = [threading.Thread(target=add_data, args=(100000,)) for _ in range(20)]for t in ts:t.start()for t in ts:t.join()print(data)

在上面的代码当中对于 data += 1 这个操作就是非线程安全的,因为这行代码汇编编译成 3 条字节码:

  9          12 LOAD_GLOBAL              1 (data)14 LOAD_CONST               1 (1)16 INPLACE_ADD

首先 LOAD_GLOBAL,加载 data 数据,LOAD_CONST 加载常量 1,最后执行 INPLACE_ADD 进行加法操作,这就可能出现线程1执行完 LOAD_GLOBAL 之后,线程 2 连续执行 3 条字节码,那么这个时候 data 的值已经发生变化了,而线程 1 拿的还是旧的数据,因此最终执行的之后会出现线程不安全的情况。(实际上虚拟机在执行的过程当中,发生数据竞争比这个复杂很多,这里只是简单说明一下)

GIL 对于虚拟机的影响

除了上面 GIL 对于 Python 代码层面的影响,GIL 对于虚拟机来说还有一个非常好的作用就是他不会让虚拟机产生死锁的现象,因为整个虚拟机只有一把锁🔒。

对于虚拟机的内存管理和垃圾回收来说,GIL 可以说极大的简化了 CPython 内部的内存管理和垃圾回收的实现。我们现在举一个内存管理和垃圾回收的多线程情况会出现数据竞争的场景:

在 Python 当中的垃圾回收是采用引用计数的方式进行处理,如果没有 GIL 那么就会存在多个线程同时对一个 CPython 对象的引用计数进行增加,而现在因为 GIL 的存在也就不需要进行考虑这个问题了。

另外一个比较重要的场景就是内存的申请和释放:在虚拟机内部并不是直接调用 malloc 进行实现的,在 CPython 内部自己实现了一个内存池进行内存的申请和释放(这么做的原因主要是节省内存),因为是自己实现内存池,因此需要保证线程安全,而现在因为有 GIL 的存在,虚拟机实现内存池只需要管单线程的情况,所以使得整个内存管理变得更加简单。

GIL 对与 Python 的第三方 C 库开发人员来说也是非常友好的,当他们在进行第三方库开发的时候不需要去考虑在修改 CPython 对象的线程安全问题,因为已经有 GIL 了。从这个角度来说 GIL 在一定程度上推动了 Python 的发展和普及。

GIL 带来的问题

GIL 带来的最主要的问题就是当你的程序是计算密集型的时候,比如数学计算、图像处理,GIL 就会带来性能问题,因为他无法在同一个时刻跑多个线程。

之所以没有在 Python 当中删除 GIL,最主要的原因就是目前很多 CPython 第三方库是依赖 GIL 这个特性的,如果直接在虚拟机层面移除 GIL,就会破坏 CPython C-API 的兼容性,这会导致很多依赖 GIL 的第三方 C 库发生错误。而向后兼容这个特性对于社区来说非常重要,这就是目前 CPython 还保留 GIL 最主要的原因。

GIL 源代码分析

在本小节当中为了更好的说明 GIL 的设计和源代码分析,本小节使用 CPython2.7.6 的 GIL 源代码进行分析(这种实现方式在 Python 3.2 以后被优化改进了,在本文当中先不提及),我还翻了一下更早的 CPython 源代码,都是使用这种方式实现的,可能细节方面可以会有点差异,我们现在来分析一下 GIL 具体是如何实现的,下面的代码是一 GIL 加锁和解锁的代码以及锁的数据结构表示:

// PyThread_type_lock 就是 void* 的 typedef
void 
PyThread_release_lock(PyThread_type_lock lock)
{pthread_lock *thelock = (pthread_lock *)lock;int status, error = 0;// dprintf 一个宏定义 都是打印消息的,不需要关心,而且默认是不打印dprintf(("PyThread_release_lock(%p) called\n", lock));// 上锁status = pthread_mutex_lock( &thelock->mut );CHECK_STATUS("pthread_mutex_lock[3]");// 释放全局解释器锁thelock->locked = 0;// 解锁status = pthread_mutex_unlock( &thelock->mut );CHECK_STATUS("pthread_mutex_unlock[3]");// 因为释放了全局解释器锁,现在需要唤醒一个被阻塞的线程/* wake up someone (anyone, if any) waiting on the lock */status = pthread_cond_signal( &thelock->lock_released );CHECK_STATUS("pthread_cond_signal");
}// waitflag 表示如果没有获取锁是否需要等待,如果不为 0 就表示没获取锁就等待,即线程被挂起
int 
PyThread_acquire_lock(PyThread_type_lock lock, int waitflag)
{int success;pthread_lock *thelock = (pthread_lock *)lock;int status, error = 0;dprintf(("PyThread_acquire_lock(%p, %d) called\n", lock, waitflag));status = pthread_mutex_lock( &thelock->mut );CHECK_STATUS("pthread_mutex_lock[1]");success = thelock->locked == 0;// 如果没有上锁,则获取锁成功,并且上锁if (success) thelock->locked = 1;status = pthread_mutex_unlock( &thelock->mut );CHECK_STATUS("pthread_mutex_unlock[1]");if ( !success && waitflag ) {/* continue trying until we get the lock *//* mut must be locked by me -- part of the condition* protocol */status = pthread_mutex_lock( &thelock->mut );CHECK_STATUS("pthread_mutex_lock[2]");// 如果现在已经有线程获取到锁了,就将当前线程挂起while ( thelock->locked ) {status = pthread_cond_wait(&thelock->lock_released,&thelock->mut);CHECK_STATUS("pthread_cond_wait");}// 当线程被唤醒之后,就说明线程只有当前线程在运行可以直接获取锁thelock->locked = 1;status = pthread_mutex_unlock( &thelock->mut );CHECK_STATUS("pthread_mutex_unlock[2]");success = 1;}if (error) success = 0;dprintf(("PyThread_acquire_lock(%p, %d) -> %d\n", lock, waitflag, success));return success;
}

pthread_lock 的结构体如下所示:

其中锁的结构体如下所示:

typedef struct {char             locked; /* 0=unlocked, 1=locked *//* a <cond, mutex> pair to handle an acquire of a locked lock */pthread_cond_t   lock_released;pthread_mutex_t  mut;
} pthread_lock;

熟悉 pthread 编程的话,上面的代码应该很轻易可以看懂,我们现在来分析一下这个数据结构:

  • locked,表示全局解释器锁 GIL 是否有线程获得锁,0 表示没有,1 则表示目前有线程获取到了这把锁。

  • lock_released,主要是用于线程的阻塞和唤醒的,如果当前有线程获取到全局解释器锁了,也就是 locked 的值等于 1,就将线程阻塞(执行pthread_cond_wait),当线程执行释放锁的代码 (PyThread_release_lock) 的时候就会将这个被阻塞的线程唤醒(执行 pthread_cond_signal )。

  • mut,这个主要是进行临界区保护的,因为对于 locked 这个变量的访问是线程不安全的,因此需要用锁进行保护。

在上面的代码当中我们详细介绍了 GIL 的实现源代码,但是还没有介绍虚拟机是如何使用它的。虚拟机在使用 GIL 的时候会有一个问题,那就是如果多个线程同时在虚拟机当中跑的时候,一个线程获取到锁了之后如果一直执行的话,那么其他线程不久饥饿了吗?因此虚拟机需要有一种机制保证当有多个线程同时获取锁的时候不会让线程饥饿。

在 CPython 当中为了不让线程饥饿有一个机制,就是虚拟机会有一个 _Py_Ticker 记录当前线程执行的字节码的个数,让执行的字节码个数超过 _Py_CheckInterval (虚拟机这只这个值为 100) 的时候就会释放锁,然后重新获取锁,在这释放和获取之间就能够让其他线程有机会获得锁从而进行字节码的执行过程。相关的源代码如下所示:

if (--_Py_Ticker < 0) { // 每执行完一个字节码就进行 -- 操作,这个值初始化为 _Py_CheckIntervalif (*next_instr == SETUP_FINALLY) {/* Make the last opcode beforea try: finally: block uninterruptible. */goto fast_next_opcode;}_Py_Ticker = _Py_CheckInterval; // 重新将这个值设置成 100tstate->tick_counter++;
#ifdef WITH_TSCticked = 1;
#endif// 这个主要是处理异常信号的 不用管if (pendingcalls_to_do) {if (Py_MakePendingCalls() < 0) {why = WHY_EXCEPTION;goto on_error;}if (pendingcalls_to_do)/* MakePendingCalls() didn't succeed.Force early re-execution of this"periodic" code, possibly aftera thread switch */_Py_Ticker = 0;}
#ifdef WITH_THREAD// 如果有 GIL 存在if (interpreter_lock) {/* Give another thread a chance */if (PyThreadState_Swap(NULL) != tstate)Py_FatalError("ceval: tstate mix-up");PyThread_release_lock(interpreter_lock); // 首先释放锁/* 其他线程的代码在这就能够运行了 *//* Other threads may run now */// 然后获取锁PyThread_acquire_lock(interpreter_lock, 1);if (PyThreadState_Swap(tstate) != NULL)Py_FatalError("ceval: orphan tstate");}
#endif
}

GIL 的挣扎

在上面的内容当中我们详细讲述了 GIL 的原理,我们可以很明显的发现其中的问题,就是一个时刻只有一个线程在运行,限制了整个虚拟机的性能,但是整个虚拟机还有一个地方可以极大的提高整个虚拟机的性能,就是在进行 IO 操作的时候首先释放 GIL,然后在 IO 操作完成之后重新获取 GIL,这个 IO 操作是广义上的 IO 操作,也包括网络相关的 API,只要和设备进行交互就可以释放 GIL,然后操作执行完成之后重新获取 GIL。

在虚拟机的自带的标准库模块当中,就有很多地方使用了这种方法,比如文件的读写和关闭,我们以文件关闭为例看一下 CPython 是如何操作的:

static int
internal_close(fileio *self)
{int err = 0;int save_errno = 0;if (self->fd >= 0) {int fd = self->fd;self->fd = -1;/* fd is accessible and someone else may have closed it */if (_PyVerify_fd(fd)) {// 释放全局解释器锁 这是一个宏 会调用前面的释放锁的函数Py_BEGIN_ALLOW_THREADSerr = close(fd);if (err < 0)save_errno = errno;// 重新获取全局解释器锁 也是一个宏 会调用前面的获取锁的函数Py_END_ALLOW_THREADS} else {save_errno = errno;err = -1;}}if (err < 0) {errno = save_errno;PyErr_SetFromErrno(PyExc_IOError);return -1;}return 0;
}

这就会使得 Python 虽然有 GIL ,但是在 IO 密集型的程序上还是能打的,比如在网络数据采集等领域, Python 还是有很大的比重。

总结

在本篇文章当中详细介绍了 CPython 选择 GIL 的原因,以及 GIL 对于 Python 程序和虚拟机的影响,最后详细分析了一个早期版本的 GIL 源代码实现。GIL 可以很大程度上简化虚拟机的设计与实现,因为有一把全局锁,整个虚拟机的开发就会变得更加简单,这种简单对于大型项目来说是非常重要的。同时这对 CPython 第三方库的开发者来说也是福音。最后讨论了 CPython 当中 GIL 的实现和使用方式以及 CPython 使用 ticker 来保证线程不会饥饿的问题。


本篇文章是深入理解 python 虚拟机系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩内容合集可访问项目:https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/105099.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java SPI(Service Provider Interface)

Java SPI是Java标准库提供的一种服务发现机制&#xff0c;它通过在classpath下约定的META-INF/services目录中&#xff0c;定义接口和其实现类之间的对应关系&#xff0c;从而动态加载目标接口的实现类。 通过一个实际例子来具体看一下 1、定义接口 public interface Animal …

【使用教程】在Ubuntu下PMM60系列一体化伺服电机通过SDO跑循环同步位置模式详解

本教程将指导您在Ubuntu操作系统下使用SDO&#xff08;Service Data Object&#xff09;来配置和控制PMM60系列一体化伺服电机以实现循环同步位置模式。我们将介绍必要的步骤和命令&#xff0c;以确保您能够成功地配置和控制PMM系列一体化伺服电机。 01.准备工作 在正式介绍之…

一种更具破坏力的DDoS放大攻击新模式

近日&#xff0c;内容分发网络&#xff08;CDN&#xff09;运营商Akamai表示&#xff0c;一种使网站快速瘫痪的DDoS放大攻击新方法正在被不法分子所利用。这种方法是通过控制数量巨大的中间设备&#xff08;middlebox&#xff0c;主要是指配置不当的服务器&#xff09;&#xf…

【VR】【Unity】白马VR课堂系列-VR开发核心基础03-项目准备-VR项目设置

【内容】 详细说明 在设置Camera Rig前,我们需要针对VR游戏做一些特别的Project设置。 点击Edit菜单,Project Settings,选中最下方的XR Plugin Management,在右边面板点击Install。 安装完成后,我们需要选中相应安卓平台下的Pico VR套件,关于怎么安装PICO VR插件,请参…

PyCharm运行Nosetests并导出测试报告

1. Pycharm运行Nosetests PyCharm可以使用两种方法&#xff0c;运行Nosetests测试文件&#xff1a; 1) 图形用户界面GUI a) 在PyCharm中&#xff0c;选中测试文件&#xff0c;如Tests/test_demo.py b) 鼠标右键选择Run Nosetests in test_demo.py即可执行测试 注1&#xff…

Python 自定义模块和包设计英语生词本(文件版)

上一篇&#xff1a;Python 自定义包和模块随机生成6位验证码&#xff08;详解版&#xff09;-CSDN博客 紧接上一篇博文&#xff0c;当我们熟练掌握如何自定义模块和包后&#xff0c;就要开始着手运用模块和包设计自己的小程序。这里我以“英语生词本”为例&#xff0c;来看我如…

vue 请求代理 proxy

目录 为什么需要配置代理 什么是同源策略 如何配置代理 请求代理的原理 举例说明 为什么需要配置代理 因为浏览器的同源策略&#xff0c;当向和本地 devServer 服务器不同源的地址发送请求&#xff0c; 会违反浏览器的同源策略&#xff0c;导致发送失败&#xff0c;所以需…

极简c++(4)类的静态成员

静态数据成员 ::是作用域操作符&#xff01; #include<iostream> using namespace std;class Point{private:int x,y;public:point(int x 0,int y 0):x(x),y(y){}~point();int getX(){return x;}int getY(){return x;} }假设需要统计点的个数&#xff0c;考虑添加一个…

【MySQL】聚合查询与分组查询

我们先重建一个test库&#xff0c;在test库里新建一个people表&#xff08;包含序列号&#xff0c;姓名&#xff0c;工资&#xff09;&#xff0c;再往表该表里新增六条数据&#xff1a; mysql> drop database if exists test; Query OK, 1 row affected (0.07 sec)mysql>…

【mfc/VS2022】计图实验:绘图工具设计知识笔记

绘制曲线&#xff08;贝塞尔曲线&#xff09;&#xff1a; 转自&#xff1a;CDC 类 | Microsoft Learn 绘制一条或多条贝塞尔曲线。 BOOL PolyBezier(const POINT* lpPoints,int nCount);参数 lpPoints 指向包含曲线端点和控制点的 POINT 数据结构数组。 nCount 指定 lpPo…

git 如何删除本地分支且并没有完全合并到目标分支中

git 如何删除本地分支且并没有完全合并到目标分支中 确保你当前所在的分支不是dev_welc_share_permission分支&#xff0c;如果是的话&#xff0c;可以执行以下命令切换到其他分支&#xff08;比如master&#xff09;&#xff1a; git checkout master使用以下命令删除dev_s…

使用kaliber与imu_utils进行IMU、相机+IMU联合标定

目录 1 标定工具编译 1.1 IMU标定工具 imu_utils 1.2 相机标定工具 kaliber 2 标定数据录制 3 开始标定 3.1 IMU标定 3.2 相机标定 3.3 相机IMU联合标定 4 将参数填入ORBSLAM的文件中 1 标定工具编译 1.1 IMU标定工具 imu_utils 标定IMU我们使用imu_utils软件进行标定…

router和route的区别?

router&#xff08;路由器&#xff09;和route&#xff08;路由&#xff09;是计算机网络中的两个概念&#xff0c;它们有不同的含义和作用。 Router&#xff08;路由器&#xff09;&#xff1a; 路由器是一种网络设备&#xff0c;用于连接多个网络&#xff0c;并在网络之间转发…

如何使用前端包管理器(如npm、Yarn)?

聚沙成塔每天进步一点点 ⭐ 专栏简介 前端入门之旅&#xff1a;探索Web开发的奇妙世界 欢迎来到前端入门之旅&#xff01;感兴趣的可以订阅本专栏哦&#xff01;这个专栏是为那些对Web开发感兴趣、刚刚踏入前端领域的朋友们量身打造的。无论你是完全的新手还是有一些基础的开发…

Linux CentOS8安装gitlab_ce步骤

1 下载安装包 wget --content-disposition https://packages.gitlab.com/gitlab/gitlab-ce/packages/el/8/gitlab-ce-15.0.2-ce.0.el8.x86_64.rpm/download.rpm2 安装gitlab yum install policycoreutils-python-utilsrpm -Uvh gitlab-ce-15.0.2-ce.0.el8.x86_64.rpm3 更新配…

在conda创建的虚拟环境中安装jupyter以及使用

1. 进入你的虚拟环境 conda activate conda_env_name 2. 安装jupyter notebook conda install -y jupyter 3. 启动jupyter jupyter notebook 4. 将conda环境添加到jupyter的内核中 conda install ipykernel python -m ipykernel install --name conda_env_namepython -m…

Stm32_标准库_12_串口_发送数据

波特率&#xff1a;约定的传输速率&#xff0c;1000bps,1s发1000位 引脚 结构 数据帧的传输特点 代码&#xff1a; #include "stm32f10x.h" // Device header #include "Delay.h" #include "OLED.h"GPIO_InitTypeDef GPIO_InitStruct; USART…

2023年建筑电工(建筑特殊工种)证考试题库及建筑电工(建筑特殊工种)试题解析

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 2023年建筑电工(建筑特殊工种)证考试题库及建筑电工(建筑特殊工种)试题解析是安全生产模拟考试一点通结合&#xff08;安监局&#xff09;特种作业人员操作证考试大纲和&#xff08;质检局&#xff09;特种设备作业人…

【算法-动态规划】两个字符串的删除操作-力扣 583

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:kuan 的首页,持续学…

Idea创建springboot工程的时候,发现pom文件没有带<parent>标签

今天创建springboot工程&#xff0c;加载maven的时候报错&#xff1a; 这个问题以前遇到过&#xff0c;这是因为 mysql-connector-j 没有带版本号的原因&#xff0c;但是springboot的依赖的版本号不是都统一交给spring-boot-starter-parent管理了吗&#xff0c;为什么还会报错&…