在 HashSet<T> 中,Contains 方法是线程安全的吗?

15

查看.NET源代码中HashSet<T>类中的Contains方法,我没有找到任何原因说明为什么Contains不是线程安全的。

我预先加载了一个HashSet<T>,并在多线程的AsParallel()循环中检查Contains

这样做是否安全?我不想使用ConcurrentDictionary,因为实际上我并不需要存储值。


你是在一个线程中写入数据,然后在多个线程中读取吗? - Yuval Itzchakov
4
只要在使用contains时不添加/删除集合中的元素,Contains就是线程安全的。 - nafas
3
为什么不读一下手册呢?https://msdn.microsoft.com/zh-cn/library/bb359438.aspx:*此类型的任何公共静态成员(在 Visual Basic 中为 Shared)都是线程安全的。任何实例成员都不能保证是线程安全的。* - user57508
2
MSDN并没有说它“不支持多线程”。他们只是不保证。原因可能是它没有经过测试,或者它可能会在未来的版本中改变。 - Ondra
1
@nafas 还有一个问题... 你必须确保在最后一次写入之后有一个 MemoryBarrier,否则读取可能会读取到一些不完整的数据。 - xanatos
显示剩余4条评论
3个回答

14
通常,仅用于读取的集合在“非正式”情况下是线程安全的(在.NET中,我所知道的没有修改自己的集合)。然而,有一些注意事项:
- 项目本身可能不是线程安全的(但是对于 HashSet 来说,这个问题应该最小化了,因为你无法从它中提取项目。尽管 GetHashCode() 和 Equals() 必须是线程安全的。如果,例如,它们访问按需加载的惰性对象,则可能不是线程安全的,或者可能缓存 / 存储某些数据以加速后续操作)。 - 必须确保在最后写入之后有一个 Thread.MemoryBarrier()(在相同线程中执行),否则另一个线程上的读取可能会读取不完整的数据。 - 必须确保在每个线程(不同于进行写操作的线程)在执行第一次读取之前都有一个 Thread.MemoryBarrier()。请注意,如果 HashSet 在创建/启动其他线程之前“准备好了”(使用 Thread.MemoryBarrier() 结束),则不需要 Thread.MemoryBarrier(),因为线程不能对内存进行过时读取(因为它们不存在)。各种操作会导致隐式的 Thread.MemoryBarrier()。例如,如果线程在 HashSet 填充之前就已经创建,进入了 Wait() 并在 HashSet 填充之后(加上它的 Thread.MemoryBarrier())被取消等待,则退出 Wait() 会导致隐式的 Thread.MemoryBarrier()。
下面是一个使用记忆化/按需加载/无论你称之为什么的类的简单示例,以此方式可以破坏线程安全性。
public class MyClass
{
    private long value2;

    public int Value1 { get; set; }

    // Value2 is lazily loaded in a very primitive
    // way (note that Lazy<T> *can* be used thread-safely!)
    public long Value2
    {
        get
        {
            if (value2 == 0)
            {
                // value2 is a long. If the .NET is running at 32 bits,
                // the assignment of a long (64 bits) isn't atomic :)
                value2 = LoadFromServer();

                // If thread1 checks and see value2 == 0 and loads it,
                // and then begin writing value2 = (value), but after
                // writing the first 32 bits of value2 we have that
                // thread2 reads value2, then thread2 will read an
                // "incomplete" data. If this "incomplete" data is == 0
                // then a second LoadFromServer() will be done. If the
                // operation was repeatable then there won't be any 
                // problem (other than time wasted). But if the 
                // operation isn't repeatable, or if the incomplete 
                // data that is read is != 0, then there will be a
                // problem (for example an exception if the operation 
                // wasn't repeatable, or different data if the operation
                // wasn't deterministic, or incomplete data if the read
                // was != 0)
            }

            return value2;
        }
    }

    private long LoadFromServer()
    {
        // This is a slow operation that justifies a lazy property
        return 1; 
    }

    public override int GetHashCode()
    {
        // The GetHashCode doesn't use Value2, because it
        // wants to be fast
        return Value1;
    }

    public override bool Equals(object obj)
    {
        MyClass obj2 = obj as MyClass;

        if (obj2 == null)
        {
            return false;
        }

        // The equality operator uses Value2, because it
        // wants to be correct.
        // Note that probably the HashSet<T> doesn't need to
        // use the Equals method on Add, if there are no
        // other objects with the same GetHashCode
        // (and surely, if the HashSet is empty and you Add a
        // single object, that object won't be compared with
        // anything, because there isn't anything to compare
        // it with! :-) )

        // Clearly the Equals is used by the Contains method
        // of the HashSet
        return Value1 == obj2.Value1 && Value2 == obj2.Value2;
    }
}

+1虽然我想更强调“非官方”。例如,由于记忆化,阅读操作可能不是线程安全的,但在这种特殊情况下似乎并非如此。 - Jon Hanna
@JonHanna 我已经添加了一个仍然需要使GetHashCode()和Equals()方法线程安全。例如,如果它们访问按需加载的延迟对象,则可能不是线程安全的,或者缓存数据以加速后续操作 - xanatos
是的,我更多地考虑到概括性;你在这里说的一切都没有任何错误,但我完全可以看到有人会跳过“通常”这个词。我们真正需要检查相关代码才能确定你的答案是否适用。 - Jon Hanna

6

假设您预先加载了数据集的值,您可以使用System.Collections.Immutable库中的ImmutableHashSet<T>。这个不可变集合声称自己是线程安全的,因此我们不必担心HashSet<T>的非官方线程安全。

var builder = ImmutableHashSet.CreateBuilder<string>(); // The builder is not thread safe

builder.Add("value1");
builder.Add("value2");

ImmutableHashSet<string> set = builder.ToImmutable();

...

if (set.Contains("value1")) // Thread safe operation
{
 ...
}

是的,那是很好的建议,也是我现在会使用的。如果我的记忆没有出错,它们在4年前还不存在。当时它是一个一次读取多次检查的场景,并且可以直接使用。但是现在我不会推荐使用它 - 有更好的选择。我曾经阅读过源代码,实际上还不错。 - Jim

4
来自微软:

线程安全集合

.NET Framework 4引入了System.Collections.Concurrent命名空间,其中包括多个既线程安全又可扩展的集合类。多个线程可以在不需要额外同步用户代码的情况下安全有效地向这些集合中添加或删除项。当您编写新代码时,请在多个线程同时写入集合时使用并发集合类。如果您只是从共享集合中读取数据,则可以使用System.Collections.Generic命名空间中的类。我们建议您不要使用1.0集合类,除非您需要针对.NET Framework 1.1或更早的运行时。

由于Contains不修改集合,它仅是一个读取操作,并且由于HashSetSystem.Collections.Generic中,因此同时调用Contains是绝对没问题的。


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