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
ohayoo
V2EX  ›  Python

请问 Python 3.11 版本是对线程安全做了什么更改吗?

  •  
  •   ohayoo · 2023-08-17 07:39:06 +08:00 · 4361 次点击
    这是一个创建于 468 天前的主题,其中的信息可能已经有所发展或是发生改变。
    import threading
    
    num = 0
    
    
    def add():
        global num
        for i in range(10_000_000):
            num += 1
    
    
    def sub():
        global num
        for i in range(10_000_000):
            num -= 1
    
    
    if __name__ == "__main__":
    
        add_t = threading.Thread(target=add)
        sub_t = threading.Thread(target=sub)
    
        add_t.start()
        sub_t.start()
    
        add_t.join()
        sub_t.join()
    
        print("num result : %s" % num)
    
    

    昨天偶然发现这份代码在 3.11.3 中它居然输出 0 ,一度以为自己写错了,抱着不信邪的态度,又搞了个 Python 3.9.7 的环境试了下,果然还是符合自己预期,输出不为 0

    想问下 3.11 版本中是做了什么修改吗?

    46 条回复    2023-08-22 15:52:32 +08:00
    joApioVVx4M4X6Rf
        1
    joApioVVx4M4X6Rf  
       2023-08-17 07:58:26 +08:00   ❤️ 1
    num -= 1 这个字节码在 3.9 和 3.11 不一样,可以用 dis 模块看一下
    tulongtou
        2
    tulongtou  
       2023-08-17 08:30:43 +08:00
    3.9/3.10/3.11 都测试了下,发现 3.10 输出就是 0 了。
    这段代码正常不就应该是 0 么,难道 3.9 的是 bug ?
    jstony
        3
    jstony  
       2023-08-17 08:35:25 +08:00
    试了一下,3.8/3.9 输出不是 0 ,3.10/3.11 是 0
    yuyang
        4
    yuyang  
       2023-08-17 08:45:41 +08:00 via Android   ❤️ 3
    因为 GIL 的存在,一堆错误的并发程序也能跑,好多人就得过且过,你说你纠结于这样一个错误的并发程序的结果有啥意义?
    jstony
        5
    jstony  
       2023-08-17 08:46:51 +08:00   ❤️ 1
    问了一下 gpt ,给了个关键词,gil ,可以看看这个解释: https://zhuanlan.zhihu.com/p/75780308
    weidaizi
        6
    weidaizi  
       2023-08-17 09:12:18 +08:00
    这个结果为 0, 1 都是有可能的,还有可能为 -1, 2, -2, 3, ...... 这和版本无关呀 : )
    Vegetable
        7
    Vegetable  
       2023-08-17 09:15:14 +08:00
    你没法保证设计之外的结果是稳定的,这代码有 bug ,所以结果不一样很长长
    yph007595
        8
    yph007595  
       2023-08-17 09:30:47 +08:00
    你觉得应该是什么结果?我一看就觉得应该是 0 啊
    fgwmlhdkkkw
        9
    fgwmlhdkkkw  
       2023-08-17 09:32:26 +08:00
    多少都是合理的吧
    deplivesb
        10
    deplivesb  
       2023-08-17 09:43:13 +08:00   ❤️ 13



    3.9 里面+= 和-= 不是原子操作,所以线程不安全
    3.11 里面这俩是原子操作,所属已线程安全
    jjx
        11
    jjx  
       2023-08-17 09:44:35 +08:00
    这个 gil 只是保证同一时间一个线程

    他不保证谋个线程 分配的时间长点,一个分配的时间断点

    这个例子,出什么结果都有可能啊
    ShadowPower
        12
    ShadowPower  
       2023-08-17 09:46:40 +08:00
    这跟 GIL 没什么关系。你可以把 GIL 锁想象成单核电脑上跑多线程的情况。
    Python 的 GIL 锁没有哪个版本会对整个函数加锁。不然的话,对于多线程 GUI 程序,做任何耗时操作都会让整个界面未响应。

    这个程序在任何版本的 Python 中跑出的结果都是无法预测的。
    ohayoo
        13
    ohayoo  
    OP
       2023-08-17 09:47:33 +08:00 via Android
    @v2exblog
    @deplivesb
    感谢两位,一针见血
    deplivesb
        14
    deplivesb  
       2023-08-17 09:51:28 +08:00
    楼上一堆说“在任何版本的 Python 下结果都是不可预测的”我只能说大人时代变了。3.10 之后 += 和 -= 的操作线程安全了。该更新一下 Python 版本享受一下新的特性了。
    araraloren
        15
    araraloren  
       2023-08-17 09:55:30 +08:00
    抛开 GIL ,这个程序就是典型的并发问题程序,因为两个线程之间没有同步
    GIL 有对线程安全保证吗?我想没有
    weyou
        16
    weyou  
       2023-08-17 10:08:32 +08:00 via Android
    @deplivesb 见 11 楼
    deplivesb
        17
    deplivesb  
       2023-08-17 10:14:55 +08:00
    @weyou 大哥,原子操作啥意思懂不? gil 是会对每个线程分片执行,但是最小粒度就是一个原子操作,为啥 3.9 之前结果不确定,因为+= 和-= 的操作是两个原子操作,gil 可能在中间中断,导致结果不同步。3.10 之后+= 和-= 是原子操作,gil 单步就执行完 += 和-= 操作了。
    lovelylain
        18
    lovelylain  
       2023-08-17 10:32:50 +08:00   ❤️ 2
    @deplivesb 不是 INPLACE_ADD 与 BINARY_OP 的原因,python 有个难以废除的 GIL ,所以单条字节码本身是原子的,只是一个操作往往会编译成一组字节码,例如你图中的 a+=1 就是两条 LOAD 一条计算一条 STORE ,如果没保证整体的原子性,就还是会有线程安全问题。INPLACE_系列替换为 BINARY_OP 好处是减少了字节码种数,但对这一组字节码并没有减少条数,所以区别不在这里。而且前面也有人说了,3.10 就线程安全了,3.10 还是 INPLACE_ADD 。
    fgwmlhdkkkw
        19
    fgwmlhdkkkw  
       2023-08-17 10:38:04 +08:00
    @deplivesb #14 还是两条指令啊
    lovelylain
        20
    lovelylain  
       2023-08-17 10:55:28 +08:00
    怀疑这里跟 opcode cache 有关,具体怎么影响的还不清楚
    deplivesb
        21
    deplivesb  
       2023-08-17 11:05:24 +08:00
    @lovelylain #18 我的,没有仔细研究,我去研究一下。
    ShadowPower
        22
    ShadowPower  
       2023-08-17 11:14:14 +08:00   ❤️ 1
    @deplivesb 这只是 Python 的一些性能优化工作产生的副作用罢了,其实+=和-=本身并没有保证线程安全。
    你可以试试改成:
    num += int(1)
    num -= int(1)

    就会得到非 0 的值。

    你不能把它当作一个“Python 特性”来用,这玩意很容易就会被破坏掉。
    将来更高版本的 Python 移除了 GIL 之后,没准楼主提供的代码都不能保证线程安全了。
    hsfzxjy
        23
    hsfzxjy  
       2023-08-17 11:15:32 +08:00 via Android
    @deplivesb 3.11 也不是原子啊😂你少圈了个 STORE_NAME
    hsfzxjy
        24
    hsfzxjy  
       2023-08-17 11:18:40 +08:00 via Android
    3.10 由 Mark Shannon 引入了一系列的 quickening 优化,估计是这个带来的副作用。像楼主这种大循环,INPLACE_ADD 和 STORE_NAME 估计都会做特化,具体发生了什么就不清楚了
    deplivesb
        25
    deplivesb  
       2023-08-17 11:19:33 +08:00   ❤️ 18
    统一回复:之前确实是自己的错误,指令码确实没有减少,所以根本原因不是这个。
    但是原因确实是在这里
    下面解释原因:首先在 Python 字节码执行的时候 ,GIL 并不是随时能在任意位置中断切换线程。只有在主动检测中断的地方才可能发生线程切换。这个是大前提
    3.10 之前的版本中,INPLACE_ADD 这个 opcode 之后 GIL 会去主动监测中断,所以导致现成不安全。
    3.10 的代码中有一个提交 https://github.com/python/cpython/commit/4958f5d69dd2bf86866c43491caf72f774ddec97
    根据 T. Wouters 的 Twitter 描述 https://twitter.com/Yhg1s/status/1460935209059328000
    这次提交修改了 INPLACE_ADD 之后主动监测中断的操作。使得 INPLACE_ADD 之后无论如何都不会发生线程切换,因此索然是两个 opcode ,但是确实是线程安全。
    deplivesb
        26
    deplivesb  
       2023-08-17 11:21:05 +08:00
    @deplivesb #25 @weyou @lovelylain @ShadowPower 感谢各位的指正,我想真正原因已经找到了。感谢各位
    joApioVVx4M4X6Rf
        27
    joApioVVx4M4X6Rf  
       2023-08-17 12:26:05 +08:00
    @deplivesb 谢谢大佬我学到了
    ohayoo
        28
    ohayoo  
    OP
       2023-08-17 12:31:29 +08:00 via Android
    @deplivesb 谢谢大佬
    joApioVVx4M4X6Rf
        29
    joApioVVx4M4X6Rf  
       2023-08-17 12:31:29 +08:00
    #25 @weyou @lovelylain @ShadowPower 谢谢大佬们,学到了
    cdwyd
        30
    cdwyd  
       2023-08-17 12:37:21 +08:00 via Android
    已经很久没在这个网站看到这么有质量的帖子了
    楼主解决了问题,回帖的人修正并加深了自己的理解。
    sujin190
        31
    sujin190  
       2023-08-17 12:49:57 +08:00   ❤️ 1
    其实就是 3.10 开始除了 JMP 相关字节码指令和 CALL 相关字节码指令,其它的字节码指令都不会再触发 GIL 调度切换线程,所以不止+=是原子操作,a = b + c * d / e 这种一行多个计算操作复杂一些的也是原子操作了

    JMP 指令和 CALL 指令用的实在是太频繁了,比如 for range 就会同时用到这两个指令,所以影响不大,性能可能略微提升了那么一丢丢
    julyclyde
        32
    julyclyde  
       2023-08-17 14:45:11 +08:00
    我理解一下,是不是+=或者-=位置被中断之后,已经执行过计算但是还没赋值回变量,这时候切换到另一边去做加减,然后切换回来再执行赋值,导致另一边的计算结果被这边后续的赋值操作给覆盖掉了,从而偏离了 0 ?
    hsfzxjy
        33
    hsfzxjy  
       2023-08-17 14:48:05 +08:00 via Android
    @julyclyde 是这样的,和其他语言非原子读写类似
    julyclyde
        34
    julyclyde  
       2023-08-17 14:50:34 +08:00
    @hsfzxjy 谢谢
    我只是没想到 python 都这么老的语言了居然还能在这种地方出 bug

    以及,这源码看起来结果就“应该是”0 啊,为什么 OP 的预期是“不为 0”呢?@ohayoo
    deplivesb
        35
    deplivesb  
       2023-08-17 15:01:38 +08:00
    @julyclyde #34 为什么不是 0 ,原因你在上面自己也说了,因为字节码中+=的操作是两步 opcode 操作,且 INPLACE_ADD 之后 GIL 会主动监测中断,导致虽然加了,但是没有重新赋值,就切换到了别的线程上。
    比如 A 线程 当前 num=100 。+=1 之后 101 但是买没来得及重新赋值给 num ,GIL 切换了线程,再 B 线程中 num 还是 100 ,-=之后就是 99 ,但是这个线程却赋值给了 num ,此时 num 就是 99 然后又且回了 A 线程。结果啊线程将中断时候的 101 赋值给了 num 导致此时 num 变成了 101 就出现问题了。

    而为什么再 3.10 以后就不会出现这个问题了,就是我上面说的 INPLACE_ADD 操作之后 GIL 不再会主动检测中断,意味着正常情况下执行完+=之后线程不会被切换,而是正确执行了赋值给 num 的操作,所以就不会出现这个问题了。
    deplivesb
        36
    deplivesb  
       2023-08-17 15:03:26 +08:00
    @julyclyde #34 而且我觉得这个并不能算是 bug ,就是非原子操作的读写锁问题。
    oppurst
        37
    oppurst  
       2023-08-17 16:03:41 +08:00
    遇事不决就问 GPT 呗:

    这段代码是一个使用 Python 中的 threading 模块实现的多线程示例。它展示了两个线程同时对一个全局变量 num 进行加法和减法操作,然后在主线程中等待这两个线程完成,并输出最终的结果。

    让我逐步解释代码的各个部分:

    首先,代码导入了 threading 模块,用于管理线程的创建和控制。

    num = 0:这是一个全局变量,初始化为 0 。两个线程将对这个变量进行操作。

    add() 函数:这个函数执行一个循环,循环次数为 10,000,000 ,每次循环将 num 增加 1 。

    sub() 函数:与 add() 函数类似,这个函数也执行一个循环,循环次数为 10,000,000 ,每次循环将 num 减少 1 。

    if __name__ == "__main__"::这个条件语句确保下面的代码只会在脚本被直接执行时运行,而不是在被导入为模块时运行。

    创建线程对象:通过 threading.Thread(target=function) 创建了两个线程对象,分别指向 add() 和 sub() 函数。

    调用 start() 方法:通过调用线程对象的 start() 方法,启动了这两个线程,使它们开始执行相应的函数。

    调用 join() 方法:join() 方法被用来等待线程完成。在这里,主线程会等待 add_t 和 sub_t 两个线程都执行完毕才继续往下执行。

    输出结果:等待两个线程执行完毕后,主线程会打印最终的 num 的值。由于两个线程同时对 num 进行操作,所以最终的结果可能会受到竞争条件的影响,可能不是预期的 0 。

    总之,这段代码展示了如何使用 threading 模块创建和管理多个线程,并展示了多线程操作共享变量时可能出现的竞争条件问题。要解决这种问题,可能需要使用线程锁或其他同步机制来确保对共享资源的访问是安全的。
    sujin190
        38
    sujin190  
       2023-08-17 16:28:29 +08:00
    @julyclyde #34 别说 python 了,你用 c 或者汇编写一个这个不如果特别指定用原子操作指令的话也很大可能不为 0 ,你不会想说 cpu 也有 bug 吧
    julyclyde
        39
    julyclyde  
       2023-08-17 17:05:45 +08:00
    @deplivesb 从表面看,加了这么多次,减了这么多次,当然应该是 0 啊
    deplivesb
        40
    deplivesb  
       2023-08-17 17:08:08 +08:00
    @julyclyde #39 你觉得的从来都不是你觉得那样。建议重学操作系统
    chaleaochexist
        41
    chaleaochexist  
       2023-08-17 17:47:39 +08:00
    正常开发 都会加锁的.
    mikewang
        42
    mikewang  
       2023-08-18 03:16:44 +08:00
    看起来是一些优化让这里的 += -= 变为了原子操作,不过不能依赖这种特性,毕竟 Python 标准文档中没有说明过他们是原子的,以后也可能随时变回非原子操作。
    julyclyde
        43
    julyclyde  
       2023-08-18 19:57:49 +08:00
    @sujin190 从+= 和-=的语义来说,就不该断在中间啊
    没想到这么多年了才确保了不会断在中间
    sujin190
        44
    sujin190  
       2023-08-18 21:17:19 +08:00 via Android
    @julyclyde 想多了,汇编都不是,这个和 cpu 独立核心对应的是独立栈帧,就想 cpu 计算指令只能运行在寄存器,Python 对应的则是计算指令只能运行在栈帧上,多线程下独立寄存器和独立栈帧问题自然是一样的了,而且各种语言都是这么设计的,这是效率和准确性取舍没啥问题,不过相对于 gcc 对寄存器使用深度各种优化,Python 编译器对栈帧使用的优化说实话真不咋滴
    RageBubble
        45
    RageBubble  
       2023-08-22 14:42:53 +08:00
    @deplivesb 不过看你的截图中,3.10 后的不是已经将 INPLACE_ADD 替换为了 BINARY_OP ,更准确的说法是不是“3.10 以后,BINARY_OP 操作之后 GIL 不再会主动检测中断”?
    deplivesb
        46
    deplivesb  
       2023-08-22 15:52:32 +08:00
    @RageBubble 看 #25
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3293 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 12:20 · PVG 20:20 · LAX 04:20 · JFK 07:20
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.