V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
LeeReamond
V2EX  ›  Python

Python 的 gil 到底解决了什么具体的问题?

  •  1
     
  •   LeeReamond · 248 天前 via Android · 4322 次点击
    这是一个创建于 248 天前的主题,其中的信息可能已经有所发展或是发生改变。
    如题,用了这么多年 py,Gil 按理说应该很熟悉了,但是今天被问到 Gil 具体锁了哪些东西的时候却被问住了。毕竟虽然引入了 gil 机制,但 py 中的线程争用资源由于原子性问题仍然需要用户自行上锁,细究的话很多文章讲的加入 Gil 后避免细粒度锁其实是不对的,因为用户层面并没有实现无锁感知。

    具体来说,例如放两个线程同时在各自负责的内存空间操作完全不相干的对象时(比如双线程同时计算质数,各自维护各自的资源),那么按照大多数语言的思路,由于没有资源争用,实际上并不需要加锁(反之,如果有争用则必须加锁,目前 py 中也是这么干的),如果这么考量的话,Gil 所谓的有锁线程才允许解释,又解决了什么问题呢,完全没必要不是么
    49 条回复    2021-05-21 09:56:56 +08:00
    ipwx
        1
    ipwx  
       248 天前
    1. Python 解释器层面的一些锁。这个其实想要优化总能优化的。
    2. 保护 C 扩展模块,这方面是挺蛋疼的。

    Python 生态半壁江山是 C 扩展库撑起来的,丢掉这部分江山等于自废武功。为了支持无锁而去掉 GIL 等于丢掉所有这些库,做网络应用的是爽了,但是 Python 就分裂了。
    ipwx
        2
    ipwx  
       248 天前
    说的更明确一些:哪怕 C 扩展库对自己的逻辑进行了加锁,但是由于要访问 Python 对象,进一步使用 Python 解释器的东西,它防止不了外面的代码同时访问相同的对象,然后就崩溃了。。。

    所以面对 C 扩展库,GIL 喊了这么长时间都搞不掉。
    sagaxu
        3
    sagaxu  
       248 天前 via Android
    python 出道的时候(90 年代初),民用 CPU 都是单核单 CPU,十几年后才有了双核 CPU 。因为没有并行,自然而然的选择了引用计数加 GIL,实现简单又不会太影响性能。后来积攒了大量的 C 扩展,保留兼容性的同时去掉 gil 是极其困难的事情,况且必要性不高,性能不够拿扩展来凑就是了。
    ipwx
        4
    ipwx  
       248 天前   ❤️ 5
    …… 所以其实 Python 成也“胶水”败也“胶水”。C 扩展库能这么容易访问 Python 底层的东西是个优势,可以迅速做一些系统层面的 API 调用、融合 C/C++ 做科学计算( PyTorch,NumPy,或者 GPU 计算),或者在暴露 C/C++ 写的核心算法为 HTTP API 。但是同样的,这个优势也让 Python 在普通的多线程网络服务器上寸步难行。

    我的看法是,Python 干好科学计算这方面的事情就行了。还有就是干好工具型胶水语言该做的事情,在性能无关的领域放光(比如运维)。科学计算本来就计算密集型,本来就要独占核心一直跑,根本不会有线程上下文切换。网络服务交给专业的,比如 Go,Java,C++ 什么的。多好。。。
    roundgis
        5
    roundgis  
       248 天前 via Android
    推薦看沈崴的回答
    LeeReamond
        6
    LeeReamond  
    OP
       248 天前
    @ipwx 据我所知 libffi 几种调用方式,一种是将数据复制一份由扩展自行维护,比如 numpy 就自己一种储存结构,另一种是传入 python 数据结构的指针。所以你的意思是 GIL 防止的问题是一个 python 解释线程在操作(比如一个 python 列表)的数据结构时,另一个线程中的 C 扩展同时修改该结构?不过这又有什么意义,扩展中可以手动释放 GIL
    dreampuf
        7
    dreampuf  
       248 天前
    https://softwareengineering.stackexchange.com/questions/186889/why-was-python-written-with-the-gil

    > Benefits of the GIL
    > - Increased speed of single-threaded programs.
    > - Easy integration of C libraries that usually are not thread-safe.
    > ... This lock(GIL) is necessary mainly because CPython's memory management is not thread-safe.
    raysonx
        8
    raysonx  
       247 天前 via iPad
    严格来说,GIL 是 CPython 解释器的历史遗留问题,而不是 Python 这门语言自身的问题。
    ysc3839
        9
    ysc3839  
       247 天前 via Android
    > 但 py 中的线程争用资源由于原子性问题仍然需要用户自行上锁

    用 CPython 真的要自己加锁吗?我似乎没见到什么项目这么做的,能否举个例子?

    > 在各自负责的内存空间操作完全不相干的对象时……实际上并不需要加锁

    但是 CPython 很多东西是全局共用的,并不是线程独立的,所以这说法不成立。
    liuxingdeyu
        10
    liuxingdeyu  
       247 天前
    我搬运一下维基百科的说法


    CPython 的线程是操作系统的原生线程。在 Linux 上为 pthread,在 Windows 上为 Win thread,完全由操作系统调度线程的执行。一个 Python 解释器进程内有一个主线程,以及多个用户程序的执行线程。即便使用多核心 CPU 平台,由于 GIL 的存在,也将禁止多线程的并行执行。[2]
    Python 解释器进程内的多线程是以协作多任务方式执行。当一个线程遇到 I/O 任务时,将释放 GIL 。计算密集型( CPU-bound )的线程在执行大约 100 次解释器的计步( ticks )时,将释放 GIL 。计步( ticks )可粗略看作 Python 虚拟机的指令。计步实际上与时间片长度无关。可以通过 sys.setcheckinterval()设置计步长度。
    在单核 CPU 上,数百次的间隔检查才会导致一次线程切换。在多核 CPU 上,存在严重的线程颠簸( thrashing )。
    Python 3.2 开始使用新的 GIL 。新的 GIL 实现中用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁时,当前线程就会在 5 毫秒后被强制释放该锁。
    可以创建独立的进程来实现并行化。Python 2.6 引进了多进程包 multiprocessing 。或者将关键组件用 C/C++编写为 Python 扩展,通过 ctypes 使 Python 程序直接调用 C 语言编译的动态链接库的导出函数。
    参考文献
    GlobalInterpreterLock - Python Wiki. [2018-07-19]. (原始内容存档于 2018-06-23 ) (英语).
    David Beazley. Inside the Python GIL (PDF). Chicago: Chicago Python User Group. 2009-06-11 [2009-10-07]. (原始内容存档 (PDF)于 2010-12-24 ).
    LeeReamond
        11
    LeeReamond  
    OP
       247 天前
    @liuxingdeyu 解释切换长度还有 api 可调,学习了。不过似乎并未回答主题关心的问题。
    LeeReamond
        12
    LeeReamond  
    OP
       247 天前   ❤️ 1
    @ysc3839 你这么一说的话倒是确实,sys.modules 之类的环境倒是确实是共享的,应该保护起来。不过我感觉也不是充分条件,毕竟用户代码未必需要修改他们,少数修改的情况用 hook 的方式可以解决,没必要为了他们搞一个全局大锁。至于用户加锁我想应该不用举例,比如多线程共享某 int 对象执行 x+=n 操作,虚拟机指令会翻译为 LOAD_FAST 、LOAD_CONST 、INPLACE_ADD 、STORE_FAST 四部,且可在任意中间位置打断,显然需要锁保护。只不过因为 py 的线程锁粒度大且不好控制,确实很少见。
    generic
        13
    generic  
       247 天前
    GIL 锁住了什么?
    字节码解释循环。多个线程在任意时刻,只有一个线程能运行字节码,除非字节码调用的 C 扩展显式释放了 GIL 。

    GIL 的作用:
    防止多线程的字节码恰好访问同一 Python 对象造成 crash 。GIL 使得多线程的纯 Python 程序不可能(因为 race condition )在 VM 的 C 代码里 crash 。
    如果 C 扩展要显式释放 GIL,那么该扩展就需要自行保证线程安全。

    如果直接去掉 GIL 会怎样:
    多线程 Python 程序的 bug 会 crash 在 VM 的 C 代码里。
    ipwx
        14
    ipwx  
       247 天前
    @LeeReamond 参见楼上 “如果 C 扩展要显式释放 GIL,那么该扩展就需要自行保证线程安全”
    ipwx
        15
    ipwx  
       247 天前
    @LeeReamond ……接楼上。

    如果 Python 一开始就决定没有 GIL,那么每个 C 扩展库作者必须知道每个版本的 Python 内部的操作是怎么回事,小心地去处理每一次和 Python 对象的打交道。这个心智负担就太重了。肯定不能完全依赖于 C 扩展库的作者。

    Java/C# 因为有 VM 所以可以大大简化这里的问题,但是 Python 因为允许完全不受管理的 C 扩展所以。。。GIL 是当年和现在都最容易的选择,也是现在很难解决的问题。
    ysc3839
        16
    ysc3839  
       247 天前 via Android
    @LeeReamond 关于锁的问题多谢指正,是我没开发过多线程相关的,所以以为不需要。

    关于共享的东西,CPython 的 None True False 是全局共享的,这三样东西是很难绕开的。
    Austaras
        17
    Austaras  
       247 天前
    不是,说这么多怎么没人总结一下,就是 gc 好写。。。
    anzu
        18
    anzu  
       247 天前
    我觉得只是 kiss
    neoblackcap
        19
    neoblackcap  
       247 天前   ❤️ 1
    GIL 的问题是由于当时采用引用计数这样的垃圾回收技术引出的。毕竟要保护好引用数正确,要不就是处处是小锁。要不就是一个大锁(GIL)。当时为了单线程的性能,就用了一个大锁( GIL )。
    后面技术发展,大家发现其实 tracing-base 的垃圾回收方案速度也不慢,同时各种优点。于是现代化的 VM 都是 tracing gc 了。
    要去掉 GIL,那是得大改啊。
    LeeReamond
        20
    LeeReamond  
    OP
       247 天前
    @ipwx 感谢回复,所以意思是关于 ffi 交互方面,按照原始的 gil 设计,是希望让库设计者在设计库时不必考虑多线程同时操作 python 对象的问题,从而简化 C 扩展的编写难度,后期也是一直为了兼容这个目标,是这个意思吧。
    junkun
        21
    junkun  
       247 天前
    @ysc3839 None True False 都是不可变的,所以其实没什么问题,因为改变的只是引用。最大的问题是 list, dict, 包括 object 都是没锁的,现在去掉 GIL 改成细粒度锁,这些单进程的性能就会受影响。
    ysc3839
        22
    ysc3839  
       247 天前 via Android
    @junkun 但是按照 CPython 的规则,这些静态对象也要进行引用计数的,所以多线程同时操作还是会有问题。
    ipwx
        23
    ipwx  
       247 天前
    @neoblackcap 不,tracing gc 前提是有 vm 啊。

    但是 python 在 c 扩展方面自由度太大了,根本做不了 vm 啊。。。
    ====


    @LeeReamond 你理解错了,我指的不是 ctypes cffi 这种 C 扩展,而是直接在 C/C++ 项目里面 link python.dll ,主体是 C/C++ 的代码。你甚至可以在 C/C++ 里面直接 Py_ListAppend,或者调用某个对象的 __call__
    ipwx
        24
    ipwx  
       247 天前
    @LeeReamond 或者你可以实现自己的一套引用计数(仿照 C++ std::shared_ptr,直接在你的 struct XXXObject 内部嵌入一个 Py_Object*,在你自己的引用计数清零的时候去调用 python api 清理掉这个 python 对象)。

    换句话说,Python 变成了 API,C++ 才是本体。。。从 C/C++ 代码角度看就是这样。
    ipwx
        25
    ipwx  
       247 天前   ❤️ 1
    @neoblackcap @LeeReamond

    比如:

    https://zhuanlan.zhihu.com/p/192974017

    pybind11,C++ 与 python 互相调用的一个库。

    你 C++ 可以有自己的 pthread 线程,甚至用上 uvloop 来做事件驱动,然后控制权可能又进入 python,然后又重新回到 c++。在 C++ 里面如果有多个 pthread native 线程,根本不归 python 管,同时去访问 PyList_Append,这就是 Python 无与伦比的自由度,也是它作为胶水语言的优势,但是也造成了 GIL 尾大不掉。
    Jirajine
        26
    Jirajine  
       247 天前
    可以看看 https://rustpython.github.io 有 jit 无 gil,当然还很不成熟,但可以用来做内嵌脚本。
    ipwx
        27
    ipwx  
       247 天前
    @Jirajine yysy qs,解决 GIL 只能靠砍掉 C 模块,拥抱 JIT/VM 。但那就不是 Cython 应该做的事情了。
    medivh
        28
    medivh  
       247 天前
    三楼因为没有双核 CPU 所以没有并行的那位,求求你了去学点计算机系统基础知识吧...
    sagaxu
        29
    sagaxu  
       247 天前 via Android
    @medivh 挺好奇的,1C1T 的 CPU 的机器怎么个并行法?
    junkun
        30
    junkun  
       247 天前
    @ysc3839 理论上来说这些对象都不会被回收,所以计不计数其实影响不大。
    ysc3839
        31
    ysc3839  
       247 天前
    @junkun
    Python 的文档说要正确处理引用计数 https://docs.python.org/3/c-api/none.html
    另外根据 Stack Overflow 上一个回答的说法,Python 是可以复用被释放的对象的 https://stackoverflow.com/a/15288194/6911112
    ipwx
        32
    ipwx  
       246 天前
    @sagaxu 现代操作系统是时间片复用的并行操作系统。也就是说你一个线程比如 PyList_Append 做了一半,操作系统可能就会把这个线程停下来,换另一个线程去执行。因此 1C1T 也有并行,也要加锁。P.S. 这种停下来靠 CPU 硬件中断信号。
    sagaxu
        33
    sagaxu  
       246 天前 via Android
    @ipwx 分配时间片交替执行,这是并发,不是并行
    junkun
        34
    junkun  
       246 天前
    @ysc3839 原来如此,但是我还是觉得如果真的要去掉 gil,这些静态对象的计数不是最大的问题。
    AlohaV2
        35
    AlohaV2  
       246 天前   ❤️ 1
    Simple is better than complex.

    这个跟语言的定位有关系,我觉得我用 Python 的实践是
    - 快速实现想法;
    - 如果有性能需求,找已有的轮子(比如 numpy 是支持多线程的);
    - 如果真有很复杂的需求导致多线程成为瓶颈,那直接在 C++开发,好用的话把它变成一个库(Boost Python);
    ipwx
        36
    ipwx  
       246 天前
    @sagaxu 这无所谓的啦,这种用词我不太在意的。这楼一直在说加锁的事情,那并发(单线程)不用加,但是 1C1T 并行(多线程)就得加。而且你得和他解释为啥 1C1T 这也算并行,有些人还觉得 1T1C 同一时间只有一个线程在跑,不算并行嘞。

    所以我都不在意这些用词了。就怕半桶水只知道这种用词,而不知道背后到底具体是什么场景。
    ipwx
        37
    ipwx  
       246 天前
    @junkun 引用计数我看不是大问题,但是 PyList_Append 这种操作可不是原子操作。随便一个线程切换就会让搞到一半的 Append 停下来,另一个线程操作同样的 List,然后就炸了。
    ipwx
        38
    ipwx  
       246 天前
    而且最基础的引用计数可以不枷锁实现,比如 c++ std::shared_ptr,用 atomic 原语就行。
    sagaxu
        39
    sagaxu  
       246 天前 via Android
    @ipwx 我想表达是,python 出现的年代并行是罕见的场景,IO 密集型场景无所谓 GIL,CPU 密集型场景因为不能并行,无脑加 GIL 和精心打理细粒度锁不会有太大的性能差异。我解释的是 python 选择 GIL 的历史原因,不是说不用加锁,是当时用细粒度锁意义不大。

    现在多核多线程乃至多 CPU 是主流了,有了并行了,GIL 在 CPU 密集型场景才凸现出问题。但是因为历史包袱,没法简单的优化掉 GIL 了。

    楼上也有人讲过了,就是 gc 不好写,rc 好写,加上当时 GIL 没有害处,自然而然就这么干了。
    LeeReamond
        40
    LeeReamond  
    OP
       246 天前
    @ipwx 所以结合大佬前几个回复,意思是 GIL 保持了类似 C++扩展中在不受 python 控制的情况下仍能并发执行对 python 原生对象的 append 操作,因为 gil 不会释放所以 c++的 pthread 互相之间不会冲突?
    neoblackcap
        41
    neoblackcap  
       246 天前
    @ipwx 讨论 VM 是没有意义的,因为 tracing gc 跟 VM 的没有什么关系。tracing GC 只要有运行时提供功能就可以。而且 CPython 是有 tracing gc 兜底的,只不过不是通用的方式罢了。
    leven87
        42
    leven87  
       246 天前
    来学习下各位大佬的思路
    ipwx
        43
    ipwx  
       246 天前
    @sagaxu 呃,我和你理解的 CPU 密集型是不是有点不一样。。。CPU 密集型,我理解就该多少核开多少线程,工作全部入队列,一个结果计算好就发出去。。。这种专门后台大量计算的场景吗? GIL 在 CPU 密集型,直接开多进程不就可以了。。。。

    好吧我理解你的意思了,这种情况下你还想用多线程那 python 就没辙了。但是话说回来,这种场景多进程超好写啊。反而是 IO 密集型,当你的 IO 再上一个台阶,要用到多核多线程 + 每个线程 event loop,那搁多进程反而就有开销了。。。


    @LeeReamond 对,我个人觉得,这是 GIL 非常重要的一点,对 C/C++ 扩展至关重要。其他什么引用计数、Python 内部解释器的各种乱七八糟问题,我觉着只要钱到位肯定都能给你搞出来。只有这一点,真的是牵扯到扔掉半壁江山的事情了。。。为了兼容性可能 python 会很有顾虑。你看 pypy 不是很多 C 扩展就不能用吗。


    @neoblackcap ummm 我说的 vm 是相对于 c/c++ 原生代码的。你看 python byte code 不也是一种 vm 吗,有了这个 vm,python 才能做 tracing gc 啊。对 c/c++ native code,python 是真的 tracing 不起来啊。
    sagaxu
        44
    sagaxu  
       246 天前 via Android
    @ipwx 多进程 ipc 要序列化反序列化,不能简单的共享数据结构,进程之间同步也比线程之间更麻烦。多线程多个 event loop 跑 IO,如果 CPU 占用率超过 50%,按照分类标准,其实已经算 CPU 密集型了。IO 密集型通常 CPU 占用率远低于 50%,线程的大部分时间在等待 IO 完成,简单堆线程就能提高性能瓶颈。

    一个典型的例子就是 tomcat 跑 java web,http connector 的 acceptorThreadCount 默认是 1,而 request processing threads 的 maxThreads 默认 200,后者是 IO 密集型,线程数往往设置为 CPU 线程数的几十上百倍。假如 Java 也有 GIL,他会造成 connector 提高线程数反而降低性能,但对 request processing threads 影响却不大了,开 100 个性能比只开 1 个要好的多。
    ysc3839
        45
    ysc3839  
       245 天前 via Android
    @ipwx
    atomic 要硬件支持吧?我不知道当年选择 GIL 的背景是什么,但我觉得在当时可能考虑到许多硬件不支持 atomic 操作,而大部分操作系统都提供了锁。
    neoblackcap
        46
    neoblackcap  
       245 天前
    @ipwx 谈外部代码就过了,什么 VM 都对堆外内存无能为力。只能靠插件自我维护。实际上搞成 Java 那样是挺好的,JIT 性能提上去,C 扩展完全可以不用。不过都是当年遗留下来的产物,要 Python 抛弃 C 扩展,基本上是要它去死了
    ipwx
        47
    ipwx  
       245 天前
    @sagaxu All right,你说的大概是一堆用户要请求服务器的场景吧。Okay Okay,可以算 CPU 密集型。

    我脑子里面 CPU 密集型的第一反应是跑一个 PageRank,跑一个 CNN 之类的……那点 IO 开销和模型本身比简直可以忽略不计,多进程是最好的选项。因而,Python GIL 在这种任务面前根本不是事情。

    所以到底为啥人闲的蛋疼要用 Python 做 CPU 密集型的 Web 服务器啊,这不是找虐么。多学一门语言和生态不就好了。。。

    @neoblackcap 不过份。参考上面这段,我认为 Python 在 CPU 密集型的 Web 服务器领域就毫无价值,而在科学计算 / 机器学习 / 数据处理方面才有价值。因此外部代码才是 Python 的重点,用 FastAPI 直接快速上线 PyTorch 模型或者包装一下手写的 C++ 模型,不香吗?在 C++ 里面写个 RestAPI 那是找虐啊!

    所以同样的,优化掉 GIL 去适应 Web Application,为啥要找这方面虐呢?老老实实学习 Java / Go 不好吗?
    ipwx
        48
    ipwx  
       245 天前
    @neoblackcap 所以 “搞成 Java 那样是挺好的,JIT 性能提上去,C 扩展完全可以不用” 那就不是 Python 了。Python 最大的价值就在于搞成现在这样,可以无缝衔接各种 C/C++。高兴了我就把算法用 C++ 写一下,pybind11 直接嵌入 FastAPI 多好。。。
    hxysnail
        49
    hxysnail  
       244 天前   ❤️ 2
    GIL 目的在于保护 Python 虚拟机内部状态。举个例子,Python 很多变量空间,比如全局变量,内部是用 dict 来实现的。
    变量的赋值,在 Python 内部最终是执行 STORE_NAME 字节码,这个字节码将变量的值,保存到对应的 dict 对象中。
    假设这个动作底层是由字典的 dict.set(name, value)函数负责,它会非常复杂,还涉及 dict 对象扩容缩容,肯定不是线程安全的。

    那怎么办呢?①dict.set(name, value)加锁;②用 GIL 保证同一时间只有一个线程在执行字节码。
    Python 选择②,因为①引入的线程开销也不小。
    有测试表明①虽然提升了 Python 的并行能力,但获得的性能提升非常有限,单线程下则全是消耗。

    那为什么有 GIL 之后,多线程应用还需要加锁呢?
    举个例子,有个全局变量 a,多个进程并发执行 a += 1 。
    这个语句编译后大概会生成这样几个字节码:
    1 LOAD_NAME 将变量从名字空间 dict 中取出,并保存在临时栈;
    2 ADD 在临时栈中做加法操作;
    3 STORE_NAME 将计算结果保存到名字空间 dict 中;
    GIL 保证了线程在执行一个字节码时,其他线程不能执行,以便保护名字空间 dict 的安全性。

    但这 3 个字节码之间可以任意切换,这样应用就会产生中间态。
    举个例子,线程①执行 LOAD_NAME 后,切到其他线程执行,变量 a 发生了修改。
    线程①恢复执行后,它临时栈中的值仍是旧的,这样就会覆盖了其他线程的写入。
    因此,需要用户自行加锁,保存 a += 1 对应的这几个字节码的原子性,一次性执行,中间不能被打断。

    总而言之,GIL 保证一个线程在执行字节码时,其他线程不能同时执行,目的是保护虚拟机内部状态的线程安全性;
    用户自己加的锁,是为了让多条字节码成为一个原子操作,中间不会发生线程切换,目的在于保护程序逻辑的正确性,消除竞争态。

    想要完全理解这个问题,需要了解 Python [内建对象] [虚拟机] [字节码] 等知识,有兴趣的话推荐看一个叫 [Python 源码剖析] 的专栏:
    https://fasionchan.com/python-source/virtual-machine/gil/
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   1151 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 22:42 · PVG 06:42 · LAX 14:42 · JFK 17:42
    ♥ Do have faith in what you're doing.