+= 运算符在 Python 中是线程安全的吗?
Posted
技术标签:
【中文标题】+= 运算符在 Python 中是线程安全的吗?【英文标题】:Is the += operator thread-safe in Python? 【发布时间】:2010-12-15 14:25:12 【问题描述】:我想为实验创建一个非线程安全的代码块,这些是 2 个线程要调用的函数。
c = 0
def increment():
c += 1
def decrement():
c -= 1
这段代码线程安全吗?
如果不是,我能理解为什么它不是线程安全的,什么样的语句通常会导致非线程安全的操作。
如果它是线程安全的,我怎样才能让它明确地非线程安全?
【问题讨论】:
每个函数开始时应该有一个global c
减速,否则这并没有真正做任何事情。
您好,nubela,您能选择正确的答案,以免以后的读者感到困惑吗?
【参考方案1】:
简短回答:不。
长答案:一般不会。
虽然 CPython 的 GIL 生成单个操作码 thread-safe,但这不是一般行为。您可能不会认为即使是像加法这样的简单操作也是原子指令。当另一个线程运行时,添加可能只完成了一半。
一旦你的函数在多个操作码中访问一个变量,你的线程安全就消失了。如果将函数体包装在 locks 中,则可以生成线程安全。但请注意,锁的计算成本可能很高,并且可能会产生死锁。
【讨论】:
一切都有多个操作码/除非另有说明,否则都是复合的。 短答案和长答案都不是。【参考方案2】:(注意:您需要在每个函数中使用global c
才能使您的代码正常工作。)
这段代码线程安全吗?
没有。在 CPython 中只有一条字节码指令是“原子的”,+=
可能不会产生单个操作码,即使涉及的值是简单的整数:
>>> c= 0
>>> def inc():
... global c
... c+= 1
>>> import dis
>>> dis.dis(inc)
3 0 LOAD_GLOBAL 0 (c)
3 LOAD_CONST 1 (1)
6 INPLACE_ADD
7 STORE_GLOBAL 0 (c)
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
所以一个线程可以在加载 c 和 1 的情况下到达索引 6,放弃 GIL 并让另一个线程进入,该线程执行 inc
并休眠,将 GIL 返回到第一个线程,该线程现在具有错误的值.
在任何情况下,什么是原子是您不应该依赖的实现细节。字节码在 CPython 的未来版本中可能会发生变化,结果在其他不依赖 GIL 的 Python 实现中将完全不同。如果你需要线程安全,你需要一个锁定机制。
【讨论】:
【参考方案3】:由于 GIL,单个操作码是线程安全的,但仅此而已:
import time
class something(object):
def __init__(self,c):
self.c=c
def inc(self):
new = self.c+1
# if the thread is interrupted by another inc() call its result is wrong
time.sleep(0.001) # sleep makes the os continue another thread
self.c = new
x = something(0)
import threading
for _ in range(10000):
threading.Thread(target=x.inc).start()
print x.c # ~900 here, instead of 10000
每个由多个线程共享的资源必须拥有一个锁。
【讨论】:
这个不回答问题,是关于+=
另外,如果我错了,请纠正我,print x.c
不会等待线程完成。因此,当您打印输出时,它们中的大多数仍在运行。
您是否要更新提到线程安全仅在处理共享/全局变量时才是问题的答案。在您的示例中,x 是一个全局变量。【参考方案4】:
你确定函数递增和递减执行没有任何错误吗?
我认为它应该引发 UnboundLocalError,因为您必须明确告诉 Python 您要使用名为“c”的全局变量。
因此将增量(也减量)更改为以下内容:
def increment():
global c
c += 1
我认为你的代码是线程不安全的。 This article 关于 Python 中的线程同步机制可能会有所帮助。
【讨论】:
【参考方案5】:如果你真的想让你的代码不是线程安全的,并且很有可能在你不尝试一万次(或者当你真正不希望“坏”的事情发生),您可以通过显式休眠来“抖动”您的代码:
def íncrement():
global c
x = c
from time import sleep
sleep(0.1)
c = x + 1
【讨论】:
【参考方案6】:很容易证明您的代码不是线程安全的。您可以通过在关键部分使用睡眠来增加看到竞争条件的可能性(这只是模拟慢速 CPU)。但是,如果您运行代码足够长的时间,您最终应该会看到竞态条件。
from time import sleep
c = 0
def increment():
global c
c_ = c
sleep(0.1)
c = c_ + 1
def decrement():
global c
c_ = c
sleep(0.1)
c = c_ - 1
【讨论】:
对这种东西使用睡眠是非常错误的。您是如何得出 0.1 的值的?更快的处理器需要更长的睡眠时间吗?使用睡眠来解决问题几乎总是错误的。 @omribahumi,什么?我想你对我回答的目的感到困惑。这段代码是一个示例,说明证明一段特定的代码不是线程安全的是多么容易。睡眠只是作为一个占位符来模拟通常存在的额外处理。如果您的意思是使用 sleep 是避免竞争条件的错误方法,我当然同意,但这不是我的回答所声称的。 @jacmkno,答案没有错,但由于某种原因让人们感到困惑。它证明了 OP 的代码是 not 线程安全的。还是您有其他建议? 投赞成票纯粹是因为你似乎因为其他人没有阅读你的答案而受到惩罚......对我来说很有意义【参考方案7】:不,这段代码绝对不是线程安全的。
import threading
i = 0
def test():
global i
for x in range(100000):
i += 1
threads = [threading.Thread(target=test) for t in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert i == 1000000, i
总是失败。
i += 1 解析为四个操作码:加载 i,加载 1,将两者相加,然后将其存储回 i。 Python 解释器每 100 个操作码切换一次活动线程(通过从一个线程释放 GIL 以便另一个线程可以拥有它)。 (这两个都是实现细节。)竞争条件发生在加载和存储之间发生 100 操作码抢占时,允许另一个线程开始递增计数器。当它回到挂起的线程时,它会继续使用“i”的旧值,同时撤消其他线程运行的增量。
使其成为线程安全的很简单;添加锁:
#!/usr/bin/python
import threading
i = 0
i_lock = threading.Lock()
def test():
global i
i_lock.acquire()
try:
for x in range(100000):
i += 1
finally:
i_lock.release()
threads = [threading.Thread(target=test) for t in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert i == 1000000, i
【讨论】:
比接受的答案更有帮助。谢谢! 赞成。如果为每个增量而不是每 100,000 个增量获取和释放锁,则您的锁示例将更具说明性。如果线程要按顺序执行而没有任何重叠,为什么还要打扰它们呢? @MarredCheese 因为这只是探索语言的特性。在实际工作负载中,总是会发生其他相互交织的事情,它们仅在特定时间点与锁定的事物交互。 声明global i
而不声明global i_lock
有什么原因吗?【参考方案8】:
为了确保我建议使用锁:
import threading
class ThreadSafeCounter():
def __init__(self):
self.lock = threading.Lock()
self.counter=0
def increment(self):
with self.lock:
self.counter+=1
def decrement(self):
with self.lock:
self.counter-=1
同步装饰器还可以帮助保持代码易于阅读。
【讨论】:
以上是关于+= 运算符在 Python 中是线程安全的吗?的主要内容,如果未能解决你的问题,请参考以下文章