我目前正在寻找一个写时复制集实现,并希望确认它的线程安全。我很确定它可能不是唯一的方法是允许编译器在某些方法中重新排序语句。例如,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有blog article声明:
负载上的易失性意味着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 (无论是不稳定的,也就是挥发性而言,就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
的调用需要ld newHashSet
,而hashSet
的写入需要st.rel newHashSet
。从上面的RELEASE定义中,没有负载可以在商店RELEASE之后移动,所以语句不能重新排序!如果有人确认,请确认我的解释是否正确?
答案 0 :(得分: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
。
答案 1 :(得分:1)
编辑:
感谢您澄清了对Remove(和其他集合突变)调用的外部锁的存在。
由于RELEASE语义,您不会在 之后 {<1}}之前将<{1}}的新值存储到hashSet
(因为removed
} st removed
)之后无法移动。
因此,st.rel hashSet
的'快照'行为将按预期工作,至少就删除和以类似方式实现的其他mutator而言。
答案 2 :(得分:0)
我不能代表C#,但在C中,原则上表示volatile并且只表示变量的内容可能随时发生变化。它不提供有关编译器或CPU重新排序的限制。你得到的只是编译器/ CPU将始终从内存中读取值,而不是信任缓存版本。
然而,我相信在最近的MSVC中(很可能是C#),读取易失性作为加载和写入的记忆屏障,充当商店的内存屏障,例如CPU将停止,直到所有负载完成,并且没有负载可以通过在易失性读取之下重新排序来逃避这种情况(尽管后来的独立负载仍可能在存储器屏障之上移动);并且CPU将停止,直到存储已经完成,并且没有商店可以通过在易失性写入下方重新排序来逃避这一点(尽管后来的独立写入仍然可以在内存屏障之上移动)。
当只有一个线程正在写入给定变量(但许多正在读取)时,只需要内存屏障即可正确操作。当多个线程可以写入给定变量时,必须使用原子操作,因为CPU设计使得写入时基本上存在竞争条件,从而可能丢失写入。