如果我有一个整数i
,在多个线程上执行i += 1
是不安全的:
>>> i = 0
>>> def increment_i():
... global i
... for j in range(1000): i += 1
...
>>> threads = [threading.Thread(target=increment_i) for j in range(10)]
>>> for thread in threads: thread.start()
...
>>> for thread in threads: thread.join()
...
>>> i
4858 # Not 10000
但是,如果我有一个列表l
,在多个线程上执行l += [1]
似乎确实很安全:
>>> l = []
>>> def extend_l():
... global l
... for j in range(1000): l += [1]
...
>>> threads = [threading.Thread(target=extend_l) for j in range(10)]
>>> for thread in threads: thread.start()
...
>>> for thread in threads: thread.join()
...
>>> len(l)
10000
l += [1]
是否保证线程安全?如果是这样,这是否适用于所有Python实现或仅适用于CPython?
编辑:似乎l += [1]
是线程安全的,但l = l + [1]
不是......
>>> l = []
>>> def extend_l():
... global l
... for j in range(1000): l = l + [1]
...
>>> threads = [threading.Thread(target=extend_l) for j in range(10)]
>>> for thread in threads: thread.start()
...
>>> for thread in threads: thread.join()
...
>>> len(l)
3305 # Not 10000
没有快乐的;-)回答这个问题。 没有任何保证,你可以通过注意Python参考手册不保证原子性来确认。
在CPython中,这是一个实用主义的问题。 正如effbot文章的截图部分所说,
从理论上讲,这意味着精确的记帐需要对PVM [Python Virtual Machine]字节码实现有准确的理解。
这是实话。 CPython专家知道L += [x]
原子,因为他们知道以下所有内容:
-
+=
编译为INPLACE_ADD
字节码。 - 列表对象的
INPLACE_ADD
实现完全用 C 编写(执行路径上没有 Python 代码,因此无法在字节码之间释放 GIL)。 - 在
listobject.c
中,INPLACE_ADD
的实现是函数list_inplace_concat()
,在执行过程中也不需要执行任何用户的Python代码(如果这样做,GIL可能会再次被释放)。
这听起来可能很难直截了当,但对于一个对CPython内部结构有了解的人来说(在他写那篇文章的时候),事实并非如此。 事实上,鉴于知识的深度,这一切都是显而易见的;-)
因此,作为一个语用学问题,CPython专家总是自由地依赖"'看起来原子化'的操作实际上应该是原子化的",这也指导了一些语言决策。 例如,effbot列表中缺少的操作(在他写了那篇文章后添加到语言中):
x = D.pop(y) # or ...
x = D.pop(y, default)
(当时)支持添加dict.pop()
的一个论点恰恰是,显而易见的 C 实现将是原子的,而正在使用的(当时)替代方案:
x = D[y]
del D[y]
不是原子的(检索和删除是通过不同的字节码完成的,因此线程可以在它们之间切换)。
但是文档从未说过.pop()
是原子的,也永远不会。 这是一种"同意的成年人"的事情:如果你足够专业,可以故意利用这一点,你就不需要手把手。 如果你不够专业,那么effbot文章的最后一句话适用:
如有疑问,请使用互斥锁!
作为一个务实的必要性,核心开发人员永远不会打破 effbot 在 CPython 中的例子(或D.pop()
或D.setdefault()
)的原子性。 但是,其他实现根本没有义务模仿这些务实的选择。 事实上,由于在这些情况下的原子性依赖于CPython的特定字节码形式,再加上CPython使用只能在字节码之间释放的全局解释器锁,因此对于其他实现来说,模仿它们可能是一个真正的痛苦。
而且你永远不知道:CPython的某些未来版本也可能删除GIL! 我对此表示怀疑,但理论上是可能的。 但是如果发生这种情况,我敢打赌保留 GIL 的并行版本也会被保留,因为很多代码(尤其是用 C
编写的扩展模块)也依赖于 GIL 来实现线程安全。
值得重复:
如有疑问,请使用互斥锁!
从 https://docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe:
全局解释器锁 (GIL) 在内部使用,以确保一次只有一个线程在 Python VM 中运行。通常,Python 仅在字节码指令之间提供线程之间的切换;它切换的频率可以通过 sys.setswitchinterval() 设置。因此,从Python程序的角度来看,每个字节码指令以及从每个指令到达的所有C实现代码都是原子的。
以下操作都是原子的(L,L1,L2是列表,D,D1,D2是字典,x,y是对象,i,j是整数):
L.append(x)
L1.extend(L2)
x = L[i]
x = L.pop()
L1[i:j] = L2
L.sort()
x = y
x.field = y
D[x] = y
D1.update(D2)
D.keys()
这些不是:
i = i+1
L.append(L[-1])
L[i] = L[j]
D[x] = D[x] + 1
以上纯粹是特定于CPython的,并且可能会因不同的Python实现(如PyPy)而异。
顺便说一下,记录原子 Python 操作有一个悬而未决的问题 - https://bugs.python.org/issue15339