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 版本中是做了什么修改吗?
1
joApioVVx4M4X6Rf 2023-08-17 07:58:26 +08:00 1
num -= 1 这个字节码在 3.9 和 3.11 不一样,可以用 dis 模块看一下
|
2
tulongtou 2023-08-17 08:30:43 +08:00
3.9/3.10/3.11 都测试了下,发现 3.10 输出就是 0 了。
这段代码正常不就应该是 0 么,难道 3.9 的是 bug ? |
3
jstony 2023-08-17 08:35:25 +08:00
试了一下,3.8/3.9 输出不是 0 ,3.10/3.11 是 0
|
4
yuyang 2023-08-17 08:45:41 +08:00 via Android 3
因为 GIL 的存在,一堆错误的并发程序也能跑,好多人就得过且过,你说你纠结于这样一个错误的并发程序的结果有啥意义?
|
5
jstony 2023-08-17 08:46:51 +08:00 1
问了一下 gpt ,给了个关键词,gil ,可以看看这个解释: https://zhuanlan.zhihu.com/p/75780308
|
6
weidaizi 2023-08-17 09:12:18 +08:00
这个结果为 0, 1 都是有可能的,还有可能为 -1, 2, -2, 3, ...... 这和版本无关呀 : )
|
7
Vegetable 2023-08-17 09:15:14 +08:00
你没法保证设计之外的结果是稳定的,这代码有 bug ,所以结果不一样很长长
|
8
yph007595 2023-08-17 09:30:47 +08:00
你觉得应该是什么结果?我一看就觉得应该是 0 啊
|
9
fgwmlhdkkkw 2023-08-17 09:32:26 +08:00
多少都是合理的吧
|
10
deplivesb 2023-08-17 09:43:13 +08:00 13
|
11
jjx 2023-08-17 09:44:35 +08:00
这个 gil 只是保证同一时间一个线程
他不保证谋个线程 分配的时间长点,一个分配的时间断点 这个例子,出什么结果都有可能啊 |
12
ShadowPower 2023-08-17 09:46:40 +08:00
这跟 GIL 没什么关系。你可以把 GIL 锁想象成单核电脑上跑多线程的情况。
Python 的 GIL 锁没有哪个版本会对整个函数加锁。不然的话,对于多线程 GUI 程序,做任何耗时操作都会让整个界面未响应。 这个程序在任何版本的 Python 中跑出的结果都是无法预测的。 |
14
deplivesb 2023-08-17 09:51:28 +08:00
楼上一堆说“在任何版本的 Python 下结果都是不可预测的”我只能说大人时代变了。3.10 之后 += 和 -= 的操作线程安全了。该更新一下 Python 版本享受一下新的特性了。
|
15
araraloren 2023-08-17 09:55:30 +08:00
抛开 GIL ,这个程序就是典型的并发问题程序,因为两个线程之间没有同步
GIL 有对线程安全保证吗?我想没有 |
17
deplivesb 2023-08-17 10:14:55 +08:00
@weyou 大哥,原子操作啥意思懂不? gil 是会对每个线程分片执行,但是最小粒度就是一个原子操作,为啥 3.9 之前结果不确定,因为+= 和-= 的操作是两个原子操作,gil 可能在中间中断,导致结果不同步。3.10 之后+= 和-= 是原子操作,gil 单步就执行完 += 和-= 操作了。
|
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 。
|
19
fgwmlhdkkkw 2023-08-17 10:38:04 +08:00
@deplivesb #14 还是两条指令啊
|
20
lovelylain 2023-08-17 10:55:28 +08:00
怀疑这里跟 opcode cache 有关,具体怎么影响的还不清楚
|
21
deplivesb 2023-08-17 11:05:24 +08:00
@lovelylain #18 我的,没有仔细研究,我去研究一下。
|
22
ShadowPower 2023-08-17 11:14:14 +08:00 1
@deplivesb 这只是 Python 的一些性能优化工作产生的副作用罢了,其实+=和-=本身并没有保证线程安全。
你可以试试改成: num += int(1) num -= int(1) 就会得到非 0 的值。 你不能把它当作一个“Python 特性”来用,这玩意很容易就会被破坏掉。 将来更高版本的 Python 移除了 GIL 之后,没准楼主提供的代码都不能保证线程安全了。 |
24
hsfzxjy 2023-08-17 11:18:40 +08:00 via Android
3.10 由 Mark Shannon 引入了一系列的 quickening 优化,估计是这个带来的副作用。像楼主这种大循环,INPLACE_ADD 和 STORE_NAME 估计都会做特化,具体发生了什么就不清楚了
|
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 ,但是确实是线程安全。 |
26
deplivesb 2023-08-17 11:21:05 +08:00
|
27
joApioVVx4M4X6Rf 2023-08-17 12:26:05 +08:00
@deplivesb 谢谢大佬我学到了
|
29
joApioVVx4M4X6Rf 2023-08-17 12:31:29 +08:00
|
30
cdwyd 2023-08-17 12:37:21 +08:00 via Android
已经很久没在这个网站看到这么有质量的帖子了
楼主解决了问题,回帖的人修正并加深了自己的理解。 |
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 就会同时用到这两个指令,所以影响不大,性能可能略微提升了那么一丢丢 |
32
julyclyde 2023-08-17 14:45:11 +08:00
我理解一下,是不是+=或者-=位置被中断之后,已经执行过计算但是还没赋值回变量,这时候切换到另一边去做加减,然后切换回来再执行赋值,导致另一边的计算结果被这边后续的赋值操作给覆盖掉了,从而偏离了 0 ?
|
34
julyclyde 2023-08-17 14:50:34 +08:00
|
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 的操作,所以就不会出现这个问题了。 |
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 模块创建和管理多个线程,并展示了多线程操作共享变量时可能出现的竞争条件问题。要解决这种问题,可能需要使用线程锁或其他同步机制来确保对共享资源的访问是安全的。 |
38
sujin190 2023-08-17 16:28:29 +08:00
@julyclyde #34 别说 python 了,你用 c 或者汇编写一个这个不如果特别指定用原子操作指令的话也很大可能不为 0 ,你不会想说 cpu 也有 bug 吧
|
41
chaleaochexist 2023-08-17 17:47:39 +08:00
正常开发 都会加锁的.
|
42
mikewang 2023-08-18 03:16:44 +08:00
看起来是一些优化让这里的 += -= 变为了原子操作,不过不能依赖这种特性,毕竟 Python 标准文档中没有说明过他们是原子的,以后也可能随时变回非原子操作。
|
44
sujin190 2023-08-18 21:17:19 +08:00 via Android
@julyclyde 想多了,汇编都不是,这个和 cpu 独立核心对应的是独立栈帧,就想 cpu 计算指令只能运行在寄存器,Python 对应的则是计算指令只能运行在栈帧上,多线程下独立寄存器和独立栈帧问题自然是一样的了,而且各种语言都是这么设计的,这是效率和准确性取舍没啥问题,不过相对于 gcc 对寄存器使用深度各种优化,Python 编译器对栈帧使用的优化说实话真不咋滴
|
45
RageBubble 2023-08-22 14:42:53 +08:00
@deplivesb 不过看你的截图中,3.10 后的不是已经将 INPLACE_ADD 替换为了 BINARY_OP ,更准确的说法是不是“3.10 以后,BINARY_OP 操作之后 GIL 不再会主动检测中断”?
|
46
deplivesb 2023-08-22 15:52:32 +08:00
@RageBubble 看 #25
|