基本.net线程——为单个读线程和单个写线程同步对象的最有效方法

本文关键字:线程 对象 有效 同步 方法 net 单个读 基本 单个写 | 更新日期: 2023-09-27 18:17:38

我使用的是c# framework 2.0

我有一个对象数组列表。我需要使其线程安全的单线程读取和另一个单线程写入场景。读取每秒发生几百次,而写(包括在数组中删除或添加元素)很少发生,如果有的话,可能每周一次。

我总是可以使用lock(),但我如何才能使这个数组线程安全与最低的性能和延迟开销的读线程。

基本.net线程——为单个读线程和单个写线程同步对象的最有效方法

编辑:如果有人遇到这个问题,我鼓励你看看讨论。关于reader线程修改数组列表中的对象的观点对于这类关于共享集合的问题非常重要(这也是为什么在这种情况下我可能会选择"只是把整个东西锁起来",而不是我在这里给出的方法,但没有详细分析这些修改)。不过,以下代码在某些情况下仍然可以使用:


对于你所描述的读到写的模式,我将做如下操作:

假设这个数组列表叫做al。对于所有的读取,我(大多数情况下,见下文)都会忽略所有线程安全(别担心,我的疯狂中有方法)。如果要写,我会这样做:

ArrayList temp = new ArrayList(al);//create copy of al
/* code to update temp goes here */
al = temp;//atomic overwrite of al.
Thread.MemoryBarrier();//make sure next read of al isn't moved by compiler or from stale cache value.

复制al是安全的,因为ArrayList对于多个读取器是安全的。赋值是安全的,因为我们覆盖了引用,它是原子的。我们只需要确保存在内存屏障,尽管在大多数当前系统中我们可能会跳过它(尽管不要,理论上至少它是必需的,让我们不要事后猜测)。

对于大多数共享使用数组列表的人来说,这将是非常可怕的,因为它使写操作的成本比必须的要高得多。然而,正如你所说的,这些操作的频率比读操作的频率低60,000,000倍,所以在这种情况下,这种平衡是有意义的。

使用这种方法的安全读取注意事项:

我做了一个错误的假设,因为考虑了一个类似的情况。以下是安全的:

for(object obj in al)
  DoSomething(obj);

即使DoSomething可能需要很长时间。原因是,这调用GetEnumerator()一次,然后在一个枚举器上工作,即使al在此期间发生变化(它将是陈旧的,但安全的),也将保持与相同列表的关系。

以下内容不安全:

for(int i = 0; i < al.Count; ++i)
  DoSomething(al[i]);//

Count是安全的,但是当你到达al[i]时,它可能已经过时了,这可能意味着当al只有20个项目时,你调用al[43]

调用BinarySearch, Clone, Contains, IndexOfGetRange也是安全的,但使用BinarySearch, ContainsIndexOf的结果来决定al的第二件事是不安全的,因为它可能在那时已经改变了。

下面也是安全的:

ArrayList list = al;
for(int i = 0; i != list.Count; ++i)
  DoSomething(list[i]);
DoSomethingElse(list);
DoSomethingReallyComplicated(list);
for(int i = 0; i != list.Count; ++i)
 SureWhyNotLetsLoopAgain(list[i]);

注意,我们没有复制al,只是复制引用,所以这非常非常便宜。我们对list所做的一切都是安全的,因为如果它过时了,它仍然是一个线程自己的旧列表版本,不会出错。

。要么在foreach中执行所有操作(我错误地假设了这种情况),将单个调用的结果返回给上面提到的安全方法(但不要用它来决定是否对al执行另一个操作),要么将al赋值给局部变量并对其进行操作。


另一个真正的重要警告是,写线程是否会对数组列表中包含的任何对象设置任何属性或调用任何非线程安全方法。如果是这样,那么您就从需要考虑一个线程同步问题变成了需要考虑几十个线程同步问题!

如果是这种情况,那么你不仅要担心数组列表,还要担心每个可以改变的对象(我所说的改变是指在StringBuilder上调用.Append("abc")的方式会改变该对象,而不是像str = "abc"改变str那样用一个全新的对象来替换)。

有四种安全的可能性:

  1. 你的数组列表中有不可变的对象-快乐的日子。
  2. 你有可变对象,但不要改变它们——也很好,但你必须确定
  3. 你有线程安全的对象-好吧,但这确实意味着你至少有一组线程问题比这个问题处理的线程问题更复杂。
  4. 为每个对象的写和读都锁上——虽然锁被争用的可能性较小,但这有它自己的沉重,实际上你并不比默认的(它应该是默认的)答案"只是锁,锁在没有争用的时候是便宜的"更好。

由于只有一个读取器和一个写入器,因此ReaderWriterLock在这种情况下没有帮助。在这种情况下,经典的lock可能是最好的选择。

话虽如此,由于读取器需要经常读取,因此经常检查锁,一个健壮的解决方案将是SpinLock: http://msdn.microsoft.com/en-us/library/system.threading.spinlock.aspx

Edit:的确,正如David Silva指出的,SpinLock在。net 2.0中不可用。然而,它可以通过重复调用Interlocked.CompareExchange来实现,但在这个阶段可能不涉及细节。经典的lock是你最好的选择。

我只使用lock。如果它是无争用的,它就会非常快——当然可以达到毫秒级。

我最近经历了这个过程。事实证明,单生产者/消费者队列的无锁版本的性能增益几乎没有意义(每个队列/dequeue大约0.0002 ms)。

我的建议是只使用lock()语句,它工作。你也可以看看我写的无锁版本,参见为单个生产者/消费者写一个无锁队列。本系列的上一篇文章还提供了一个可以使用的锁定队列。