.NET Framework中的并发HashSet <T>?

kuk*_*kab 132 c# multithreading mutex locking thread-safety

我有以下课程.

class Test{
    public HashSet<string> Data = new HashSet<string>();
}
Run Code Online (Sandbox Code Playgroud)

我需要从不同的线程更改字段"Data",所以我想对我当前的线程安全实现有一些看法.

class Test{
    public HashSet<string> Data = new HashSet<string>();

    public void Add(string Val){
            lock(Data) Data.Add(Val);
    }

    public void Remove(string Val){
            lock(Data) Data.Remove(Val);
    }
}
Run Code Online (Sandbox Code Playgroud)

是否有更好的解决方案,直接进入现场并保护它免受多线程的并发访问?

Zen*_*ulz 147

您的实施是正确的.遗憾的是,.NET Framework不提供内置的并发hashset类型.但是,有一些解决方法.

ConcurrentDictionary(推荐)

第一个是ConcurrentDictionary<TKey, TValue>在命名空间中使用该类System.Collections.Concurrent.在这种情况下,值是没有意义的,所以我们可以使用一个简单的byte(内存中的1个字节).

private ConcurrentDictionary<string, byte> _data;
Run Code Online (Sandbox Code Playgroud)

这是推荐的选项,因为类型是线程安全的,并且提供与HashSet<T>except键相同的优点,值是不同的对象.

来源:社交MSDN

ConcurrentBag

如果您不介意重复条目,则可以ConcurrentBag<T>在上一个类的同一名称空间中使用该类.

private ConcurrentBag<string> _data;
Run Code Online (Sandbox Code Playgroud)

自我实现

最后,正如您所做的那样,您可以使用锁定或.NET为您提供线程安全的其他方式来实现您自己的数据类型.这是一个很好的例子:如何在.Net中实现ConcurrentHashSet

该解决方案的唯一缺点是该类型HashSet<T>不能正式并发访问,即使对于读取操作也是如此.

我引用链接帖子的代码(最初由Ben Mosher编写).

using System.Collections.Generic;
using System.Threading;

namespace BlahBlah.Utilities
{
    public class ConcurrentHashSet<T> : IDisposable
    {
        private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
        private readonly HashSet<T> _hashSet = new HashSet<T>();

        #region Implementation of ICollection<T> ...ish
        public bool Add(T item)
        {
            _lock.EnterWriteLock();
            try
            {
                return _hashSet.Add(item);
            }
            finally
            {
                if (_lock.IsWriteLockHeld) _lock.ExitWriteLock();
            }
        }

        public void Clear()
        {
            _lock.EnterWriteLock();
            try
            {
                _hashSet.Clear();
            }
            finally
            {
                if (_lock.IsWriteLockHeld) _lock.ExitWriteLock();
            }
        }

        public bool Contains(T item)
        {
            _lock.EnterReadLock();
            try
            {
                return _hashSet.Contains(item);
            }
            finally
            {
                if (_lock.IsReadLockHeld) _lock.ExitReadLock();
            }
        }

        public bool Remove(T item)
        {
            _lock.EnterWriteLock();
            try
            {
                return _hashSet.Remove(item);
            }
            finally
            {
                if (_lock.IsWriteLockHeld) _lock.ExitWriteLock();
            }
        }

        public int Count
        {
            get
            {
                _lock.EnterReadLock();
                try
                {
                    return _hashSet.Count;
                }
                finally
                {
                    if (_lock.IsReadLockHeld) _lock.ExitReadLock();
                }
            }
        }
        #endregion

        #region Dispose
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
                if (_lock != null)
                    _lock.Dispose();
        }
        ~ConcurrentHashSet()
        {
            Dispose(false);
        }
        #endregion
    }
}
Run Code Online (Sandbox Code Playgroud)

编辑:移动try块的入口锁定方法,因为它们可以抛出异常并执行finally块中包含的指令.

  • @Ralf嗯,这是一个集合,而不是列表,因为它是无序的. (37认同)
  • 根据MSDN关于["集合和同步(线程安全)"的相当简短的文档](http://msdn.microsoft.com/en-us/library/573ths2x(v = vs.71).aspx), System.Collections和相关的命名空间可以安全地由多个线程读取.这意味着多个线程可以安全地读取HashSet. (9认同)
  • 带有垃圾值的字典是一个列表 (8认同)
  • @Oliver,一个引用每个条目使用更多的内存,即使它是一个"null"引用(引用在32位运行时需要4个字节,在64位运行时需要8个字节).因此,使用`byte`,空结构或类似内容可以减少内存占用(或者如果运行时将本地内存边界上的数据对齐以便更快访问,则可能不会). (6认同)
  • @Jono准确地说,类型“ConcurrentBag”没有实现“Contains”方法。相反,扩展方法“Contains”可通过“Enumerable”类型使用,因为“ConcurrentBag”实现“IEnumerable&lt;T&gt;”。因此,当您进入“Contains”的实现时,您可以看到集合被“天真地”迭代以找到搜索的值。这意味着“ConcurrentBag”的搜索复杂度为 O(n),而“ConcurrentDictionary”和“HashSet”根据其数据结构有专用的搜索算法,通常摊销为 O(1)。 (6认同)
  • Self-implementation不是ConcurrentHashSet,而是ThreadSafeHashSet.这两者之间存在很大差异,这就是为什么Micorosft放弃了SynchronizedCollections(人们弄错了).为了实现像GetOrAdd等"并发"操作,应该实现(比如字典),否则无法在没有额外锁定的情况下确保并发性.但是如果你需要在课外进行额外的锁定,为什么不从一开始就使用简单的HashSet呢? (4认同)
  • 首先在HashSet上具有GetOrAdd的意义是什么?GetOrAdd用于键/值对。在这里,我们只有一个值,它是它自己的密钥。由于这个`Add`足以覆盖`GetOrAdd`功能。要么值不存在,要么它已经添加了,要么就在那里,但是您不需要获取它,因为您已经拥有了它。 (3认同)
  • `concurrentBag` 不是一个选项,因为它不提供类似于 hashset 的方法。例如“删除(T 项)”。 (3认同)
  • @AaronHS 线程安全和并发之间的主要区别在于线程安全“保证多个线程同时安全执行”,而并发包括事务的含义。这一切都取决于您认为操作是什么,它是多么原子。此自定义实现不是并发的,因为 2 个线程不能同时添加相同的值。为此,必须使用额外的锁。如果没有额外的锁,您无法检查值是否存在并在它不存在时添加它。并发集合不需要额外的锁。 (2认同)
  • @AaronHS @GeorgeMavritsakis指出的重要区别是,如果没有`GetOrAdd`,则上面代码中的`Contains`的结果是不确定的,因此实际上使该函数充其量是有用的,或在最坏的情况下是无用的。对于调用者而言,只能“提示”的API不方便-如果误解,则会出错。您对“语义学”的概念似乎集中在顺序的“正确性”上,这确实是必不可少的,但是当然,有些人也认为有形的性能影响是语义上的。此处显示的方法严重限制了呼叫者在该状态下的选择/能力。 (2认同)
  • @HankSchultz:可悲的是,情况已经不是这样了.目前,[集合和同步(线程安全)](https://msdn.microsoft.com/en-us/library/573ths2x.aspx)说:"以下文本仅适用于必须以.NET版本为目标的程序版本4之前的框架." 我怀疑有什么变化,但它不再是_documented_是安全的.请注意,某些集合(例如,字典)被单独记录为在并发读取时是安全的.但是,hashset不是. (2认同)
  • @Jono 使用 LINQ `Contains` 是错误的。[`ConcurrentBag&lt;T&gt;` - 线程安全](https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentbag-1#thread-safety): *"但是,成员通过 `ConcurrentBag&lt;T&gt;` 实现的接口之一访问,包括扩展方法,不保证线程安全,并且可能需要由调用者同步。”* `ConcurrentBag&lt;T&gt;` 甚至不应该被提及作为一个选项。这使得这个答案具有误导性。 (2认同)

i3a*_*non 31

而不是包装ConcurrentDictionary或锁定HashSet我创建的实际ConcurrentHashSet基于ConcurrentDictionary.

这个实现支持每个项目的基本操作而没有HashSet设置操作,因为它们在并发场景中没有意义IMO:

var concurrentHashSet = new ConcurrentHashSet<string>(
    new[]
    {
        "hamster",
        "HAMster",
        "bar",
    },
    StringComparer.OrdinalIgnoreCase);

concurrentHashSet.TryRemove("foo");

if (concurrentHashSet.Contains("BAR"))
{
    Console.WriteLine(concurrentHashSet.Count);
}
Run Code Online (Sandbox Code Playgroud)

输出:2

您可以从的NuGet得到它在这里和在Github上查看源在这里.

  • @Neo No ...因为它故意使用**HashSet <T>**语义,你调用__Add__并返回一个布尔值,指示项目是否被添加(true),或者它是否已经存在(false).https://msdn.microsoft.com/en-us/library/bb353005(v=vs.110).aspx (6认同)
  • 这应该是公认的答案,很棒的实施 (3认同)
  • @Nekromancer 正如我在回答中所说,我认为在并发实现中提供这些 set 方法没有意义。例如,`Overlaps` 要么需要在整个运行过程中锁定实例,要么提供一个可能已经是错误的答案。这两个选项都是不好的 IMO(并且可以由消费者从外部添加)。 (2认同)

Sør*_*sen 20

由于没有人提及它,我将提供一种替代方法,可能适用于您的特定目的,也可能不适合您:

Microsoft Immutable Collections

来自MS团队的博客文章背后:

虽然并发创建和运行比以往更容易,但仍然存在一个基本问题:可变共享状态.从多个线程读取通常非常简单,但是一旦状态需要更新,它就会变得更加困难,尤其是在需要锁定的设计中.

锁定的替代方法是使用不可变状态.不可变数据结构保证永远不会改变,因此可以在不同的线程之间自由传递,而不必担心踩到别人的脚趾.

这种设计会产生一个新问题:如何管理状态变化而不是每次都复制整个状态?当涉及集合时,这尤其棘手.

这是不可变集合的用武之地.

这些集合包括ImmutableHashSet <T>ImmutableList <T>.

性能

由于不可变集合使用下面的树数据结构来实现结构共享,因此它们的性能特征与可变集合不同.与锁定可变集合进行比较时,结果将取决于锁争用和访问模式.但是,从另一篇关于不可变集合的博客文章中获取:

问:我听说过不可变的集合很慢.这些有什么不同吗?当性能或内存很重要时,我可以使用它们吗?

答:这些不可变的集合经过高度调整,在可比较的集合中具有竞争性的性能特征,同时平衡了内存共享.在某些情况下,它们几乎与可变集合一样快,无论是在算法上还是在实际时间内,有时甚至更快,而在其他情况下,它们在算法上更复杂.然而,在许多情况下,差异可以忽略不计.通常,您应该使用最简单的代码来完成工作,然后根据需要调整性能.不可变集合可以帮助您编写简单的代码,尤其是在必须考虑线程安全时.

换句话说,在许多情况下,差异不会明显,你应该选择更简单的选择 - 对于并发集将使用ImmutableHashSet<T>,因为你没有现有的锁定可变实现!:-)

  • 如果您的目的是从多个线程更新共享状态,或者我在这里遗漏了一些东西,那么 `ImmutableHashSet&lt;T&gt;` 并没有多大帮助? (6认同)
  • @tugberk是的,不.由于该集合是不可变的,因此您必须更新对它的引用,该集合本身对您没有帮助.好消息是,您已经将从多个线程更新共享数据结构的复杂问题简化为更简单的更新共享引用的问题.该库为您提供[ImmutableInterlocked.Update](https://msdn.microsoft.com/en-us/library/mt806088(v = vs.111).aspx)方法来帮助您. (4认同)
  • @SørenBoisen刚刚阅读了有关不可变集合的信息,并尝试找出如何安全地使用它们。`ImmutableInterlocked.Update` 似乎是缺失的环节。谢谢你! (2认同)