Python变量赋值是原子性的吗?

47

假设我正在使用一个 signal 处理程序来处理时间间隔计时器。

def _aHandler(signum, _):
  global SomeGlobalVariable
  SomeGlobalVariable=True

如果我设置 SomeGlobalVariable,会不会有问题,假设在设置 SomeGlobalVariable 时(即 Python VM 执行 bytecode 来设置变量),信号处理程序中的赋值操作可能会导致某些问题?(即 元稳态

更新: 我特别关注在处理程序之外进行“复合赋值”的情况。

(也许我想得太“底层”了,这个问题在 Python 中已经解决...我来自嵌入式系统背景,有时会有这种冲动)

4个回答

32

将简单变量进行简单赋值是“原子”的,也就是线程安全的(复合赋值如+=或者对对象的条目或属性进行赋值则不需要,但是您的示例是对一个简单变量进行简单赋值,即使是全局变量,因此是安全的)。


2
如果处理程序执行(例如)gvar = 3,并且在处理程序外部的代码执行(例如)gvar += 2,那么gvar最初为7,然后gvar可能最终成为3、5或9,这取决于操作如何交错。从技术上讲,这是“安全”的(意思是,进程不会崩溃;-),但语义上不太正确。 - Alex Martelli
12
这个规定在哪里写明了?-1 分是因为缺乏权威参考。 - user1804599
5
官方文件应被视为权威,我想说。如果没有记录,则取决于实现,不是吗? - R. Martinho Fernandes
7
@R.MartinhoFernandes,这肯定取决于实现方式--在现实世界中,当然,CPython参考实现非常占主导地位,以至于所有其他实现都大多试图遵循它的(有文档或无文档的)行为,使问题只具有理论和学术上的相关性。鉴于此,我认为,在现实世界中回答这个问题"实际发生了什么"仍然比含糊其辞或拒绝帮助有积极价值,即使没有"权威参考"(也不太可能很快出现)。 - Alex Martelli
2
如果标准没有明确说明简单赋值是原子操作,那么答案应该提到行为取决于实现。目前的答案在没有引用任何权威来源的情况下做出了强烈的声明,因此评分为-1。 - Richard Hansen
显示剩余5条评论

17

Google的样式指南建议不要这样做

我并不是在声称Google的样式指南是终极真理,但“线程”部分的原理提供了一些见解(高亮部分是我的):

不要依赖于内置类型的原子性。

虽然Python的内置数据类型如字典看起来具有原子操作,但它们存在一些角落情况并非原子性的(例如如果实现为Python方法的__hash____eq__),不能依赖其原子性。您也不应依赖原子变量分配(因为这又依赖于字典)。

使用Queue模块的队列数据类型作为线程之间通信的首选方式。否则,使用线程模块及其锁定原语。学习有关条件变量的正确使用方法,以便可以使用threading.Condition而不是使用较低级别的锁。

因此,我的理解是,在Python中,所有内容都类似于字典,当您在后台执行a=b时,某个地方会发生globals['a'] = b,这是不好的,因为字典不一定是线程安全的。

对于单个变量,Queue不是理想的选择,因为我们希望它仅包含一个元素,并且我无法找到一个完美的预先存在的容器,在stdlib中自动同步.set()方法。因此,现在我只做:

import threading

myvar = 0
myvar_lock = threading.Lock()
with myvar_lock:
    myvar = 1
with myvar_lock:
    myvar = 2

有趣的是,Martelli似乎不介意谷歌风格指南的建议 :-) (他在谷歌工作)

我想知道CPython GIL是否对这个问题有影响:什么是CPython中的全局解释器锁(GIL)?

这篇帖子还表明,包括以下注释在内的CPython字典是线程安全的https://docs.python.org/3/glossary.html#term-global-interpreter-lock

这简化了CPython实现,使对象模型(包括关键内置类型如dict)隐式地安全,以防止并发访问。


1
我对风格指南声称变量赋值可能不是原子操作持有一些怀疑,因为它依赖于字典。当键不是内置类型时,字典操作可能不是原子操作,但对于变量赋值,键是一个字符串(内置类型)。 - C S
@CS 感谢您的反馈。虽然对于CPython来说是正确的,但我遵循这个建议的主要原因是考虑未来可能没有GIL的其他实现。 - Ciro Santilli OurBigBook.com
从最后的引用来看,能否得出这样的结论:任何生成单个字节码指令的语句都是线程安全的? - Tejas Kale
我特别关注于原帖中的示例:信号处理。如果 Python 信号处理函数可以在主线程上的任何时间运行,那么如何保护主线程免受竞态条件的影响?据我所见,使用锁和相关工具在这里并没有帮助,因为我们在同一个线程中,只会立即死锁。 - Damian Birchler

16

您可以尝试使用dis命令来查看底层的字节码。

import dis

def foo():
    a = 1


dis.dis(foo)

生成字节码:

# a = 1
5             0 LOAD_CONST               1 (1)
              2 STORE_FAST               0 (a)

因此,该任务是单个Python字节码(指令2),在CPython中是原子性的,因为它一次执行一个字节码。

然而,添加一个a += 1

def foo():
    a = 1
    a += 1

生成字节码:

# a+=1
6             4 LOAD_FAST                0 (a)
              6 LOAD_CONST               1 (1)
              8 INPLACE_ADD
             10 STORE_FAST               0 (a)

+= 相当于4条指令,不是原子操作。


2

复合赋值涉及三个步骤:读取-更新-写入。如果另一个线程在读取发生后但在写入之前运行并向该位置写入新值,则会出现竞态条件。在这种情况下,将更新并写回旧值,这将覆盖其他线程写入的任何新值。在Python中,任何涉及执行单个字节码的操作都应是原子的,但复合赋值不符合此标准。请使用锁。


如果您有一个单线程,处理程序在哪里运行?如果它在同一线程上运行,则在其首次运行时无法更改状态。 - Max Shawabkeh
@jldupont:如果你只有一个线程,为什么还要关心原子性呢?根据定义,你不能在只有一个线程/进程的情况下出现竞争条件。忘记复合赋值吧,在这种情况下,每个单独的函数调用都是原子的,无论你在函数中做了多少事情,包括你的入口点。 - Eloff
@jldupont 你可能想阅读 signal文档 的第一部分。信号将在主线程上运行,并且“它们只能发生在Python解释器的‘原子’指令之间”。因此,在信号处理程序之外进行简单赋值不会受到影响,但是复合赋值会受到影响。 - num1
如果一个信号中断了来自长时间运行的函数调用的赋值,会怎么样呢?例如,在原始问题中列出的处理程序中,True 被替换为 SomeGlobalVariable 被设置为 OtherGlobalVariable,而 __main__ 正在运行类似于 OtherGlobalVariable=LongRunningFunc() 的东西。OtherGlobalVariable 是否会有错误值,还是只会有旧值或新值?文档说:“在纯 C 实现的长计算期间到达的信号可能会延迟任意长的时间。”但没有说明 Python 代码中会发生什么。 - Brian Minton
经过一些对 dis.dis 的实验,我发现函数调用是一个原子操作,而赋值则是另一个。因此,如果一个变量的赋值被信号处理程序中断,那么该变量只会有旧值或新值,永远不会出现垃圾值。 - Brian Minton
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接