对 Python 中 GIL 的一点理解
GIL(Global Interpreter Lock),全局解释器锁,是 CPython 为了避免在多线程环境下造成 Python 解释器内部数据的不一致而引入的一把锁,让 Python 中的多个线程交替运行,避免竞争。
需要说明的是 GIL 不是 Python 语言规范的一部分,只是由于 CPython 实现的需要而引入的,其他的实现如 Jython 和 PyPy 是没有 GIL 的。那么为什么 CPython 需要 GIL 呢,下面我们就来一探究竟(基于 CPython 3.10.4)。
为什么需要 GIL
GIL 本质上是一把锁,学过操作系统的同学都知道锁的引入是为了避免并发访问造成数据的不一致。CPython 中有很多定义在函数外面的全局变量,比如内存管理中的 usable_arenas
和 usedpools
,如果多个线程同时申请内存就可能同时修改这些变量,造成数据错乱。另外 Python 的垃圾回收机制是基于引用计数的,所有对象都有一个 ob_refcnt
字段表示当前有多少变量会引用当前对象,变量赋值、参数传递等操作都会增加引用计数,退出作用域或函数返回会减少引用计数。同样地,如果有多个线程同时修改同一个对象的引用计数,就有可能使 ob_refcnt
与真实值不同,可能会造成内存泄漏,不会被使用的对象得不到回收,更严重可能会回收还在被引用的对象,造成 Python 解释器崩溃。
GIL 的实现
CPython 中 GIL 的定义如下
struct _gil_runtime_state {
unsigned long interval; // 请求 GIL 的线程在 interval 毫秒后还没成功,就会向持有 GIL 的线程发出释放信号
_Py_atomic_address last_holder; // GIL 上一次的持有线程,强制切换线程时会用到
_Py_atomic_int locked; // GIL 是否被某个线程持有
unsigned long switch_number; // GIL 的持有线程切换了多少次
// 条件变量和互斥锁,一般都是成对出现
PyCOND_T cond;
PyMUTEX_T mutex;
// 条件变量,用于强制切换线程
PyCOND_T switch_cond;
PyMUTEX_T switch_mutex;
};
最本质的是 mutex 保护的 locked 字段,表示 GIL 当前是否被持有,其他字段是为了优化 GIL 而被用到的。线程申请 GIL 时会调用 take_gil()
方法,释放 GIL时 调用 drop_gil()
方法。为了避免饥饿现象,当一个线程等待了 interval 毫秒(默认是 5 毫秒)还没申请到 GIL 的时候,就会主动向持有 GIL 的线程发出信号,GIL 的持有者会在恰当时机检查该信号,如果发现有其他线程在申请就会强制释放 GIL。这里所说的恰当时机在不同版本中有所不同,早期是每执行 100 条指令会检查一次,在 Python 3.10.4 中是在条件语句结束、循环语句的每次循环体结束以及函数调用结束的时候才会去检查。
申请 GIL 的函数 take_gil()
简化后如下
static void take_gil(PyThreadState *tstate)
{
...
// 申请互斥锁
MUTEX_LOCK(gil->mutex);
// 如果 GIL 空闲就直接获取
if (!_Py_atomic_load_relaxed(&gil->locked)) {
goto _ready;
}
// 尝试等待
while (_Py_atomic_load_relaxed(&gil->locked)) {
unsigned long saved_switchnum = gil->switch_number;
unsigned long interval = (gil->interval >= 1 ? gil->interval : 1);
int timed_out = 0;
COND_TIMED_WAIT(gil->cond, gil->mutex, interval, timed_out);
if (timed_out && _Py_atomic_load_relaxed(&gil->locked) && gil->switch_number == saved_switchnum) {
SET_GIL_DROP_REQUEST(interp);
}
}
_ready:
MUTEX_LOCK(gil->switch_mutex);
_Py_atomic_store_relaxed(&gil->locked, 1);
_Py_ANNOTATE_RWLOCK_ACQUIRED(&gil->locked, /*is_write=*/1);
if (tstate != (PyThreadState*)_Py_atomic_load_relaxed(&gil->last_holder)) {
_Py_atomic_store_relaxed(&gil->last_holder, (uintptr_t)tstate);
++gil->switch_number;
}
// 唤醒强制切换的线程主动等待的条件变量
COND_SIGNAL(gil->switch_cond);
MUTEX_UNLOCK(gil->switch_mutex);
if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) {
RESET_GIL_DROP_REQUEST(interp);
}
else {
COMPUTE_EVAL_BREAKER(interp, ceval, ceval2);
}
...
// 释放互斥锁
MUTEX_UNLOCK(gil->mutex);
}
整个函数体为了保证原子性,需要在开头和结尾分别申请和释放互斥锁 gil->mutex
。如果当前 GIL 是空闲状态就直接获取 GIL,如果不空闲就等待条件变量 gil->cond
interval 毫秒(不小于 1 毫秒),如果超时并且期间没有发生过 GIL 切换就将 gil_drop_request
置位,请求强制切换 GIL 持有线程,否则继续等待。一旦获取 GIL 成功需要更新 gil->locked
、gil->last_holder
和 gil->switch_number
的值,唤醒条件变量 gil->switch_cond
,并且释放互斥锁 gil->mutex
。
释放 GIL 的函数 drop_gil()
简化后如下
static void drop_gil(struct _ceval_runtime_state *ceval, struct _ceval_state *ceval2,
PyThreadState *tstate)
{
...
if (tstate != NULL) {
_Py_atomic_store_relaxed(&gil->last_holder, (uintptr_t)tstate);
}
MUTEX_LOCK(gil->mutex);
_Py_ANNOTATE_RWLOCK_RELEASED(&gil->locked, /*is_write=*/1);
// 释放 GIL
_Py_atomic_store_relaxed(&gil->locked, 0);
// 唤醒正在等待 GIL 的线程
COND_SIGNAL(gil->cond);
MUTEX_UNLOCK(gil->mutex);
if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request) && tstate != NULL) {
MUTEX_LOCK(gil->switch_mutex);
// 强制等待一次线程切换才被唤醒,避免饥饿
if (((PyThreadState*)_Py_atomic_load_relaxed(&gil->last_holder)) == tstate)
{
assert(is_tstate_valid(tstate));
RESET_GIL_DROP_REQUEST(tstate->interp);
COND_WAIT(gil->switch_cond, gil->switch_mutex);
}
MUTEX_UNLOCK(gil->switch_mutex);
}
}
首先在 gil->mutex
的保护下释放 GIL,然后唤醒其他正在等待 GIL 的线程。在多 CPU 的环境下,当前线程在释放 GIL 后有更高的概率重新获得 GIL,为了避免对其他线程造成饥饿,当前线程需要强制等待条件变量 gil->switch_cond
,只有在其他线程获取 GIL 的时候当前线程才会被唤醒。
几点说明
GIL 优化
受 GIL 约束的代码不能并行执行,降低了整体性能,为了尽量降低性能损失,Python 在进行 IO 操作或不涉及对象访问的密集 CPU 计算的时候,会主动释放 GIL,减小了 GIL 的粒度,比如
- 读写文件
- 网络访问
- 加密数据/压缩数据
所以严格来说,在单进程的情况下,多个 Python 线程时可能同时执行的,比如一个线程在正常运行,另一个线程在压缩数据。
用户数据的一致性不能依赖 GIL
GIL 是为了维护 Python 解释器内部变量的一致性而产生的锁,用户数据的一致性不由 GIL 负责。虽然 GIL 在一定程度上也保证了用户数据的一致性,比如 Python 3.10.4 中不涉及跳转和函数调用的指令都会在 GIL 的约束下原子性的执行,但是数据在业务逻辑上的一致性需要用户自己加锁来保证。
下面的代码用两个线程模拟用户集碎片得奖
from threading import Thread
def main():
stat = {"piece_count": 0, "reward_count": 0}
t1 = Thread(target=process_piece, args=(stat,))
t2 = Thread(target=process_piece, args=(stat,))
t1.start()
t2.start()
t1.join()
t2.join()
print(stat)
def process_piece(stat):
for i in range(10000000):
if stat["piece_count"] % 10 == 0:
reward = True
else:
reward = False
if reward:
stat["reward_count"] += 1
stat["piece_count"] += 1
if __name__ == "__main__":
main()
假设用户每集齐 10 个碎片就能得到一次奖励,每个线程收集了 10000000 个碎片,应该得到 9999999 个奖励(最后一次没有计算),总共应该收集 20000000 个碎片,得到 1999998 个奖励,但是在我电脑上一次运行结果如下
{'piece_count': 20000000, 'reward_count': 1999987}
总的碎片数量与预期一致,但是奖励数量却少了 12 个。碎片数量正确是因为在 Python 3.10.4 中,stat["piece_count"] += 1
是在 GIL 约束下原子性执行的。由于每次循环结束都可能切换执行线程,那么可能线程 t1 在某次循环结束时将 piece_count
加到 100,但是在下次循环开始模 10 判断前,Python 解释器切换到线程 t2 执行,t2 将 piece_count
加到 101,那么就会错过一次奖励。
总结
GIL 是 CPython 为了在多线程环境下为了维护解释器内部数据一致性而引入的,为了尽可能降低 GIL 的粒度,在 IO 操作和不涉及对象访问的 CPU 计算时会主动释放 GIL。最后,用户数据的一致性不能依赖 GIL,可能需要用户使用 Lock
或 RLock()
来保证数据的原子性访问。
参考文档
https://realpython.com/python-gil/
https://github.com/python/cpython/commit/074e5ed974be65fbcfe75a4c0529dbc53f13446f
https://mail.python.org/pipermail/python-dev/2009-October/093321.html
https://www.backblaze.com/blog/the-python-gil-past-present-and-future/
对 Python 中 GIL 的一点理解的更多相关文章
- Python中GIL
GIL(global interpreter lock)全局解释器锁 python中GIL使得同一个时刻只有一个线程在一个cpu上执行,无法将多个线程映射到多个cpu上执行,但GIL并不会一直占有,它 ...
- python中对多态的理解
目录 python中对多态的理解 一.多态 二.多态性 三.鸭子类型 python中对多态的理解 一.多态 多态是指一类事物有多种形态,比如动物类,可以有猫,狗,猪等等.(一个抽象类有多个子类,因而多 ...
- python中GIL和线程与进程
线程与全局解释器锁(GIL) 一.线程概论 1.何为线程 每个进程有一个地址空间,而且默认就有一个控制线程.如果把一个进程比喻为一个车间的工作过程那么线程就是车间里的一个一个流水线. 进程只是用来把资 ...
- 对python中元类的理解
1. 类也是对象 在大多数编程语言中,类就是一组用来描述如何生成一个对象的代码段.在Python中这一点仍然成立: >>> class ObjectCreator(object): ...
- Python中“if __name__=='__main__':”理解与总结
1 引言 在Python当中,如果代码写得规范一些,通常会写上一句“if __name__==’__main__:”作为程序的入口,但似乎没有这么一句代码,程序也能正常运行.这句代码多余吗?原理又在哪 ...
- 对于Python中回调函数的理解
关于回调函数,网上有很多说明和各种解释,多数在尝试用语言描述.我认为,如果对各个角色之间的关系不清楚,如果没有相关的编程需求,那么语言便非常无力,很难理解. 这是360百科的解释: 在计算机程序设计中 ...
- Python中的strip()的理解
在看到Python中strip的时候产生了疑问 strip() 用于移除字符串头尾指定的字符(默认为空格) 开始测试: >>> s = 'ncy_123.python' >&g ...
- 对python中的__name__的理解
一开始学习python的时候,不理解python中的__name__的用途,一致感觉__name__的返回结果就是__main__ 今天系统的看了一下,才理解过来,__name__真正的用处是用在使用 ...
- python 中is和== 的理解
Python中的对象包含三要素:id.type.value其中id用来唯一标识一个对象,type标识对象的类型,value是对象的值is判断的是a对象是否就是b对象,是通过id来判断的==判断的是a对 ...
随机推荐
- 论文解读(XR-Transformer)Fast Multi-Resolution Transformer Fine-tuning for Extreme Multi-label Text Classification
Paper Information Title:Fast Multi-Resolution Transformer Fine-tuning for Extreme Multi-label Text C ...
- PAT B1013 数素数
输入样例: 5 27 输出样例: 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101 103 解题思路: 从2开始 ...
- vue上拉加载下拉加载
npm i vue-scroller <scroller :on-refresh="refresh" :on-infinite="infinite" :n ...
- 6.S081-2021-Lab3 Pgtbl学习笔记
Speed up system calls 根据hints查看kernel/proc.c中的函数proc_pagetable // kernel/proc.c // Create a user pag ...
- JAVASE Scanner
package com.huang.boke.flowPath;import java.util.Scanner;public class test01 { public static void ma ...
- JavaScript高级教程
JavaScript高级教程 基础总结深入 数据类型 分类 you are so nb! undefined :undefined string :任意字符串 sybmol: object:任意对象, ...
- linux压缩打包、定时任务
压缩打包 gzip压缩 win中的压缩包:zip rar Linux常见的压缩包有哪些? gzip bzip2 1.gzip压缩 压缩命令:gzip [压缩文件] 解压命令:gzip -d [压缩包] ...
- 关于Vue.cli 脚手架环境中引入Bootstrap时,table表格样式缺失的解决办法
Vue+bootstrap不能正常使用table的样式 环境:下载官网的本地bootstrap包,然后在vue 的index.html引入bootstrap的css和js环境 问题描述:1. vue里 ...
- hutool工具类常用API整理
0.官网学习地址 https://www.hutool.cn/ 1.依赖 <dependency> <groupId>cn.hutool</groupId> < ...
- 2021.08.01 P4359 伪光滑数(二叉堆)
2021.08.01 P4359 伪光滑数(二叉堆) [P4359 CQOI2016]伪光滑数 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn) 题意: 若一个大于 11 的整数 MM ...