扩展 Python 列表(例如 l += [1])是否保证是线程安全的



如果我有一个整数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

相关内容

  • 没有找到相关文章

最新更新