GIL(Global Interpreter Lock),全局解释器锁,是 CPython 为了避免在多线程环境下造成 Python 解释器内部数据的不一致而引入的一把锁,让 Python 中的多个线程交替运行,避免竞争。

需要说明的是 GIL 不是 Python 语言规范的一部分,只是由于 CPython 实现的需要而引入的,其他的实现如 Jython 和 PyPy 是没有 GIL 的。那么为什么 CPython 需要 GIL 呢,下面我们就来一探究竟(基于 CPython 3.10.4)。

为什么需要 GIL

GIL 本质上是一把锁,学过操作系统的同学都知道锁的引入是为了避免并发访问造成数据的不一致。CPython 中有很多定义在函数外面的全局变量,比如内存管理中的 usable_arenasusedpools,如果多个线程同时申请内存就可能同时修改这些变量,造成数据错乱。另外 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->lockedgil->last_holdergil->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,可能需要用户使用 LockRLock() 来保证数据的原子性访问。

参考文档

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 的一点理解的更多相关文章

  1. Python中GIL

    GIL(global interpreter lock)全局解释器锁 python中GIL使得同一个时刻只有一个线程在一个cpu上执行,无法将多个线程映射到多个cpu上执行,但GIL并不会一直占有,它 ...

  2. python中对多态的理解

    目录 python中对多态的理解 一.多态 二.多态性 三.鸭子类型 python中对多态的理解 一.多态 多态是指一类事物有多种形态,比如动物类,可以有猫,狗,猪等等.(一个抽象类有多个子类,因而多 ...

  3. python中GIL和线程与进程

    线程与全局解释器锁(GIL) 一.线程概论 1.何为线程 每个进程有一个地址空间,而且默认就有一个控制线程.如果把一个进程比喻为一个车间的工作过程那么线程就是车间里的一个一个流水线. 进程只是用来把资 ...

  4. 对python中元类的理解

    1. 类也是对象 在大多数编程语言中,类就是一组用来描述如何生成一个对象的代码段.在Python中这一点仍然成立: >>> class ObjectCreator(object): ...

  5. Python中“if __name__=='__main__':”理解与总结

    1 引言 在Python当中,如果代码写得规范一些,通常会写上一句“if __name__==’__main__:”作为程序的入口,但似乎没有这么一句代码,程序也能正常运行.这句代码多余吗?原理又在哪 ...

  6. 对于Python中回调函数的理解

    关于回调函数,网上有很多说明和各种解释,多数在尝试用语言描述.我认为,如果对各个角色之间的关系不清楚,如果没有相关的编程需求,那么语言便非常无力,很难理解. 这是360百科的解释: 在计算机程序设计中 ...

  7. Python中的strip()的理解

    在看到Python中strip的时候产生了疑问 strip() 用于移除字符串头尾指定的字符(默认为空格) 开始测试: >>> s = 'ncy_123.python' >&g ...

  8. 对python中的__name__的理解

    一开始学习python的时候,不理解python中的__name__的用途,一致感觉__name__的返回结果就是__main__ 今天系统的看了一下,才理解过来,__name__真正的用处是用在使用 ...

  9. python 中is和== 的理解

    Python中的对象包含三要素:id.type.value其中id用来唯一标识一个对象,type标识对象的类型,value是对象的值is判断的是a对象是否就是b对象,是通过id来判断的==判断的是a对 ...

随机推荐

  1. CSS揭秘之《背景图案》

    网格 html { background: #58a; background-image: linear-gradient(white 2px, transparent 0), linear-grad ...

  2. python爬取梦幻西游召唤兽资质信息(不包含变异)

    一.分析 1.爬取网站:https://xyq.163.com/chongwu/ 2.获取网页源码: request.get("https://xyq.163.com/chongwu/&qu ...

  3. 软件构造实验-百度图像识别api

    识别结果: 识别结果:

  4. java中接口interface可以持有多个类的共享常量

    3.接口持有多个类的共享常量  接口另一主要功能,马克-to-win: 可以使用接口来引入多个类的共享常量.所有的这些变量名都将作为常量看待.所有定义在接口中的常量都默认为public.static和 ...

  5. Jackson 和 fastJSON 导包异常

    内容 一.异常信息 HTTP Status 400 - type Status report message org.springframework.http.converter.HttpMessag ...

  6. npm权限不够(安装什么都报错)

    问题 Windows下使用npm安装任何包都报错, Windows下使用npm显示权限不够 如图: 解决方法    1. 方法一    使用管理员权限打开 命令窗口,  治标不治本!!!!不推荐    ...

  7. Python简单文件读写

    ''' 用文件存储账户信息 使用列表存储多个账户信息,每个账户为一个字典对象 ''' users=[] #创建一个空列表 users.append({'id':'admin','pwd':'1235@ ...

  8. Installing github.com/mdempsky/gocode FAILED ----vscode安装go插件中的一些坑

    问题前景: 最近在使用vscode,编写一些go的代码,但发现调试的时候,会需要安装很多插件,但通过vscode之间安装的话,会出现如下的错误: Installing github.com/mdemp ...

  9. JAVA学习2——HelloWorld

    Java语言的诞生.版本以及工具:Java的安装开发环境以及环境变量的配置:第一个Java程序--HelloWorld

  10. java基础知识-序列化/反序列化-gson基础知识

    以下内容来之官网翻译,地址 1.Gson依赖 1.1.Gradle/Android dependencies { implementation 'com.google.code.gson:gson:2 ...