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

288 天前
 ohayoo
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 版本中是做了什么修改吗?

4104 次点击
所在节点    Python
46 条回复
deplivesb
288 天前
@lovelylain #18 我的,没有仔细研究,我去研究一下。
ShadowPower
288 天前
@deplivesb 这只是 Python 的一些性能优化工作产生的副作用罢了,其实+=和-=本身并没有保证线程安全。
你可以试试改成:
num += int(1)
num -= int(1)

就会得到非 0 的值。

你不能把它当作一个“Python 特性”来用,这玩意很容易就会被破坏掉。
将来更高版本的 Python 移除了 GIL 之后,没准楼主提供的代码都不能保证线程安全了。
hsfzxjy
288 天前
@deplivesb 3.11 也不是原子啊😂你少圈了个 STORE_NAME
hsfzxjy
288 天前
3.10 由 Mark Shannon 引入了一系列的 quickening 优化,估计是这个带来的副作用。像楼主这种大循环,INPLACE_ADD 和 STORE_NAME 估计都会做特化,具体发生了什么就不清楚了
deplivesb
288 天前
统一回复:之前确实是自己的错误,指令码确实没有减少,所以根本原因不是这个。
但是原因确实是在这里
下面解释原因:首先在 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
288 天前
@deplivesb #25 @weyou @lovelylain @ShadowPower 感谢各位的指正,我想真正原因已经找到了。感谢各位
0x0208v0
288 天前
@deplivesb 谢谢大佬我学到了
ohayoo
288 天前
@deplivesb 谢谢大佬
0x0208v0
288 天前
#25 @weyou @lovelylain @ShadowPower 谢谢大佬们,学到了
cdwyd
288 天前
已经很久没在这个网站看到这么有质量的帖子了
楼主解决了问题,回帖的人修正并加深了自己的理解。
sujin190
288 天前
其实就是 3.10 开始除了 JMP 相关字节码指令和 CALL 相关字节码指令,其它的字节码指令都不会再触发 GIL 调度切换线程,所以不止+=是原子操作,a = b + c * d / e 这种一行多个计算操作复杂一些的也是原子操作了

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

以及,这源码看起来结果就“应该是”0 啊,为什么 OP 的预期是“不为 0”呢?@ohayoo
deplivesb
288 天前
@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
288 天前
@julyclyde #34 而且我觉得这个并不能算是 bug ,就是非原子操作的读写锁问题。
oppurst
288 天前
遇事不决就问 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
288 天前
@julyclyde #34 别说 python 了,你用 c 或者汇编写一个这个不如果特别指定用原子操作指令的话也很大可能不为 0 ,你不会想说 cpu 也有 bug 吧
julyclyde
288 天前
@deplivesb 从表面看,加了这么多次,减了这么多次,当然应该是 0 啊
deplivesb
288 天前
@julyclyde #39 你觉得的从来都不是你觉得那样。建议重学操作系统

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/965954

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX