2

根据我的阅读,英特尔处理器架构强制执行比 .net 实现所需提供的更强大的内存模型。代码在多大程度上利用英特尔处理器做出的保证是合适的,或者代码应该在多大程度上添加英特尔实现不需要的内存屏障,以防代码迁移到一个较弱的平台记忆模型?定义一个带有方法的静态类是否合适,例如“如果使用弱内存模型,则执行内存屏障”,并要求代码酌情与该库的“强模型”或“弱模型”版本链接?或者,是否可以在程序启动时使用反射来生成这样一个静态类,这样 JIT 编译器可以在使用强模型时,“

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

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

附录

考虑以下代码:

// 线程 1 -- 假设在开始时 SharedPerson 指向一个人 "Smiley", "George"
  var newPerson = new Person();
  newPerson.LastName = "辛普森";
  newPerson.FirstName = "巴特";
  // 也许MemoryBarrier1
  SharedPerson = newPerson;

// 线程 2
  var wasPerson = SharedPerson;
  // 也许MemoryBarrier2
  var wasLastName = wasPerson.FirstName;
  var WasFirstName = wasPerson.LastName;

据我了解,即使没有内存屏障,在英特尔处理器上运行的代码也能保证写入不会被重新排序;因此,在线程 2 中,被读取的人要么是“Smiley”、“George”,要么是“Simpson”、“Bart”。然而,.net 内存模型比这更弱,.net 程序可能会发现自己运行在线程 2 可能会看到不完整对象的处理器上(因为写入SharedPerson可能发生在写入之前newPerson.FirstName)。在 at 添加内存屏障MaybeMemoryBarrier1可以避免这种危险,但无论是否实际需要内存屏障,都会有性能成本。

我认为最低要求的 .net 内存模型不会那么弱,以至于在保证线程 2在读取自身之前MaybeMemoryBarrier2永远不会访问所引用的对象的情况下需要(如上面代码中的情况,因为新实例在存储到 ) 之前不会暴露给任何外部代码。另一方面,假设情况略有变化,因此创建了一条记录,然后将其放入队列中(假设队列本身所有必要的锁和内存屏障);之后,处理器会:SharedPersonSharedPersonSharedPersonThread 2JobInfoThread 1

// 线程 1
  var newJob = JobQueue.GetJob(); // 获取 Thread2 写入的 JobInfo
  newJob.StartTime = DateTime.Now(); // 八字节结构可能跨越缓存行
                                     // 一旦写入就永远不会改变
  // 也许MemoryBarrier1
  当前工作 = 新工作;

// 线程 2
  var wasJob = 当前工作;
  // 也许MemoryBarrier2
  var wasStartTime = CurrentJob.StartTime();

如果线程 1 有内存屏障,但线程 2 没有,是否可以保证当线程 2 看到JobInfo它创建的记录出现在 中时CurrentJob,它会正确读取其StartTime字段(并且不会看到缓存或部分缓存的值从Thread 2操纵那个物体的时候开始?

4

2 回答 2

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 write。如果 SharedPerson 是易失的,那么您的代码就可以了(不需要额外的显式内存屏障)。请注意,在 x86 上,易失性写入只是常规写入,因此它是“免费的”:运行时不会添加任何指令。但它确实禁止了 .net 运行时的优化(上面的示例变得非法,因为在 volatile 写入之后没有先前的内存操作可以移动。所以 .LastName 和 .FirstName必须在 volatile 写入发生之前分配)。

于 2013-06-01T19:27:19.243 回答
0

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

CLI 规范指出:

当对一个位置的所有写访问都相同时,符合 CLI 的 CLI 应保证对不大于本机字大小(本机 int 类型的大小)的正确对齐的内存位置的读写访问是原子的(参见 §12.6.2)尺寸

也就是说,据我所知,在任何受支持的平台上都不存在这样的内存模型,Chris Brumme 的博客在这里提出了类似的建议。

于 2012-06-15T17:04:23.327 回答