如何保证对Array中“reference type”项的更新对其他线程可见?

时间:2012-08-14 10:15:07

标签: c# multithreading synchronization volatile

private InstrumentInfo[] instrumentInfos = new InstrumentInfo[Constants.MAX_INSTRUMENTS_NUMBER_IN_SYSTEM];

public void SetInstrumentInfo(Instrument instrument, InstrumentInfo info)
{
    if (instrument == null || info == null)
    {
        return;
    }
    instrumentInfos[instrument.Id] = info;  // need to make it visible to other threads!
}

public InstrumentInfo GetInstrumentInfo(Instrument instrument)
{
    return instrumentInfos[instrument.Id];  // need to obtain fresh value!
}
从不同的线程调用

SetInstrumentInfoGetInstrumentInfoInstrumentInfo是不可变类。 我打算在致电GetInstrumentInfo时获得最新的副本吗?我担心我可以收到“缓存”副本。我应该添加一种同步吗?

instrumentInfos声明为volatile无济于事,因为我需要将数组项声明为volatile,而不是数组本身。

我的代码是否有问题,如果有问题怎么解决?

UPD1:

我需要我的代码在现实生活中工作,不符合所有规范!因此,如果我的代码在现实生活中起作用,但在某些环境下的某些计算机上“理论上”不起作用 - 那没关系!

  • 我需要我的代码在Windows下使用最新的.NET Framework在现代X64服务器(目前有2个处理器HP DL360p Gen8)上工作。
  • 我不需要在奇怪的计算机或Mono或其他任何东西下使用我的代码
  • 我不想引入延迟,因为这是HFT软件。因此,“Microsoft的实现使用强大的内存模型进行写入。这意味着写入被视为易失性”我可能不需要添加额外的Thread.MemoryBarrier,除了增加延迟之外什么也不做。我认为我们可以相信微软将在未来版本中继续使用“强大的内存模型”。至少微软不太可能改变内存模型。所以我们假设它不会。

UPD2:

最近的建议是使用Thread.MemoryBarrier();。现在我不明白我必须插入它的确切位置,以使我的程序适用于标准配置(x64,Windows,Microsoft .NET 4.0)。请记住,我不想插入行“只是为了能够在IA64或.NET 10.0上启动程序”。速度对我来说比便携性更重要。但是,如何更新我的代码以便它可以在任何计算机上运行也会很有趣。

UPD3

.NET 4.5解决方案:

    public void SetInstrumentInfo(Instrument instrument, InstrumentInfo info)
    {
        if (instrument == null || info == null)
        {
            return;
        }
        Volatile.Write(ref instrumentInfos[instrument.Id], info);
    }

    public InstrumentInfo GetInstrumentInfo(Instrument instrument)
    {
        InstrumentInfo result = Volatile.Read(ref instrumentInfos[instrument.Id]);
        return result;
    }

8 个答案:

答案 0 :(得分:3)

这是一个长而复杂的答案的问题,但我会尝试将其提炼成一些可行的建议。

<强> 1。简单的解决方案:只能在锁定下访问instrumentInfos

避免多线程程序不可预测性的最简单方法是始终使用锁保护共享状态。

根据您的评论,您觉得这个解决方案太贵了。您可能需要仔细检查该假设,但如果情况确实如此,那么让我们看看其余的选项。

<强> 2。高级解决方案:Thread.MemoryBarrier

或者,您可以使用Thread.MemoryBarrier:

private InstrumentInfo[] instrumentInfos = new InstrumentInfo[Constants.MAX_INSTRUMENTS_NUMBER_IN_SYSTEM]; 

public void SetInstrumentInfo(Instrument instrument, InstrumentInfo info) 
{ 
    if (instrument == null || info == null) 
    { 
        return; 
    } 

    Thread.MemoryBarrier(); // Prevents an earlier write from getting reordered with the write below

    instrumentInfos[instrument.Id] = info;  // need to make it visible to other threads! 
} 

public InstrumentInfo GetInstrumentInfo(Instrument instrument) 
{ 
    InstrumentInfo info = instrumentInfos[instrument.Id];  // need to obtain fresh value! 
    Thread.MemoryBarrier(); // Prevents a later read from getting reordered with the read above
    return info;
}

在写入之前使用Thread.MemoryBarrier ,在之后使用读取可防止潜在的麻烦。第一个内存屏障阻止写入线程重新排序使用将对象发布到数组中的写入来初始化对象字段的写入,并且第二个内存屏障阻止读取线程重新排序从数组接收对象的读取随后读取该对象的字段。

作为旁注,.NET 4还公开了使用Thread.MemoryBarrier的Thread.VolatileRead和Thread.VolatileWrite,如上所示。但是,对于System.Object以外的引用类型,Thread.VolatileRead和Thread.VolatileWrite没有重载。

第3。高级解决方案(.NET 4.5):Volatile.Read和Volatile.Write

.NET 4.5公开了比完全内存屏障更高效的Volatile.Read和Volatile.Write方法。如果您的目标是.NET 4,则此选项无效。

<强> 4。 “错误但会碰巧工作”解决方案

你永远不应该依赖我要说的话。但是......您不太可能重现原始代码中存在的问题。

事实上,在.NET 4中的X64上,如果你能再现它,我会非常惊讶。 X86-X64提供了相当强大的内存重新排序保证,因此这些类型的发布模式恰好可以正常工作。 .NET 4 C#编译器和.NET 4 CLR JIT编译器也避免了会破坏您的模式的优化。因此,允许重新排序内存操作的三个组件都不会这样做。

也就是说,有些(有些模糊的)发布模式的变体实际上在X64中不适用于.NET 4。因此,即使您认为代码永远不需要在除.NET 4 X64之外的任何体系结构上运行,如果您使用正确的方法之一,您的代码将更易于维护,即使问题目前在您的服务器上不可重现

答案 1 :(得分:2)

我解决此问题的首选方法是使用关键部分。 C#有一个名为“Lock”的内置语言结构,可以解决这个问题。 lock语句确保同时在该关键部分中不能有多个线程。

private InstrumentInfo[] instrumentInfos = new InstrumentInfo[Constants.MAX_INSTRUMENTS_NUMBER_IN_SYSTEM];

public void SetInstrumentInfo(Instrument instrument, InstrumentInfo info)
{
    if (instrument == null || info == null) {
        return;
    }
    lock (instrumentInfos) {
        instrumentInfos[instrument.Id] = info;
    }
}

public InstrumentInfo GetInstrumentInfo(Instrument instrument)
{
    lock (instrumentInfos) {
        return instrumentInfos[instrument.Id];
    }
}

这确实有性能影响,但它始终确保您获得可靠的结果。如果您遍历instrumentInfos对象,这将特别有用;对于像这样的代码,你绝对需要一个lock语句。

请注意,lock是一种通用解决方案,可确保以可靠和原子方式执行任何复杂语句。在您的情况下,因为您正在设置对象指针,您可能会发现可以使用更简单的构造,如线程安全列表或数组 - 前提是这些只是触及instrumentInfos的两个函数。有关详细信息,请访问:Are C# arrays thread safe?

编辑:关于您的代码的关键问题是:InstrumentInfo是什么?如果它是从object派生的,那么每次更新共享数组时都需要注意始终构造一个新的InstrumentInfo对象,因为在单独的线程上更新对象会导致问题。如果它是struct,则lock语句将提供您需要的所有安全性,因为其中的值将在写入和读取时被复制。

编辑2:从更多的研究来看,似乎确实存在一些线程共享变量的更改不会出现在某些编译器配置中的情况。该主题讨论了一种情况,即可以在一个线程上设置“bool”值而从不在另一个线程上检索:Can a C# thread really cache a value and ignore changes to that value on other threads?

但是,我注意到这种情况仅存在于涉及.NET值类型的情况。如果你看看Joe Ericson的回应。这个特定代码的反编译说明了原因:线程共享值被读入寄存器,然后从不重新读取,因为编译器优化了循环中不必要的读取。

鉴于您正在使用一组共享对象,并且假设您已经封装了所有访问器,我认为您使用原始方法是完全安全的。但是:使用锁定造成的性能损失非常小,几乎可以忘记。例如,我编写了一个简单的测试应用程序,尝试调用SetInstrumentInfoGetInstrumentInfo 1000万次。表现结果如下:

  • 没有锁定,1000万设置和接听电话:2秒149毫秒
  • 锁定,1000万设置和接听电话:2秒195毫秒

这大致转化为原始表现:

  • 没有锁定,你可以执行4653327 get&amp;每秒设置一次通话。
  • 通过锁定,您可以执行4555808 get&amp;每秒设置一次通话。

从我的角度来看,lock()声明值得2.1%的性能权衡。根据我的经验,这些getset方法只占应用程序执行时间的最小部分。您可能最好在其他地方寻找潜在的优化,或花费额外的资金来获得更高的时钟速率CPU。

答案 2 :(得分:2)

您可以使用系统级同步原语(如Mutex);但那些有点笨手笨脚。互斥体真的很慢,因为它是一个内核级原语。这意味着您可以跨进程拥有互斥代码。这不是您所需要的,您可以使用在lockMonitor.EnterMonitor.Exit等单一流程中运行的成本低得多的广告。

您不能使用VolatileRead / WriteMemoryBarrier之类的内容,因为如果未按原子方式分配数组中的元素,则需要对集合进行写入操作。 VolatileRead / WriteMemoryBarrier不会这样做。这些只是让你获得并释放语义。这意味着写入的内容对其他线程是“可见的”。如果你没有对集合写入原子,你就可以破坏数据 - 获取/释放语义对此没有帮助。

e.g:

private InstrumentInfo[] instrumentInfos = new InstrumentInfo[Constants.MAX_INSTRUMENTS_NUMBER_IN_SYSTEM];

private readonly object locker = new object();

public void SetInstrumentInfo(Instrument instrument, InstrumentInfo info)
{
    if (instrument == null || info == null)
    {
        return;
    }
    lock(locker)
    {
        instrumentInfos[instrument.Id] = info;
    }
}

public InstrumentInfo GetInstrumentInfo(Instrument instrument)
{
    lock(locker)
    {
        return instrumentInfos[instrument.Id];
    }
}

并发收集也会这样做;但是,由于每个操作都受同步保护,因此会产生成本。另外,并发集合只知道访问元素的原子性,你仍然必须确保应用程序级别的原子性:如果你使用并发集合执行以下操作:

public bool Contains(Instrument instrument)
{
   foreach(var element in instrumentInfos)
   {
       if(element == instrument) return true;
   }
}

...你至少有几个问题。第一,你没有停止SetInstrumentInfo在枚举时修改集合(许多集合不支持这个和throw和异常)。即,仅在检索单个元素时“保护”该集合。二,每次迭代都会保护集合。如果您有100个元素并且并发集合使用锁定,则会获得100个锁定(假设要查找的元素是最后一个或根本找不到)。这将比它需要的慢得多。如果您不使用并发集合,则只需使用lock即可获得相同的结果:

public bool Contains(Instrument instrument)
{
   lock(locker)
   {
      foreach(var element in instrumentInfos)
      {
          if(element == instrument) return true;
      }
   }
}

并且只有一个锁并且性能更高。

更新我认为必须指出,如果InstrumentInfo结构,请使用lock(或其他一些同步原语)变得更加重要,因为它将具有值语义,并且每个赋值都需要移动尽可能多的字节InstrumentInfo用于存储其字段(即,它不再是32位或64位本机字引用分配)。即一个简单的InstrumentInfo赋值(例如对数组元素)永远不会是原子的,因此不是线程安全的。

UPDATE 2 如果替换数组元素的操作可能是原子的(如果InstrumentInfo是引用并且IL指令stelem.ref是原子的),则变为简单的引用赋值。 (即,将元素写入数组的行为是原子的与上面提到的相似)在这种情况下,如果只处理instrumentInfos的代码是您发布的,那么您可以使用Thread.MemoryBarrier()

public void SetInstrumentInfo(Instrument instrument, InstrumentInfo info)
{
    if (instrument == null || info == null)
    {
        return;
    }
    Thread.MemoryBarrier();
    instrumentInfos[instrument.Id] = info;
}

public InstrumentInfo GetInstrumentInfo(Instrument instrument)
{
    var result = instrumentInfos[instrument.Id];
    Thread.MemoryBarrier();
    return result;
}

...如果可能的话,这相当于声明数组volatile中的每个元素。

VolatileWrite不起作用,因为它需要引用它将写入的变量,你不能给它引用数组元素。

答案 3 :(得分:0)

据我所知,在我多年的设备驱动程序编程中,volatile用于可以在CPU控制之外进行更改的内容,即通过硬件干预,或者用于映射到系统空间的硬件内存(CSR等) 。当线程更新内存位置时,CPU会锁定高速缓存行并引发处理器间中断,以便其他CPU可以丢弃它。所以你没有机会阅读过时的数据。从理论上讲,如果数据不是原子(多个四边形),您可能只担心并发写入和读取到同一个数组位置,因为读者可能会读取部分更新的数据。我认为这不会发生在一系列参考文献中。

我会猜测你想要实现的目标,因为它看起来类似于我过去开发的app +驱动程序,用于显示来自手机摄像头测试板的流媒体视频。视频显示应用程序可以对每个帧应用一些基本的图像处理(白平衡,偏移像素等)。这些设置是来自UI的SET和来自处理线程的GET。从处理线程的角度来看,设置是不可变的。看起来你正在尝试用类似音频而不是视频做类似的事情。

我在C ++应用程序中采用的方法是将当前设置复制到伴随“框架”的结构中。因此,每个框架都有自己的设置副本,可以应用于它。 UI线程执行锁定以写入对设置的更改,并且处理线程执行锁定以复制设置。锁定是必需的,因为多个四边形被移动,没有锁定,我们可以确定读取部分更新的设置。并不重要,因为在流媒体中可能没有人会注意到混乱的帧,但如果他们暂停视频或将帧保存到磁盘,那么他们肯定会在黑暗的墙壁中发现闪亮的绿色像素。在声音的情况下,即使在蒸汽过程中也很容易发现毛刺。

那是第一种情况。我们来看看案例二。

当设备被未知数量的线程使用时,如何对设备进行彻底重新配置?如果您继续执行此操作,那么您可以保证多个线程将从配置A开始,并且在此过程中将遇到配置B,其具有高度确定性意味着死亡。这就是你需要读写器锁等东西的地方。也就是说,一个同步原语允许您等待当前活动完成,同时阻止新活动。这是读写器锁的本质。

案例结束二。现在让我们看看你的麻烦是什么,当然我的猜测是正确的。

如果您的工作线程在其处理周期开始时执行GET并在整个周期中保持该引用,那么您不需要锁定,因为参考更新是原子的,如Peter所述。这是我建议给你的实现,它相当于我在每帧处理开始时复制设置结构。

但是,如果你从整个代码中进行多次 GET,那么你就麻烦了,因为在同一个处理周期内,一些调用将返回引用A,一些调用将返回引用B.如果可以的话与您的应用程序,然后你是一个幸运的人。

但是,如果您遇到此问题的问题,那么您必须修复错误或尝试通过构建它来修补它。修复错误很简单:消除多个GET,即使这会导致您进行少量重写,以便您可以传递参考。

如果要修补该错误,请使用Reader-Writer锁。每个线程获取一个读取器锁定,执行GET,然后保持锁定直到其循环结束。然后它释放读卡器锁。更新数组的线程获取写入器锁,执行SET并立即释放写入器锁。

该解决方案几乎可以正常工作,但是当代码长时间保持锁定时,即使它的读取器锁定,它也会发臭。

但是,即使我们忘记了多线程编程,也存在一个更微妙的问题。线程在循环开始时获取读锁定,在结束时释放它,然后启动新循环并重新获取它。当编写器出现时,线程无法在编写器完成之前启动其新的循环,这意味着直到现在正在工作的所有其他读取器线程完成其处理。这意味着某些线程在重新启动它们的周期之前会遇到意外的高延迟,因为即使它们是高优先级线程,它们也会被写入器锁定请求阻止,直到所有当前读取器也完成它们的周期。如果您正在做音频或其他时间关键的事情,那么您可能希望避免这种行为。

我希望我做了一个很好的猜测,我的解释很明确: - )

答案 4 :(得分:0)

由于某些原因,我没有时间了解,我无法在任何地方添加任何评论,所以我添加了另一个答案。对于给您带来的不便,我深表歉意。

UPD3当然有效,但危险误导

如果实际需要UPD3解决方案,那么请考虑以下场景:ThreadA在CPU1上运行并对Var1进行原子更新。然后ThreadA被抢占,调度程序决定在CPU2上重新安排它。 Ooopss ...由于Var1在CPU1的缓存中,那么根据UPD3的完全错误的逻辑,ThreadA应该对Var1进行易失性写入,然后进行易失性读取。 Oooopsss ...一个线程永远不会知道它何时被重新安排或者最终会在哪个CPU上运行。因此,根据UPD3完全错误的逻辑,线程应始终执行易失性写入和易失性读取,否则它可能会读取过时数据。这意味着所有线程都应该始终进行易失性读/写。

对于单个线程,更新多字节结构(非原子更新)时问题会更严重。想象一下,一些更新发生在CPU1上,一些发生在CPU2上,一些发生在CPU3上等。

有点让我想知道为什么这个世界还没有结束。

答案 5 :(得分:-1)

或者,您可以使用System.Collections.Concurrent命名空间中的某些类,例如ConcurrentBag<T>类。这些类构建为线程安全的。

http://msdn.microsoft.com/en-us/library/system.collections.concurrent.aspx

答案 6 :(得分:-1)

在这种情况下,您可以使用ConcurrentDictionary。这是线程安全的。

答案 7 :(得分:-1)

非常有趣的问题。我希望埃里克或乔恩能够直接创造纪录,但在此之前让我尽力而为。另外,请允许我通过说明 100%确定此答案是正确的。

通常,在处理“我是否让这个字段变得易变?”问题,你担心这样的情况:

SomeClass foo = new SomeClass("foo");

void Thread1Method() {
    foo = new SomeClass("Bar");
    ....
}

void Thread2Method() {
    if (foo.Name == "foo") ...
    ...
    // Here, the old value of foo might be cached 
    // even if Thread1Method has already updated it.
    // Making foo volatile will fix that.
    if (foo.Name == "bar") ...
}

在您的情况下,您不是要更改整个数组的地址,而是更改该数组中元素的值,该数组是(可能)本机字大小的托管指针。这些更改是原子的,但我不确定它们是否保证具有内存屏障(获取/释放语义,根据C#规范)并刷新数组本身。因此,我建议在数组本身上添加volatile

如果数组本身是易失性的,那么我认为读取线程不可能将项目缓存出数组。您似乎不需要将元素设置为volatile,因为您没有在数组中就地修改元素 - 您将使用新元素直接替换它们(至少通过事物的外观!)。

你可能遇到麻烦的地方是:

var myTrumpet = new Instrument { Id = 5 };
var trumpetInfo = GetInstrumentInfo(myTrumpet);
trumpetInfo.Name = "Trumpet";
SetInstrumentInfo(myTrumpet, trumpetInfo);

这里你正在更新项目,所以看到缓存发生我不会感到非常惊讶。不过,我不确定它是否真的会。 MSIL包含用于读取和写入数组元素的显式操作码,因此语义可能与标准堆读取和写入不同。