11
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!
}

SetInstrumentInfoGetInstrumentInfo从不同的线程调用。 InstrumentInfo是不可变的类。打电话时我能保证有最新的副本GetInstrumentInfo吗?恐怕我会收到“缓存”副本。我应该添加某种同步吗?

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

我的代码有问题吗?如果有,如何解决?

UPD1:

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

  • 我需要我的代码在 Windows 下使用最新的 .NET 框架在现代 X64 服务器(当前为 2 个处理器 HP DL360p Gen8)上工作。
  • 我不需要在奇怪的计算机或 Mono 或其他任何东西下工作我的代码
  • 我不想引入延迟,因为这是 HFT 软件。因此,“微软的实现使用强大的内存模型进行写入。这意味着写入被视为易失性”,我可能不需要添加额外的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;
    }
4

8 回答 8

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 以外的任何体系结构上运行,如果您使用正确的方法之一,您的代码将更易于维护,即使该问题目前无法在您的服务器上重现.

于 2012-08-15T23:57:42.873 回答
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。此处提供了更多详细信息:C# 数组线程安全吗?

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

编辑 2:从更多的研究来看,似乎确实存在某些情况下,对线程共享变量的更改不会出现在某些编译器配置中。该线程讨论了一个“布尔”值可以在一个线程上设置而从不在另一个线程上检索的情况:C# 线程真的可以缓存一个值并忽略其他线程上对该值的更改吗?

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

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

  • 不加锁,1000万设置和接听:2秒149毫秒
  • 带锁,1000万设置和接听:2秒195毫秒

这转化为大致的原始性能:

  • 不加锁,每秒可以执行 4653327 次 get & set 调用。
  • 使用锁定,您可以每秒执行 4555808 次获取和设置调用。

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

于 2012-08-14T18:29:07.570 回答
2

您可以使用系统级同步原语(如 Mutex);但那些有点笨拙。Mutex 真的很慢,因为它是内核级别的原语。这意味着您可以拥有跨进程的互斥代码。这不是您需要的,您可以使用性能成本更低的东西,例如在单个进程中工作的lockor 。Monitor.EnterMonitor.Exit

您不能使用VolatileRead/之类的东西,Write或者MemoryBarrier因为如果数组中的元素不是原子分配的,您需要使写入集合的行为成为原子的。 VolatileRead/WriteMemoryBarrier不这样做。这些只是给你获取和释放语义。这意味着写入的任何内容对其他线程都是“可见的”。如果您不对集合进行原子写入,则可能会损坏数据——获取/释放语义对此无济于事。

例如:

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 在枚举时修改集合(许多集合不支持此操作并抛出异常)。即集合仅在检索单个元素时“受保护”。第二,集合在每次迭代时都受到保护。如果您有 100 个元素并且并发集合使用了一个锁,那么您将获得 100 个锁(假设要查找的元素是最后一个或根本没有找到)。这将比它需要的慢得多。如果您不使用并发集合,您可以简单地使用 lock 来获得相同的结果:

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

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

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

更新 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不起作用,因为它需要引用它将写入的变量,你不能给它引用数组元素。

于 2012-08-14T19:01:31.973 回答
0

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

UPD3 当然有效,但具有危险的误导性

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

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

这有点让我想知道为什么世界还没有走到尽头。

于 2012-08-21T12:38:05.627 回答
0

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

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

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

那是案例一。我们来看案例二。

当设备被未知数量的线程使用时,如何对设备进行彻底的重新配置?如果你直接去做,那么你肯定会有几个线程从配置A开始,在这个过程中遇到配置B,这在很大程度上意味着死亡。这就是您需要诸如读写器锁之类的东西的地方。也就是说,一个同步原语将允许您等待当前活动完成,同时阻止新活动。这就是读写锁的本质。

案例二结束。现在让我们看看你有什么问题,如果我的猜测是对的。

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

但是,如果您从整个代码中创建多个GET,那么您就有麻烦了,因为在同一个处理周期内,一些调用将返回引用 A,而一些调用将返回引用 B。如果您的应用程序没问题,那么您就是一个幸运的家伙。

但是,如果您对此问题有疑问,则必须修复该错误或尝试通过在其之上构建来修补它。修复这个 bug 很简单:消除多个 GET,即使这会花费你一个小的重写,以便你可以传递引用。

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

该解决方案几乎可以工作,但是当代码长时间持有锁时它会很臭,即使它是阅读器锁。

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

我希望我猜得很好,并且我的解释很清楚:-)

于 2012-08-14T19:41:01.493 回答
-1

非常有趣的问题。我希望 Eric 或 Jon 进来澄清事实,但在那之前让我尽力而为。另外,让我先说我不是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在数组本身上添加一个。

如果数组本身是易失的,那么我认为读取线程不可能从数组中缓存一个项目。您似乎不需要使元素变得易变,因为您没有在数组中就地修改元素 - 您正在用新元素彻底替换它们(至少从外观上看!)。

您可能会遇到麻烦的地方是:

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

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

于 2012-08-14T19:15:17.533 回答
-1

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

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

于 2012-08-14T18:35:51.130 回答
-1

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

于 2012-08-14T18:35:57.813 回答