8

我目前正在查看一个写时复制集实现,并想确认它是线程安全的。我相当确定它可能不是的唯一方法是允许编译器在某些方法中重新排序语句。例如,该Remove方法如下所示:

public bool Remove(T item)
{
    var newHashSet = new HashSet<T>(hashSet);
    var removed = newHashSet.Remove(item);
    hashSet = newHashSet;
    return removed;
}

其中 hashSet 定义为

private volatile HashSet<T> hashSet;

所以我的问题是,鉴于 hashSet 是否volatile意味着Remove新集合上的 发生在写入成员变量之前?如果没有,那么其他线程可能会在删除发生之前看到该集合。

我实际上在生产中没有看到任何问题,但我只是想确认它是安全的。

更新

更具体地说,还有另一种获取 的方法IEnumerator

public IEnumerator<T> GetEnumerator()
{
    return hashSet.GetEnumerator();
}

所以更具体的问题是:是否可以保证返回的对象IEnumerator永远不会ConcurrentModificationException从删除中抛出一个?

更新 2

抱歉,答案都是针对多个作者的线程安全问题。提出了很好的观点,但这不是我想在这里找到的。我想知道是否允许编译器将操作重新排序为Remove如下所示:

    var newHashSet = new HashSet<T>(hashSet);
    hashSet = newHashSet;                  // swapped
    var removed = newHashSet.Remove(item); // swapped
    return removed;

如果这是可能的,则意味着线程可以在分配GetEnumerator之后调用hashSet,但之前item被删除,这可能导致在枚举期间修改集合。

Joe Duffy 有一篇博客文章指出:

Volatile on load 意味着 ACQUIRE,不多也不少。(当然还有额外的编译器优化限制,比如不允许在循环之外进行提升,但现在让我们关注 MM 方面。) ACQUIRE 的标准定义是后续内存操作可能不会在 ACQUIRE 指令之前移动;例如,给定 { ld.acq X, ld Y },ld Y 不能出现在 ld.acq X 之前。但是,之前的内存操作肯定可以在它之后移动;例如,给定 { ld X, ld.acq Y },ld.acq Y 确实可以出现在 ld X 之前。当前运行的唯一实际发生这种情况的处理器 Microsoft .NET 代码是 IA64,但这是一个值得注意的领域CLR的MM比大部分机器都弱。接下来,.NET 上的所有存储都是 RELEASE(不管 volatile,即 volatile 就 jitted 代码而言是无操作的)。RELEASE 的标准定义是之前的内存操作在 RELEASE 操作之后可能不会移动;例如,给定 { st X, st.rel Y },st.rel Y 不能出现在 st X 之前。但是,后续的内存操作确实可以在它之前移动;例如,给定 { st.rel X, ld Y },ld Y 可以在 st.rel X 之前移动。

我读这个的方式是调用newHashSet.Remove需要 ald newHashSet和写入hashSet需要 a st.rel newHashSet。从上面的 RELEASE 定义来看,在 store RELEASE 之后没有负载可以移动,所以语句不能重新排序!有人可以确认请确认我的解释是正确的吗?

4

3 回答 3

3

考虑使用Interlocked.Exchange - 它将保证排序,或Interlocked.CompareExchange作为附带好处,您可以检测(并可能从中恢复)同时写入集合。显然,它增加了一些额外的同步级别,因此它与您当前的代码不同,但更容易推理。

public bool Remove(T item) 
{ 
    var old = hashSet;
    var newHashSet = new HashSet<T>(old); 
    var removed = newHashSet.Remove(item); 
    var current = Interlocked.CompareExchange(ref hashSet, newHashSet, old);
    if (current != old)
    {
       // failed... throw or retry...
    }
    return removed; 
} 

volatile hashSet我认为在这种情况下 你仍然需要。

于 2012-07-06T07:53:09.697 回答
1

编辑:

感谢您澄清调用 Remove (和其他集合突变)的外部锁的存在。

由于 RELEASE 语义,您最终不会将新值存储到,hashSet直到变量的值被分配(因为不能在 之后移动removedst removedst.rel hashSet

Therefore, the 'snapshot' behaviour of GetEnumerator will work as intended, at least with respect to Remove and other mutators implemented in a similar fashion.

于 2012-07-10T19:39:58.793 回答
0

我不能代表 C#,但在 C 中,volatile 原则上表示并且仅表示变量的内容可以随时更改。它对编译器或 CPU 重新排序没有任何限制。您得到的只是编译器/CPU 将始终从内存中读取值,而不是信任缓存版本。

然而,我相信在最近的 MSVC(很可能是 C#)中,读取 volatile 充当加载的内存屏障,而写入充当存储的内存屏障,例如 CPU 将停止直到所有加载完成并且没有加载可以逃脱这个通过在易失性读取下方重新排序(尽管后来的独立加载可能仍会向上移动到内存屏障之上);并且 CPU 将停止直到存储完成,并且没有存储可以通过在易失性写入下方重新排序来逃避这种情况(尽管后来的独立写入仍可能向上移动到内存屏障之上)。

当只有一个线程正在写入给定变量(但许多正在读取)时,正确操作只需要内存屏障。当多个线程可能写入给定变量时,必须使用原子操作,因为 CPU 设计使得写入时基本上存在竞争条件,因此写入可能会丢失。

于 2012-07-06T09:16:06.123 回答