安全迭代 WeakKeyDictionary 和 WeakValueDictionary

12
Python 3.2weakref模块的WeakKeyDictionaryWeakValueDictionary的文档中有一条关于迭代这些容器的注意事项:

Note: Caution: Because a WeakKeyDictionary is built on top of a Python dictionary, it must not change size when iterating over it. This can be difficult to ensure for a WeakKeyDictionary because actions performed by the program during iteration may cause items in the dictionary to vanish “by magic” (as a side effect of garbage collection).

作为这些容器行为规范的说明,这似乎相当可怕。特别是在运行使用 CPython 垃圾回收器(使用包含循环的数据结构)或使用另一个 Python 实现(例如 Jython)的代码时,听起来好像没有安全的方式可以迭代这些集合。
当垃圾回收器可能在程序的任何时候清除引用时,我如何安全地迭代这些集合?我的首要解决方案是针对 CPython 的,但我也对其他实现中的问题感兴趣。
在 WeakKeyDictionary 上进行迭代的安全方法是什么?
import weakref

d = weakref.WeakKeyDictionary()

...

for k, v in list(d.items()):
    ...
4个回答

12
在Python 2.7或Python 3.1+中,遍历WeakKeyDictionaryWeakValueDictionaryWeakSet是安全的。2010年时就加入了迭代保护,以防止弱引用回调在迭代期间从底层字典或集合中删除引用,但文档从未得到更新。
有了保护后,如果一个条目在迭代到它之前就死亡了,迭代将跳过该条目,但不会导致段错误、运行时错误或任何其他问题。已死条目将添加到待删除列表中,并稍后处理。 这里是保护代码(尽管有注释,但不线程安全)。
class _IterationGuard:
    # This context manager registers itself in the current iterators of the
    # weak container, such as to delay all removals until the context manager
    # exits.
    # This technique should be relatively thread-safe (since sets are).

    def __init__(self, weakcontainer):
        # Don't create cycles
        self.weakcontainer = ref(weakcontainer)

    def __enter__(self):
        w = self.weakcontainer()
        if w is not None:
            w._iterating.add(self)
        return self

    def __exit__(self, e, t, b):
        w = self.weakcontainer()
        if w is not None:
            s = w._iterating
            s.remove(self)
            if not s:
                w._commit_removals()

这里是WeakKeyDictionary弱引用回调函数检查守卫的地方

def remove(k, selfref=ref(self)):
    self = selfref()
    if self is not None:
        if self._iterating:
            self._pending_removals.append(k)
        else:
            del self.data[k]

这里是WeakKeyDictionary.__iter__设置守卫的地方

def keys(self):
    with _IterationGuard(self):
        for wr in self.data:
            obj = wr()
            if obj is not None:
                yield obj

__iter__ = keys

其他迭代器中使用相同的保护程序。


如果没有这个保护,调用list(d.items())也不安全。在items迭代器内部可能会发生GC传递并在迭代期间从字典中删除项目。(事实上list是用C语言编写的也提供不了保护。)

在2.6及以前的版本中,迭代WeakKeyDictionary或WeakValueDictionary最安全的方法是使用itemsitems将返回一个列表,并且它将使用底层字典的items方法,这个方法大部分情况下不会被GC打断。 3.0中字典API的变化改变了keys/values/items的工作方式,这可能就是引入该保护程序的原因。


“在项迭代器内部可能会发生GC传递并在迭代期间从字典中删除项目。(列表是用C编写的事实不提供保护。)”这是不正确的。GC必须持有GIL来更新引用计数并调用__del__方法,它无法与不释放GIL的C调用同时进行。这就是为什么大多数Python中的C调用都是原子的,如果它们不释放GIL,则没有其他Python代码可以与它们同时运行。” - Eloff
@Eloff:但 WeakKeyDictionary 的迭代器是用 Python 编写的,而不是 C。它与常规字典迭代器不同。(另外,CPython 没有使用单独的 GC 线程 - 它在正常线程中运行 GC。当 GC 中断正在运行的 Python 代码时,它会在该代码所在的线程内部执行,这意味着我们必须处理重入性,而不仅仅是线程安全问题。) - user2357112
我认为你是正确的,从C中调用迭代器会回调到解释器,这可能会释放和重新获取GIL。垃圾回收也可能在其中运行。 - Eloff

7

为了保证安全,您需要在某个地方保存引用。使用这个习语:

for k,v in list(d.items()):

这段代码并不完全安全,因为虽然它在大多数情况下是有效的,但在循环的最后一次迭代中,列表可能会被垃圾回收。

正确的做法应该是:

items = list(d.items())
for k,v in items:
    #do stuff that doesn't have a chance of destroying "items"
del items

如果您使用WeakKeyDictionary,您可以简单地存储键,并在使用WeakValueDictionary时存储值。

顺便提一下:在Python2中,.items()已经返回一个列表。

最终取决于您对“安全”的定义。如果您只是指迭代将正确进行(在所有元素上迭代一次),那么:

for k,v in list(d.items()):

这是安全的,因为字典的迭代实际上是通过 list(d.items()) 来执行的,然后您只需遍历列表即可。

如果您的意思是在迭代期间元素不应该作为 for 循环的副作用而“消失”在字典中,则必须保持强引用直到循环结束,并且这需要您在开始循环之前将列表存储在变量中。


2
为什么你的第一个示例是不安全的?该列表将包含每个键和值的强引用,并且在最后一次迭代期间,'k'和'v'保持对我所感兴趣的对象的强引用。因此,在最后一次迭代终止之前,该列表可能会被垃圾回收。是这样吗? - Feuermurmel
这就像说for k,v in d.items()是安全的,因为kv对对象保持强引用。如果在for循环内部有可能删除kv,那么迭代是不安全的。对于足够简单的任务,遍历WeakKeyDictionary应该是安全的。 - Bakuriu
3
我可能有所误解,但是如何删除kv对象的引用?只要这些变量在作用域内且没有被覆盖,它们所引用的对象就是安全的。或者你是在说在最后一次迭代期间删除所有对这些对象的强引用吗?那么这将改变字典,但不会不安全,因为字典在迭代开始后不再被访问。能否给出一个在最后一次迭代中可能出现问题的例子? - Feuermurmel

2

首先不使用迭代将其转换为强引用。

将原始答案转换为“最初的回答”。

items = []
while d:
    try:
        items.append(d.popitem())
    except KeyError:
        pass

如果在while循环期间丢失了一些键,这不应该导致问题。

然后您可以遍历items。完成后,d.update(items)将它们放回,然后del items

最初的回答:

如果在while循环期间丢失了一些键,这不会导致问题。您可以遍历items,完成后使用d.update(items)将其放回,然后使用del items删除它们。


0

禁用垃圾回收器。

import gc

gc.disable()
try:
    items = list(d.items())
finally:
    gc.enable()

那么请遍历items


这绝对不是那么简单的事情!首先,这不是可移植的,因为并非所有的Python实现都实现了该接口。其次,也许禁用垃圾回收是个坏主意,因为循环可能会运行很长时间。第三,这在并发应用程序中会有bug,因为一个线程可能会启用垃圾回收,而另一个线程仍在迭代字典。 - Feuermurmel

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