【问题标题】:Is the following thread safe?以下线程安全吗?
【发布时间】:2015-06-15 08:08:28
【问题描述】:

我有以下代码,想知道它是否是线程安全的。我只在从集合中添加或删除项目时锁定,但在迭代集合时不锁定。迭代时锁定会严重影响性能,因为集合可能包含数十万个项目。有什么建议可以使这个线程安全吗?

谢谢

public class Item
{
    public string DataPoint { get; private set; }

    public Item(string dataPoint)
    {
        DataPoint = dataPoint;
    }
}

public class Test
{
    private List<Item> _items; 
    private readonly object myListLock = new object();

    public Test()
    {
        _items = new List<Item>();
    }

    public void Subscribe(Item item)
    {
        lock (myListLock)
        {
            if (!_items.Contains(item))
            {
                _items.Add(item);
            }
        }
    }

    public void Unsubscribe(Item item)
    {
        lock (myListLock)
        {
            if (_items.Contains(item))
            {
                _items.Remove(item);
            }
        }
    }

    public void Iterate()
    {
        foreach (var item in _items)
        {
            var dp = item.DataPoint;
        }
    }

}

编辑

我很好奇,并再次分析了未锁定迭代与在 myListLock 上的锁定内迭代之间的性能,并且锁定超过 1000 万个项目的迭代的性能开销实际上非常小。

【问题讨论】:

  • 我知道这可能不是您要查找的评论,但您是否知道Concurent Dictionary 是线程安全的?
  • 如果您正在修改集合,则需要禁用迭代,因为如果集合在迭代过程中被修改,foreach 会引发错误。
  • @Thomas,是的,但它也严重影响了我运行的一些测试中的性能
  • 处于调试模式?有时,安全地跑比不系安全带的快跑要慢一​​点……你不觉得吗?
  • 如果您的锁争用非常低(“非常低”的意思是“profile and see”),那么并发收集确实会表现得更慢。此外,迭代并发集合还会在所有存储桶上获得完全锁定,并在迭代之前创建一个单独的列表(类似于下面的@xanatos 答案)——这不是字典最适合的方法。但与在 Iterate 内部完成的实际处理相比,这种性能损失可能可以忽略不计。

标签: c# multithreading collections concurrency locking


【解决方案1】:

不,它不是线程安全的,因为可以在您查看集合时对其进行修改...您可以做什么:

Item[] items; 

lock (myListLock)
{
    items = _items.ToArray();
}

foreach (var item in items)
{
    var dp = item.DataPoint;
}

所以你在循环之前将集合复制到lock 中。这显然会占用内存(因为您必须复制 List&lt;&gt;)(ConcurrentBag&lt;&gt;.GetEnumerator() 几乎完全一样)

请注意,这仅在 Item 是线程安全的情况下才有效(例如,因为它是不可变的)

【讨论】:

  • 谢谢,但为什么Item 需要线程安全才能工作?如果我将Item 中的DataPoint 属性更改为public set; 并在迭代中更改DataPoint,为什么这会导致问题?
  • @Matt:更改字符串值本身不会导致异常,因为更改引用是 .NET 中的原子值并且字符串是不可变的。这取决于您在 Iterate 方法中所做的事情以及此方法是否期望此值可能会更改。我建议也使Item 不可变。如果您需要更改 DataPoint 值,请使用您的 SubscribeUnsubscribe 方法(已知是线程安全的)将旧的不可变实例替换为新实例。
  • @MattWolf 您提供的示例可能不是您正在使用的完整工作程序。如果DataPoint 只是一个string 属性,它不是“即时”计算的(所以一个没有复杂get 的属性,但是,正如你所写的,get; set;),那么没问题。但是如果例如 DataPoint 是基于其他属性在 get 中计算出来的(例如 string FullName { get { return FirstName + " " + LastName; } } 给出一个简单的例子),那么它可能不是线程安全的(因为 FirstNameLastName 可能是单独更新)
  • @MattWolf 因为这正是您不想要的:在迭代时锁定会严重影响性能,因为集合可能包含数十万个项目。您必须确定在锁定下迭代是否足够慢以值得在迭代List&lt;&gt; 的副本之前复制List&lt;&gt;(显然,即使在lock 下复制也需要一些时间,而且它仍然是一个 O(n) 操作及时)
  • @xanatos 不错的答案,但请不要忘记提及,在这种情况下,对列表项目的访问将是单向的; foreach 循环中的任何版本都不会影响原始项目库存。
【解决方案2】:

理论上你的代码不是线程安全的。


在后台foreach执行正常的for循环,如果您在foreach遍历您的列表时从不同的线程添加项目,则可能会遗漏一个项目。此外,如果您删除一个项目(从一个不同的线程),您可能会得到一个 AV 异常或者 - 更糟糕的是 - 乱码数据。

如果您希望您的代码是线程安全的,您有两种选择:

  1. 您可以克隆您的列表(为此我通常使用.ToArray() 方法)。这将导致内存中的列表增加一倍,并且其结果对于原位版本可能不是最新的,或者...
  2. 您可以将整个迭代放在一个锁定的块中,这将导致在您执行长时间运行的操作时阻止其他线程访问该数组。

【讨论】:

  • @mg30rg,所以如果我在锁中进行迭代,那么它会是线程安全的吗?
  • 因为上面的代码如果小心使用是相当稳定的。我可以使用上面的代码编写一个长时间运行的测试代码(无需额外的同步手段),它不会在异常上运行。这就是为什么线程很难。
  • @MattWolf 是的。
  • @mg30rg,谢谢,如果我可以问一个密切相关的问题。如果我在锁中进行迭代,并且迭代本身是一个运行时间较长的过程,这是否会阻止对同一类中其他公共的、不相关的属性的访问?
  • @MattWolf 它只会阻止lock()-ed 代码的执行。
【解决方案3】:

不,不是。请注意,MSDN 上记录的所有类都有一个关于线程安全的部分(接近尾声):https://msdn.microsoft.com/en-us/library/6sh2ey19%28v=vs.110%29.aspx

GetEnumerator 的文档有更多注释:https://msdn.microsoft.com/en-us/library/b0yss765%28v=vs.110%29.aspx

关键是迭代本身不是线程安全的。即使从集合中迭代读取的每个单独的迭代都是线程安全的,但如果集合被修改,一致的迭代通常会中断。即使集合本身从未处于不一致状态,您也可能会遇到诸如两次读取相同元素或跳过某些元素等问题。

顺便说一句,您的 Unsubscribe() 正在对列表进行两次线性搜索,这可能不是您想要的。您不需要在 Remove() 之前调用 Contains()。

【讨论】:

  • 我知道,但这并不能回答我的问题。在您的链接中,它说锁定读写操作将提供线程安全。不过我不确定迭代...
  • @MattWolf 是的,我没有仔细阅读 MSDN 部分。我看到你已经解决了这个问题,但我还是添加了一些注释。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多