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

2023-10-15 03:44

本文主要是介绍深入理解 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、计算机系统基础、算法与数据结构)知识。

这篇关于深入理解 python 虚拟机:GIL 源码分析——天使还是魔鬼?的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/215124

相关文章

Python的Darts库实现时间序列预测

《Python的Darts库实现时间序列预测》Darts一个集统计、机器学习与深度学习模型于一体的Python时间序列预测库,本文主要介绍了Python的Darts库实现时间序列预测,感兴趣的可以了解... 目录目录一、什么是 Darts?二、安装与基本配置安装 Darts导入基础模块三、时间序列数据结构与

Python正则表达式匹配和替换的操作指南

《Python正则表达式匹配和替换的操作指南》正则表达式是处理文本的强大工具,Python通过re模块提供了完整的正则表达式功能,本文将通过代码示例详细介绍Python中的正则匹配和替换操作,需要的朋... 目录基础语法导入re模块基本元字符常用匹配方法1. re.match() - 从字符串开头匹配2.

Python使用FastAPI实现大文件分片上传与断点续传功能

《Python使用FastAPI实现大文件分片上传与断点续传功能》大文件直传常遇到超时、网络抖动失败、失败后只能重传的问题,分片上传+断点续传可以把大文件拆成若干小块逐个上传,并在中断后从已完成分片继... 目录一、接口设计二、服务端实现(FastAPI)2.1 运行环境2.2 目录结构建议2.3 serv

通过Docker容器部署Python环境的全流程

《通过Docker容器部署Python环境的全流程》在现代化开发流程中,Docker因其轻量化、环境隔离和跨平台一致性的特性,已成为部署Python应用的标准工具,本文将详细演示如何通过Docker容... 目录引言一、docker与python的协同优势二、核心步骤详解三、进阶配置技巧四、生产环境最佳实践

Python一次性将指定版本所有包上传PyPI镜像解决方案

《Python一次性将指定版本所有包上传PyPI镜像解决方案》本文主要介绍了一个安全、完整、可离线部署的解决方案,用于一次性准备指定Python版本的所有包,然后导出到内网环境,感兴趣的小伙伴可以跟随... 目录为什么需要这个方案完整解决方案1. 项目目录结构2. 创建智能下载脚本3. 创建包清单生成脚本4

Python实现Excel批量样式修改器(附完整代码)

《Python实现Excel批量样式修改器(附完整代码)》这篇文章主要为大家详细介绍了如何使用Python实现一个Excel批量样式修改器,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一... 目录前言功能特性核心功能界面特性系统要求安装说明使用指南基本操作流程高级功能技术实现核心技术栈关键函

python获取指定名字的程序的文件路径的两种方法

《python获取指定名字的程序的文件路径的两种方法》本文主要介绍了python获取指定名字的程序的文件路径的两种方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要... 最近在做项目,需要用到给定一个程序名字就可以自动获取到这个程序在Windows系统下的绝对路径,以下

使用Python批量将.ncm格式的音频文件转换为.mp3格式的实战详解

《使用Python批量将.ncm格式的音频文件转换为.mp3格式的实战详解》本文详细介绍了如何使用Python通过ncmdump工具批量将.ncm音频转换为.mp3的步骤,包括安装、配置ffmpeg环... 目录1. 前言2. 安装 ncmdump3. 实现 .ncm 转 .mp34. 执行过程5. 执行结

Python实现批量CSV转Excel的高性能处理方案

《Python实现批量CSV转Excel的高性能处理方案》在日常办公中,我们经常需要将CSV格式的数据转换为Excel文件,本文将介绍一个基于Python的高性能解决方案,感兴趣的小伙伴可以跟随小编一... 目录一、场景需求二、技术方案三、核心代码四、批量处理方案五、性能优化六、使用示例完整代码七、小结一、

Python中 try / except / else / finally 异常处理方法详解

《Python中try/except/else/finally异常处理方法详解》:本文主要介绍Python中try/except/else/finally异常处理方法的相关资料,涵... 目录1. 基本结构2. 各部分的作用tryexceptelsefinally3. 执行流程总结4. 常见用法(1)多个e