对Python中GIL(全局解释器锁)的一点理解浅析

目录
  • 前言
  • 为什么需要 GIL
  • GIL 的实现
  • 几点说明
    • GIL 优化
    • 用户数据的一致性不能依赖 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的影响

说了那么多,如果不说解决方案就仅仅是个科普帖,然并卵。GIL这么烂,有没有办法绕过呢?我们来看看有哪些现成的方案。

用multiprocess替代Thread

multiprocess库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,因此也不会出现进程之间的GIL争抢。

当然multiprocess也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。就拿计数器来举例子,如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而multiprocess由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用share memory的方法。这个额外的实现成本使得本来就非常痛苦的多线程程序编码,变得更加痛苦了。具体难点在哪有兴趣的读者可以扩展阅读这篇文章

用其他解析器

之前也提到了既然GIL只是CPython的产物,那么其他解析器是不是更好呢?没错,像JPython和IronPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助。然而由于用了Java/C#用于解析器实现,他们也失去了利用社区众多C语言模块有用特性的机会。所以这些解析器也因此一直都比较小众。毕竟功能和性能大家在初期都会选择前者,Done is better than perfect。

所以没救了么?

当然Python社区也在非常努力的不断改进GIL,甚至是尝试去除GIL。并在各个小版本中有了不少的进步。有兴趣的读者可以扩展阅读这个Slide

另一个改进Reworking the GIL

– 将切换颗粒度从基于opcode计数改成基于时间片计数

– 避免最近一次释放GIL锁的线程再次被立即调度

– 新增线程优先级功能(高优先级线程可以迫使其他线程释放所持有的GIL锁)

总结

GIL 是 CPython 为了在多线程环境下为了维护解释器内部数据一致性而引入的,为了尽可能降低 GIL 的粒度,在 IO 操作和不涉及对象访问的 CPU 计算时会主动释放 GIL。最后,用户数据的一致性不能依赖 GIL,可能需要用户使用 Lock 或 RLock() 来保证数据的原子性访问。

到此这篇关于对Python中GIL理解的文章就介绍到这了,更多相关Python中GIL理解内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

参考文档

(0)

相关推荐

  • python 深入了解GIL锁详细

    目录 1.什么是GIL锁 2.CPython对线程安全的内存管理机制 3.GIL锁的产生 4.GIL锁的底层原理 5.Python GIL不能绝对保证线程安全 6.总结 前言: python的使用者都知道Cpython解释器有一个弊端,真正执行时同一时间只会有一个线程执行,这是由于设计者当初设计的一个缺陷,里面有个叫GIL锁的,但他到底是什么?我们只知道因为他导致python使用多线程执行时,其实一直是单线程,但是原理却不知道,那么接下来我们就认识一下GIL锁 1.什么是GIL锁 GIL(Glo

  • 线程安全及Python中的GIL原理分析

    本文讲述了线程安全及Python中的GIL.分享给大家供大家参考,具体如下: 摘要 什么是线程安全? 为什么python会使用GIL的机制? 在多核时代的到来的背景下,基于多线程来充分利用硬件的编程方法也不断发展起来, 但是一旦 牵扯到多线程,就必然会涉及到一个概念,即 线程安全, 本文就主要谈下笔者对线程安全的一些理解. 而Python为很多人所抱怨的一点就是GIL,那么python为什么选择使用GIL, 本文也就这个问题进行一些讨论. 引入 你的PC或者笔记本还是单核吗? 如果是,那你已经o

  • 深入学习python多线程与GIL

    python 多线程效率 在一台8核的CentOS上,用python 2.7.6程序执行一段CPU密集型的程序. import time def fun(n):#CPU密集型的程序 while(n>0): n -= 1 start_time = time.time() fun(10000000) print('{} s'.format(time.time() - start_time))#测量程序执行时间 测量三次程序的执行时间,平均时间为0.968370994秒.这就是一个线程执行一次fun(

  • 浅谈Python中的全局锁(GIL)问题

    CPU-bound(计算密集型) 和I/O bound(I/O密集型) 计算密集型任务(CPU-bound) 的特点是要进行大量的计算,占据着主要的任务,消耗CPU资源,一直处于满负荷状态.比如复杂的加减乘除.计算圆周率.对视频进行高清解码等等,全靠CPU的运算能力.这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数. 计算密集型任务由于主要消耗CPU资源,因

  • 一篇文章快速了解Python的GIL

    前言:博主在刚接触Python的时候时常听到GIL这个词,并且发现这个词经常和Python无法高效的实现多线程划上等号.本着不光要知其然,还要知其所以然的研究态度,博主搜集了各方面的资料,花了一周内几个小时的闲暇时间深入理解了下GIL,并归纳成此文,也希望读者能通过次本文更好且客观的理解GIL. GIL是什么 首先需要明确的一点是GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念.就好比C++是一套语言(语法)标准,但是可以用不同的编译器来编译成可执

  • Python中GIL的使用详解

    1.GIL简介 GIL的全称为Global Interpreter Lock,全局解释器锁. 1.1 GIL设计理念与限制 python的代码执行由python虚拟机(也叫解释器主循环,CPython版本)来控制,python在设计之初就考虑到在解释器的主循环中,同时只有一个线程在运行.即在任意时刻只有一个线程在解释器中运行.对python虚拟机访问的控制由全局解释锁GIL控制,正是这个锁来控制同一时刻只有一个线程能够运行. 在调用外部代码(如C.C++扩展函数)的时候,GIL将会被锁定,直到这

  • python中GIL的原理及用法总结

    1.说明 GIL规定一个Python解释程序只能同时由一个线程控制. 在CPU限制类型和多线程代码中,GIL是一个性能瓶颈. GIL使Python多线程成为伪并行多线程. 仅CPython解释器上存在GIL. 2.原理 (1)线程1.2.3轮流执行,每一个线程在执行是,都会锁住GIL,以阻止别的线程执行: 同样的,每一个线程执行一段后,会释放GIL,以允许别的线程开始利用资源. (2)由于古老GIL机制,如果线程2需要在CPU2上执行,它需要先等待在CPU1上执行的线程1释放GIL(记住:GIL

  • Cpython解释器中的GIL全局解释器锁

    1.什么是GIL全局解释器锁 GIL:Global Interpreter Lock,意思就是全局解释器锁,这个GIL并不是Python的特性,他是只在Cpython解释器里引入的一个概念,而在其他的语言编写的解释器里就没有GIL,例如:Jython,Pypy等 下面是官方给出的解释: In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from exe

  • 对Python中GIL(全局解释器锁)的一点理解浅析

    目录 前言 为什么需要 GIL GIL 的实现 几点说明 GIL 优化 用户数据的一致性不能依赖 GIL 总结 参考文档 前言 GIL(Global Interpreter Lock),全局解释器锁,是 CPython 为了避免在多线程环境下造成 Python 解释器内部数据的不一致而引入的一把锁,让 Python 中的多个线程交替运行,避免竞争. 需要说明的是 GIL 不是 Python 语言规范的一部分,只是由于 CPython 实现的需要而引入的,其他的实现如 Jython 和 PyPy

  • 详解Python中的GIL(全局解释器锁)详解及解决GIL的几种方案

    先看一道GIL面试题: 描述Python GIL的概念, 以及它对python多线程的影响?编写一个多线程抓取网页的程序,并阐明多线程抓取程序是否可比单线程性能有提升,并解释原因. GIL:又叫全局解释器锁,每个线程在执行的过程中都需要先获取GIL,保证同一时刻只有一个线程在运行,目的是解决多线程同时竞争程序中的全局变量而出现的线程安全问题.它并不是python语言的特性,仅仅是由于历史的原因在CPython解释器中难以移除,因为python语言运行环境大部分默认在CPython解释器中. 通过

  • Python并发编程多进程,多线程及GIL全局解释器锁

    目录 1. 并发与并行 2. 线程与进程的应用场景 2.1. 并行/并发编程相关的技术栈 3. Python中的GIL是什么,它影响什么 1. 并发与并行 所谓的并行(Parallelism),就是多个彼此独立的任务可以同时一起执行,彼此并不相互干扰,并行强调的是同时且独立的运行,彼此不需要协作. 而所谓并发(Concurrency),则是多个任务彼此交替执行,但是同一时间只能有一个处于运行状态,并发执行强调任务之间的彼此协作. 并发通常被误解为并行,并发实际是隐式的调度独立的代码,以协作的方式

  • 对Python中 \r, \n, \r\n的彻底理解

    回车和换行的历史: 机械打字机有回车和换行两个键作用分别是: 换行就是把滚筒卷一格,不改变水平位置. (即移到下一行,但不是行首,而是和上一行水平位置一样) 回车就是把水平位置复位,不卷动滚筒. (即将光标移到行首,但是不会移到下一行,如果继续输入的话会覆盖掉前面的内容) Enter = 回车+换行(\r\n) 理解: \n是换行,英文是New line \r是回车,英文是Carriage return unix换行:\n(0x0A) MAC回车:\r(0x0D) WIN回车换行:\r\n(0x

  • Python中多线程及程序锁浅析

    Python中多线程使用到Threading模块.Threading模块中用到的主要的类是Thread,我们先来写一个简单的多线程代码: 复制代码 代码如下: # coding : uft-8 __author__ = 'Phtih0n' import threading class MyThread(threading.Thread):     def __init__(self):         threading.Thread.__init__(self) def run(self):

  • Python中的__new__与__init__魔术方法理解笔记

    很喜欢Python这门语言.在看过语法后学习了Django 这个 Web 开发框架.算是对 Python 有些熟悉了.不过对里面很多东西还是不知道,因为用的少.今天学习了两个魔术方法:__new__ 和 __init__. 开攻: 如果对 Python 有所简单了解的话应该知道它包含类这个概念的.语法如下: 复制代码 代码如下: class ClassName:     <statement - 1>:         .         .           .     <state

随机推荐