【问题标题】:Making a "modify-while-enumerating" collection thread-safe使“枚举时修改”集合线程安全
【发布时间】:2012-10-17 06:50:55
【问题描述】:

我想创建一个可以在枚举时修改的线程安全集合。

示例ActionSet 类存储Action 处理程序。它具有将新处理程序添加到列表的Add 方法和枚举并调用所有收集的操作处理程序的Invoke 方法。预期的工作场景包括非常频繁的枚举,在枚举时偶尔会进行修改。

如果您在枚举未结束时使用Add 方法修改普通集合,则会引发异常。

有一个简单但缓慢的解决方案:在枚举之前克隆集合:

class ThreadSafeSlowActionSet {
    List<Action> _actions = new List<Action>();

    public void Add(Action action) {
        lock(_actions) {
            _actions.Add(action);
        }
    }

    public void Invoke() {
        lock(_actions) {
            List<Action> actionsClone = _actions.ToList();
        }
        foreach (var action in actionsClone ) {
            action();
        }
    }
}

这个解决方案的问题是枚举开销,我希望枚举非常快。

我创建了一个相当快的“递归安全”集合,即使在枚举时也允许添加新值。如果在枚举主 _actions 集合时添加新值,这些值将添加到临时 _delta 集合而不是主集合。完成所有枚举后,_delta 值将添加到 _actions 集合中。如果在枚举主 _actions 集合时添加一些新值(创建 _delta 集合),然后再次重新输入 Invoke 方法,我们必须创建一个新的合并集合(_actions + _delta)并用它替换_actions

所以,这个集合看起来“递归安全”,但我想让它成为线程安全的。我认为我需要使用Interlocked.* 构造、System.Threading 中的类和其他同步原语来使这个集合线程安全,但我不知道如何做到这一点。

如何使这个集合线程安全?

class RecursionSafeFastActionSet {
    List<Action> _actions = new List<Action>(); //The main store
    List<Action> _delta; //Temporary buffer for storing added values while the main store is being enumerated
    int _lock = 0; //The number of concurrent Invoke enumerations

    public void Add(Action action) {
        if (_lock == 0) { //_actions list is not being enumerated and can be modified
            _actions.Add(action);
        } else { //_actions list is being enumerated and cannot be modified
            if (_delta == null) {
                _delta = new List<Action>();
            }
            _delta.Add(action); //Storing the new values in the _delta buffer
        }
    }

    public void Invoke() {
        if (_delta != null) { //Re-entering Invoke after calling Add:  Invoke->Add,Invoke
            Debug.Assert(_lock > 0);
            var newActions = new List<Action>(_actions); //Creating a new list for merging delta
            newActions.AddRange(_delta); //Merging the delta
            _delta = null;
            _actions = newActions; //Replacing the original list (which is still being iterated)
        }
        _lock++;
        foreach (var action in _actions) {
            action();
        }
        _lock--;
        if (_lock == 0 && _delta != null) {
            _actions.AddRange(_delta); //Merging the delta
            _delta = null;
        }
    }
}

更新:添加了ThreadSafeSlowActionSet 变体。

【问题讨论】:

  • 需要确认规范。如果在Invoke 期间调用了Add,那么添加的action 应该在Invoke 的那一回合中执行,稍后执行或未指定(即,无论哪个都可以)
  • 而且.. 我们是否保证所有后续的Invoke 都会调用之前添加的所有操作?
  • 你检查并发命名空间msdn.microsoft.com/en-us/library/…
  • @tia 规范非常简单。想象一下,每次调用 Invoke 时都会复制该列表并枚举该副本。 ActionSet 应该具有相同的行为(不同之处在于性能会更好,因为不是每次都复制列表 - 仅在 Invoke->Add,Invoke 情况下)。所以,1)添加的action只会在下一个Invoke期间执行。 2)我认为后续的Invoke(并发与否)应该调用之前添加的所有操作。
  • @CodeIgnoto 是的,我知道它们,但我对它们有一些问题。我需要经常执行的Invoke 方法的最佳性能。实际情况几乎不会涉及不同的线程(主要是递归,但发布的版本解决了这种情况)。我仍然希望该系列能够防线。

标签: c# .net collections concurrency thread-safety


【解决方案1】:

因为我实际上还需要从集合中删除项目,所以我最终使用的实现是基于重写的 LinkedList,它在删除/插入时锁定相邻节点并且不会抱怨在枚举过程中发生了变化。 我还添加了Dictionary 以使元素搜索更快。

【讨论】:

  • 你只锁定相邻节点?您是否考虑过死锁并检查它是否实际上比逐列表锁更快?
  • 我锁定了 3 个节点(上一个、当前和下一个)循环检查,直到 previous.Next == current。这个顺序应该可以防止死锁。
  • 但你是对的。我忘了我实际上把那个代码扔掉了。我想创建一个通用的线程安全单/双链表,然后用Dictionary 将其包装在一个类中以便快速访问。然后我注意到我仍然需要锁定整个结构,因为我必须锁定Dictionary。所以我从链表类中删除了所有锁和一般功能,并开始只锁定基于Dictionary 的主要部分。
【解决方案2】:

一种更简单的方法(例如,ConcurrentBag 使用)是让GetEnumerator() 在集合内容的快照上返回一个枚举器。在您的情况下,这可能看起来像:

public IEnumerator<Action> GetEnumerator()
{
    lock(sync)
    {
        return _actions.ToList().GetEnumerator();
    }
}

如果您这样做,则不需要 _delta 字段及其增加的复杂性。

【讨论】:

  • 对于频繁枚举的 OPs 问题,您可能希望以线程安全的方式缓存 ToList() 结果,并使任何 Add/Remove 的缓存无效。稍微复杂一点,但仍然比起点好。
  • @Damien_The_Unbeliever 您的变体绝对比@Joe 的变体更好(我应该从一开始就将其添加到问题中作为ThreadSafeSlowActionSet)。尽管如此,修改仍然相当普遍,并且列表克隆会降低性能(最重要的是它会导致 WP7 上频繁的 GC 导致的延迟)。
【解决方案3】:

这是为线程安全而修改的类:

class SafeActionSet
{
    Object _sync = new Object();
    List<Action> _actions = new List<Action>(); //The main store
    List<Action> _delta = new List<Action>();   //Temporary buffer for storing added values while the main store is being enumerated
    int _lock = 0; //The number of concurrent Invoke enumerations

    public void Add(Action action)
    {
        lock(sync)
        {
            if (0 == _lock)
            { //_actions list is not being enumerated and can be modified
                _actions.Add(action);
            }
            else
            { //_actions list is being enumerated and cannot be modified
                _delta.Add(action); //Storing the new values in the _delta buffer
            }
        }
    }

    public void Invoke()
    {
        lock(sync)
        {
            if (0 < _delta.Count)
            { //Re-entering Invoke after calling Add:  Invoke->Add,Invoke
                Debug.Assert(0 < _lock);
                var newActions = new List<Action>(_actions); //Creating a new list for merging delta
                newActions.AddRange(_delta); //Merging the delta
                _delta.Clear();
                _actions = newActions; //Replacing the original list (which is still being iterated)
            }
            ++_lock;
        }
        foreach (var action in _actions)
        {
            action();
        }
        lock(sync)
        {
            --_lock;
            if ((0 == _lock) && (0 < _delta.Count))
            {
                _actions.AddRange(_delta); //Merging the delta
                _delta.Clear();
            }
        }
    }
}

我做了一些其他的调整,原因如下:

  • 反转 IF 表达式首先具有常量值,所以如果我执行 错字并输入“=”而不是“==”或“!=”等,编译器将 立即告诉我错字。 (:我养成了一个习惯,因为我的大脑和手指经常不同步:)
  • 预分配 _delta,并调用 .Clear() 而不是将其设置为 null, 因为我觉得它更容易阅读。
  • 各种lock(_sync) {...} 为您提供all 实例变量访问的线程安全。 :( 除了您在枚举本身中访问 _action 之外。):

【讨论】:

  • 没有必要将常量放在if条件(称为yoda conditions)的前面,如果你把=而不是==,它将不会编译,除非变量是Boolean,在这种情况下编译器会发出警告。
  • 抱歉分区关闭。起初,当我第一次看到这个解决方案时,它似乎不是死锁/异常安全的,我基于ReaderWriterLockSlim =) 创建了一个可怕的 200 行实现。现在我看到这个解决方案真的是线程安全的。我怎么看不到……
  • @AllonGuralnek - 实际上在“C”语言中,如果x 是一个int 变量,if (x = 5) ... 编译得很好,并且总是正确的。虽然if (x = 0) ... 编译得很好并且总是错误的。您是正确的,在C++ 中,x 必须是Boolean 才能适用if (x = someExpression ) ...。您可能会因为在 if 中放置常量表达式而收到警告,但错误会迫使我修复它,而警告可能会溜走。是的,我确实意识到使用编译器来改善我的坏习惯本身就是一个坏习惯。 ;-)
猜你喜欢
  • 2014-08-13
  • 2012-04-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-01-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多