list(dict.items())线程安全吗?



在下面的例子中使用list(d.items())是否安全?

import threading
n = 2000
d = {}
def dict_to_list():
while True:
list(d.items())  # is this safe to do?
def modify():
for i in range(n):
d[i] = i
if __name__ == "__main__":
t1 = threading.Thread(target=dict_to_list, daemon=True)
t1.start()
t2 = threading.Thread(target=modify, daemon=True)
t2.start()
t2.join()

这个问题背后的背景是,字典项视图上的迭代器在每一步检查字典大小是否改变,如下面的例子所示。

d = {}
view = d.items()  # this is an iterable
it = iter(view)  # this is an iterator
d[1] = 1
print(list(view))  # this is ok, it prints [(1, 1)]
print(list(it))  # this raises a RuntimeError because the size of the dictionary changed

因此,如果上面第一个示例中对list(...)的调用可以被中断(即,线程t1可以释放GIL),那么第一个示例可能会导致线程t1中出现RuntimeErrors。有消息称该操作不是原子操作,请参阅此处。然而,我还没能让第一个例子崩溃。

我理解这里安全的做法是使用一些锁,而不是试图依赖于某些操作的原子性。但是,我正在调试第三方库中的一个问题,该库使用类似的代码,并且我不能直接更改。

简短的回答:这可能是好的,但无论如何要使用锁。

使用dis你可以看到list(d.items())实际上是两个字节码指令(68):

>>> import dis
>>> dis.dis("list(d.items())")
1           0 LOAD_NAME                0 (list)
2 LOAD_NAME                1 (d)
4 LOAD_METHOD              2 (items)
6 CALL_METHOD              0
8 CALL_FUNCTION            1
10 RETURN_VALUE

在Python FAQ中,它说(通常)用C实现的东西是原子的(从运行Python程序的角度来看):

什么类型的全局值突变是线程安全的?

一般来说,Python只提供在字节码指令之间在线程之间切换;[…]。因此,从Python程序的角度来看,每个字节码指令以及从每个指令到达的所有C实现代码都是原子的。

[…]

例如,下列操作都是原子[…]

D.keys()

list()是用C实现的,d.items()是用C实现的,所以每个都应该是原子的,除非它们最终以某种方式调用Python代码(如果它们调用你使用Python实现覆盖的dunder方法可能会发生),或者如果你使用dict的子类而不是真正的dict,或者如果他们的C实现释放GIL。依赖它们是原子性的不是一个好主意。

你提到,如果iter()的底层可迭代对象改变大小,它会出错,但这与这里无关,因为.keys().values().items()返回一个视图对象,而这些对象在底层对象改变时没有问题:

d = {"a": 1, "b": 2}
view = d.items()
print(list(view))  # [("a", 1), ("b", 2)]
d["c"] = 3         # this could happen in a different thread
print(list(view))  # [("a", 1), ("b", 2), ("c", 3)]

如果你一次在多个指令中修改字典,你有时会得到d处于不一致的状态,有些修改已经完成,有些还没有,但是你不应该得到RuntimeError,就像你对iter()那样,除非你以一种非原子的方式修改它。

我怀疑那篇文章的作者对字典视图感到困惑,认为dict.items返回一个iterator像Python 2中的dict.iteritems一样,不是iterable就像在Python 3中一样。请注意,这篇文章写于13年前,比Python 3.0发布早了5个月。顺便说一句,正如PEP 3106所说(强调我的):

原始计划是简单地让。keys(),。values()和。items()返回iterator,即与Python 2.x中iterkeys(), itervalues()和iteritems()的返回值完全相同。

Python 2,iteritems给出iterator:
>>> d = {1: 1, 2: 2, 3: 3}
>>> items = d.iteritems()
>>> items
<dictionary-itemiterator object at 0x0000000003EBA958>
>>> next(items)
(1, 1)
>>> list(items)
[(2, 2), (3, 3)]
>>> list(items)
[]
Python 3,items给出了一个iterable,不是iterator:
>>> d = {1: 1, 2: 2, 3: 3}
>>> items = d.items()
>>> items
dict_items([(1, 1), (2, 2), (3, 3)])
>>> next(items)
Traceback (most recent call last):
File "<pyshell#9>", line 1, in <module>
next(items)
TypeError: 'dict_items' object is not an iterator
>>> list(items)
[(1, 1), (2, 2), (3, 3)]
>>> list(items)
[(1, 1), (2, 2), (3, 3)]

在Python 2中,使用iterator,这会导致错误:

>>> d = {1: 1, 2: 2, 3: 3}
>>> items = d.iteritems()
>>> d[4] = 4
>>> next(items)
Traceback (most recent call last):
File "<pyshell#26>", line 1, in <module>
next(items)
RuntimeError: dictionary changed size during iteration

在Python 3中,如果d.items()确实返回一个迭代器,也就是说,如果它等于iter(d.items()),那么它将是不安全的。因为您的线程可能在iter()创建迭代器和list()使用迭代器之间被中断。但是由于它返回iterable,是list()函数本身在内部创建了一个iterator因此,迭代器的创建和使用都发生在同一条单字节码指令(执行list()函数)期间。

如果您将代码更改为list(iter(d.items()))并将n增加为20000000,那么您可能会得到错误。从运行的例子试试它在线!:

Exception in thread Thread-1:
Traceback (most recent call last):
File "/usr/lib64/python3.8/threading.py", line 932, in _bootstrap_inner
self.run()
File "/usr/lib64/python3.8/threading.py", line 870, in run
self._target(*self._args, **self._kwargs)
File ".code.tio", line 9, in dict_to_list
list(iter(d.items()))  # is this safe to do?
RuntimeError: dictionary changed size during iteration