【问题标题】:Basic .NET threading - Most efficient way to synchronize an object for a single reader and single writer thread基本 .NET 线程 - 为单个读取器和单个写入器线程同步对象的最有效方法
【发布时间】:2011-12-13 18:15:17
【问题描述】:

我正在使用 c# 框架 2.0

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

我总是可以使用 lock(),但我怎样才能使这个数组线程安全,同时为读取器线程提供最小的性能和延迟开销。

【问题讨论】:

  • 你为什么用ArrayList而不是List<object>List<SpecificType>

标签: c# multithreading performance .net-2.0


【解决方案1】:

编辑:任何遇到此问题的人,我鼓励您查看讨论。关于读者线程修改数组列表中的对象的观点对于这一类关于共享集合的问题非常重要(以及为什么在这种情况下我可能会选择“只是在整个事情上放一个大锁”而不是我在这里给出的没有对这些修改进行详细分析的方法。不过,以下方法仍然适用于某些情况:


对于您描述的读写模式,我将执行以下操作。

假设 ArrayList 被称为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 对多个阅读器是安全的。赋值是安全的,因为我们覆盖了引用,它是原子的。我们只需要确保存在内存屏障,尽管在大多数当前系统的实践中我们可能会跳过它(但不要,理论上至少它是必需的,我们不要猜测它)。

这对于大多数共享使用 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]

调用BinarySearchCloneContainsIndexOfGetRange也是安全的,但使用BinarySearchContainsIndexOf的结果来决定并不安全你对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. 您锁定每个对象以进行写入和读取 - 虽然使用如此精细的锁不太可能出现争用锁,但这有其自身的重量,实际上您并不比默认(并且应该是默认)答案更好的“只是锁,无论如何,锁在没有争议的情况下很便宜)。

【讨论】:

  • 注:这假设您上面所说的每个细节都是正确的。作为对案例细节的优化,它做了很多违背传统观念的事情。
  • 你写的代码进入读取arraylist的reader线程例程?如果是这种情况,那么做一个“新”对我来说太贵了,因为它被调用了很多。
  • 我想知道,因为 write 涉及在 arraylist 中删除和添加元素,因此会更改 list.Count,在 list.Count 上循环的读取器部分中的 for 循环可能会为修改的集合抛出异常或索引已更改...或者您认为您的逻辑也会解决这个问题?
  • ...那里,更新到详细说明读者在这种情况下做什么和不安全。尽管如此,它确实是对尝试这种方法的一个警告(有一天你会陷入不安全的情况而忘记了另一个线程)。它是在你真正需要它的时候使用的。好吧,当你在 stackoverflow.com 上寻找有趣的问题时 :)
  • 好的,所以在我的例子中,作者只添加/删除列表中的对象,而不修改对象本身。阅读器确实修改了数组列表中包含的对象,但不修改列表本身。其中包含的对象是可变的。所以你仍然认为在阅读器线程中做一个 foreach 是安全的吗?在我的情况下,处理陈旧的列表并不是一个大问题,因为它只会发生一次,但它必须是安全的并且必须是快速的。
【解决方案2】:

由于只有读者和一名作者,ReaderWriterLock 在这种情况下无济于事。在这种情况下,经典的lock 可能是最好的选择。

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

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

【讨论】:

  • 看起来 SpinLock 仅在 Framework 4 中可用,他正在寻找 framework 2.0 解决方案。不知道能不能用reflector来创建SpinLock类的framework 2.0?
  • 我链接了更多关于它如何工作的参考。它可以通过重复调用 Interlocked.CompareExchange 来实现。
  • 创建一个好的 SpinLock 有一些陷阱。我记得乔·达菲(Joe Duffy)在他们身上写了一些好东西。不过,我认为根本没有必要锁定阅读。
【解决方案3】:

我只会使用lock。如果没有竞争,它会非常快——当然可以达到毫秒级。

【讨论】:

    【解决方案4】:

    我最近经历了这个过程。事实证明,单生产者/消费者队列的无锁版本的性能提升几乎毫无意义(每个队列/出队大约 0.0002 毫秒)。

    我的建议是只使用 lock() 语句,它有效。你也可以看看我写的无锁版本,见Writing a Lockless Queue for a single producer/consumer。该系列的最后一篇文章有​​一个locking queue,您也可以使用。

    【讨论】:

    • 虽然它不是生产者/消费者队列,但它是一个共享集合。
    • @Jon Hanna,OPs 声明“我需要为单线程读取和另一个单线程编写器场景使其线程安全......”让我相信上面链接的代码文章可以修改以适应他的需要。同意这不是一个完美的匹配,但如果他坚持走那条路,这只是一个起点。同样,我的建议是只使用 lock() 语句。
    • 我不同意,因为类似数组列表的结构的表面比队列复杂得多。您可以创建无锁版本,但它远没有队列(或堆栈 - 队列和堆栈可能是最容易实现无锁的两个容器)那么简单。即便如此,“简单”和“容易”相对——在这两种情况下仍有很多需要考虑的地方。
    猜你喜欢
    • 2011-06-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多