代码应该对CPU内存模型做出哪些假设,以及如何记录这些假设?

时间:2012-06-15 15:43:37

标签: .net multithreading memory-barriers memory-model

据我所知,英特尔处理器架构强制执行比.net实现更强大的内存模型。代码在多大程度上利用英特尔处理器所做的保证,或代码在多大程度上增加了英特尔实现所不需要的内存障碍,以防代码迁移到较弱的平台记忆模型?用例如方法定义静态类是否合适? “如果使用弱内存模型,则执行内存屏障”,并要求代码在适当时与该库的“强模型”或“弱模型”版本链接?或者,可以使用Reflection在程序启动时生成这样的静态类,这样JIT编译器在使用强模型时可以“内联扩展”“内存屏障,如果弱”指令为空(即省略)它们完全来自JITted代码)?

如果我有我的druthers,.net将提供MemoryLock类的变体与一些半锁定操作,这将要求所有持有半锁的线程都需要遵守该半锁定锁的记忆模型。在具有非常强大的内存模型的系统中,半锁将无能为力。在具有非常弱的内存模型的系统中,任何希望进入已经有另一个线程的半锁的线程都必须等到第一个线程退出,或者它可以通过CPU或核心进行调度(基于在半锁定指定的模型上,第一个线程正在使用。请注意,与普通锁定不同,MemoryLock永远不会死锁,因为可以通过调度所有线程在同一CPU上运行来解决冲突锁定要求的任何组合,并且系统可以释放由{0}持有的任何MemoryLock一个死的线程(因为MemoryLock的目的是保护资源不被违反内存模型的方式访问,而死线程当然不能进行这样的访问)。

当然,.net 4.0中不存在这样的事情;鉴于此,处理确实存在的情况的最佳方法是什么?将设计用于更强内存模型的代码迁移到具有较弱模型的系统,在没有强制执行强模型的方法的情况下,将会导致灾难,但会添加大量Lock或{{1对代码的原始目标平台不必要的调用似乎不太吸引人。我知道代码强制强大的内存模型的唯一方法是让每个线程设置其CPU亲和性。如果有一种设置进程选项的方法,那么.net一次只能使用一个核心,这可能是有用的(特别是如果它意味着JIT可以用更快的非总线锁定等效替换总线锁定互锁操作) ,但我知道设置CPU亲和力的唯一方法是限制程序为其所有线程使用特定的选定CPU,即使该CPU被其他应用程序大量加载而其他一些CPU处于空闲状态。

附录

请考虑以下代码:

// Thread 1 -- Assume that at start SharedPerson points to a Person "Smiley", "George"
  var newPerson = new Person();
  newPerson.LastName = "Simpson";
  newPerson.FirstName = "Bart";
  // MaybeMemoryBarrier1
  SharedPerson = newPerson;

// Thread 2
  var wasPerson = SharedPerson;
  // MaybeMemoryBarrier2
  var wasLastName = wasPerson.FirstName;
  var WasFirstName = wasPerson.LastName;

根据我的理解,即使没有内存屏障,在Intel处理器上运行的代码也可以保证写入不会重新排序;因此,在线程2中,被阅读的人将是“笑脸”,“乔治”或“辛普森”,“巴特”。然而,.net内存模型比这弱,并且.net程序可能会发现自己在处理器上运行,其中线程2可能会看到一个不完整的对象(因为写入MemoryBarrier可能在写入{之前} {1}})。在SharedPerson添加内存屏障可以避免这种危险,但无论是否真正需要,内存屏障都会带来性能损失。

我认为,在保证线程2永远不会访问newPerson.FirstName之前引用的对象的情况下,最小所需的.net内存模型是如此之弱以至于需要MaybeMemoryBarrier1阅读MaybeMemoryBarrier2本身(就像上面代码中的情况一样,因为新实例在存储到SharedPerson之前不会暴露给任何外部代码)。另一方面,假设情况略有改变,因此SharedPerson创建了一个SharedPerson记录,然后将其放入Thread 2的队列中(假设所有必要的锁和内存障碍)排队本身);在此之后,处理器执行:

// Thread 1
  var newJob = JobQueue.GetJob(); // Gets JobInfo that was written by Thread2
  newJob.StartTime = DateTime.Now(); // Eight-byte struct might straddle cache line
                                     // Will never be changed once written
  // MaybeMemoryBarrier1
  CurrentJob = newJob;

// Thread 2
  var wasJob = CurrentJob;
  // MaybeMemoryBarrier2
  var wasStartTime = CurrentJob.StartTime();

如果线程1有内存屏障,但线程2没有,则可以保证当线程2看到它创建的JobInfo记录显示在Thread 1时,它将正确读取其JobInfo 1}}字段(并且不会看到从CurrentJob操作该对象时遗留的缓存或部分缓存的值?

2 个答案:

答案 0 :(得分:1)

TL; DR:你应该只针对.net内存模型编写代码;没有更强的。

确实,x86架构的内存模型比.net。

所描述的更强

但即使您从未打算将代码移植到其他平台(例如ARM),也不应该考虑x86内存模型。因为您的编译器和JITer可以自由地执行破坏x86模型的优化。因此即使在Intel CPU上也不安全。

例如,JIT可以决定在您的示例中完全避免使用newPerson局部变量,这将等同于此代码:

SharedPerson = new Person();
SharedPerson.LastName = "Simpson";
SharedPerson.FirstName = "Bart";

你看到这有多破吗?即使使用先前初始化的SharedPerson,线程2也可以看到FirstName和LastName == null(如果它在设置之前读取)!这种优化是完全合法的,不会改变单线程行为。

如果没有正确的同步,硬件和运行时可以随意引入/消除/重新排序内存写入和读取,只要单线程行为不会改变。

要以原子方式发布对其他线程的引用,您应该使用volatile写入。如果SharedPerson是易失性的,那么代码就可以了(不需要额外的显式内存屏障)。请注意,在x86上,易失性写入只是常规写入,因此它“免费”:运行时不添加任何指令。但是它确实禁止.net运行时的优化(上面的例子变得非法,因为在易失性写入之后没有先前的内存操作可以移动。所以.LastName和.FirstName 必须在易失性写入发生之前分配

答案 1 :(得分:0)

我不相信你的理解是正确的。 .NET内存模型似乎确实允许对存储进行重新排序,也就是说,在具有极弱内存模型的某些不存在的CPU上,在存储FirstNameLastName成员之前,可以通过thread1存储SharedPerson,导致“Bart”/ null或null /“Simpson”,甚至null / null。但是我不相信弱内存模型可能会在您的示例中导致不一致写(“George”/“Simpson”),因为thread2创建了对{{1的本地引用并且从中读取,而thread1正在使用新实例执行SharedPerson的原子替换。

CLI spec州:

  

符合标准的CLI应保证对其的读写访问权限   正确对齐的内存位置不大于本机字大小   (native int类型的大小)是原子的(参见§12.6.2)   写入对位置的访问大小相同

据说,据我所知,在任何支持的平台上都不存在这样的内存模型,而Chris Brumme的博客here建议类似。