Memory Reordering/Memory Model 及其对.NET的影响

题记

      关于内存模型, 这实在是个被说烂了的话题. 五六年前刚刚接触到.NET的时候, 各路大牛就开始讨论了. 还记得那时候每每带着无比崇敬的心去阅读那些文字和思想. 之后每每回头去重读那些文字,更感觉收获颇多. 可是大牛们往往言语颇为简概, 所以尽管读的次数多, 但是多数成为时间和差记忆力的受害者屡屡忘记. 所以最近下定决心, 写这样一篇博文, 汇总各路豪杰之思想, 聚集近几年之结论, 加上笔者一点微不足道的收获, 务求准确翔实, 希望图文并茂. 程序员帮程序员, 大家互助.是为题记.

-Jeffrey Sun

引出 – Singleton & Volatile

      不知道作为程序员的您想过没有, 如果CPU不是按照您的程序写那样的顺序(Programming Order)执行, 结果会是怎样的? 如果您以前没有意识到这一点, 您可能会比较震惊. 但在进入多核心多处理器时代之后,事实上就是这样的, CPU会调整指令执行的顺序, 并以调整后的顺序(Processer Order)来执行指令. 这是提高CPU执行效率的重要措施. 

      除了极少数人比如.NET框架的设计者们在很早的时间就遇到了这个问题, 撇开C++/JAVA程序员们不谈, 我猜测很多.NET程序员首次意识到这个问题, 应该是当Double-Check遇到了单例模型的实现:

Imperfect Singleton Implement
  1.  public class Singleton
  2. {
  3.      private static object syncRoot = new object();
  4.      private Singleton instance;
  5.  
  6.      private Singleton() { }
  7.  
  8.      public Singleton Instance
  9.     {
  10.          get
  11.         {
  12.              if (instance == null)
  13.             {
  14.                  lock (syncRoot)
  15.                 {
  16.                      if (instance == null)
  17.                     {
  18.                          instance = new Singleton();
  19.                     }
  20.                 }
  21.             }
  22.  
  23.              return instance;
  24.         }
  25.     }
  26. }

 

      这个实现有一点点瑕疵. 在多核心CPU上, 存在这样一种可能. 两个CPU核心同时执行Instance这段代码. 当Processer0执行到释放锁的时候, 由于CPU高速缓存(Cache)和写缓存(Store Buffer)的存在, 主内存中可能Singleton对象还没有被创建. 当Processer1执行到instance==null的判断时依然为真, 然后另外一个Singleton对象也被表明需要创建. 这样当所有写操作完成后, 这个单例模型实际上给出了两个完全不相干的Singleton对象!

A Better Singleton
  1.  public class Singleton
  2. {
  3.      private static object syncRoot = new object();
  4.      private volatile Singleton instance;
  5.  
  6.      private Singleton() { }
  7.  
  8.      public Singleton Instance
  9.     {
  10.          get
  11.         {
  12.              if (instance == null)
  13.             {
  14.                  lock (syncRoot)
  15.                 {
  16.                      if (instance == null)
  17.                     {
  18.                          instance = new Singleton();
  19.                     }
  20.                 }
  21.             }
  22.  
  23.              return instance;
  24.         }
  25.     }
  26. }

 

      一个改进后的实现, 只是在单例内部维护的Singleton私有实例前面加了"volatile"关键字. Volatile关键字有什么奇妙的作用呢? MSDN给出这样的解释:

"The volatile keyword indicates that a field might be modified by multiple threads that are executing at the same time. Fields that are declared volatile are not subject to compiler optimizations that assume access by a single thread. This ensures that the most up-to-date value is present in the field at all times."

          Volatile的这段解释最主要的意思是: 标有Volatile的字段将编译器认为是多线程代码, 从而不会执行优化; 这保证内存中字段的值总是最新的.

          那么, 真的是这样么? 是不是使用了Volatile关键字之后, 单例模型就完全没有问题了? 有比Volatile更好的实现么? 探究问题的根本, 要从现代CPU的结构上谈起.

     

    名词解释

    旧/前 - 对编程顺序而言, 较前的语句/操作

    新/后 - 对编程顺序而言, 较后的语句/操作

    Load/Read - CPU读操作, 是指将内存数据加载到寄存器的操作过程.

    Store/Write - CPU写操作, 是指根据CPU指令, 将修改过的数据回写主存储器的操作过程.

    Load.Acquire - 含有Acquire语义的读操作. 相当于一个单向向后的栅障. 普通的读和写操作可以向后越过该读操作, 但是之后的读和写操作不能向前越过该读操作.

    Store.Release - 含有Release语义的写操作. 相当于一个单向向前的栅障. 普通的读和写可以向前越过该写操作, 但是之前的读和写操作不能向后越过该写操作.

    Full Fence - 全向栅障. 任何读写操作都不能跨越该栅障.

    Cache - CPU封装的高速缓存. 在现代CPU当中, 一般会设置多级缓存(比如从L1到L3等), 多级缓存有不同的访问速度. Cache按照封装分有两种, 一种是与每个CPU核心封装在一起并被其单独占有的, 另一种是几个CPU核心共享的.  在不影响本文分析的基础上, 笔者使用Cache一词统称CPU当中每个逻辑CPU核心独享的缓存. 另因Store Combine Buffer亦不影响本文分析, 保留了Store Buffer之后, 同样省去Store Combine Buffer.

    Cache Line -  对应于内存中不同的数据边界大小,Cache根据不同的固定尺寸分成一些大小(Boundary)不等的存储空间, 这些存储空间叫做Cache Line.

     

    现代CPU内存操作基本逻辑结构

     

          CPU发展到今天存在3中基本的架构: x86, AMD64(x64), IA-64. 我们知道CPU中都会有计算单元, 寄存器, 指令控制器, 多级缓存, 写缓冲等复杂各种功能部件, 但是这跟本文的内存模型无关, 本文也不打算介绍这些实现的细节. 为了简化讨论, 我们省去多级缓存的结构来用缓存统称缓存结构(多级缓存机制不会影响内存模型的讨论), 省去计算单元/寄存器/指令控制器来用一个单独的PU单元来代替, 省去CPU中的其他部分但是保存了Store Buffer. 这样这3类架构的CPU的基本内存操作工作逻辑结构, 都可以标示为如图. 图中左侧是CPU的两个核心. 每个核心单独持有独立的缓存和写缓冲区.额外提一句, MSDN Magazine上Vance Morrison老师的Store Buffer的画法值得商榷.

     CPU

 

 

      那么在这个模型下, CPU是如何完成读和写的呢? 在不考虑Memory Reordering的情况下, 一般的过程是:

      读取: 首先在当前的Cache中查找, 如果Cache命中(Cache中含有对应地址的缓存项), 那么直接从缓存中返回该内容; 如果cache未命中(cache中没有对应地址的缓存项), 那么向总线发出读请求, 获得总线令牌后, 读取主存储中对应地址及其周围的内容, 添加到缓存中, 并从缓存返回对应地址的内容.

      写入: 首先写操作压入Store Buffer队列, 然后根据指令控制器中指令的Process Order检查后续的读操作.如果后续操作中有对同一地址的读操作, 那么更新对应cache中的项. 最后在某个合适的时候, 向总线发出写请求, 获得总线令牌后, 将Store Buffer中缓存的写操作的结果写入主存储器.

 

Memory Reordering

 

      严谨的讲, 这个标题更应该是Memory Access Reordering, 即指对CPU而言, 内存访问指令重新排序. 这对CPU效能来说是个关键的软件因素. 在核心频率外频位宽等这些硬件条件一定时, 内存的访问方式直接决定了CPU的整体效能. 因为CPU访问高速缓存的速度大概是访问主存的100倍, 所以更好的内存访问方式, 更高的高速缓存命中率, 会大大的提高CPU的性能. 统计数据证明, 访问高速缓存失败(从而不得不访问主存)的代价, 大概是访问主存失败(从而不得不访问外存例如硬盘)的10倍.

      Memory Reordering定位于为优化CPU及总线效能而进行的内存访问指令再排序. 它一定会有一个度 - 称为Memory Model - 来平衡两个极端:

      A. 内存访问指令严格按照编程顺序执行, 即不排序. CPU不能从Memory Ordering获得任何好处. 但是程序会比较容易编写, 因为程序本身定义了内存访问的顺序.

      B. 内存访问指令自由重新排序. CPU自由按照最大的效能原则重新排序内存访问顺序, CPU和总线效能得到最大发挥. 但是你根本无法为这样的CPU编写程序, 因为CPU不保证任何事情. 比如, 你写了这样一个程序, i = 1; i ++; 得到的i可能是0, 可能是1, 也可能是2.

      辩证的说, 正确性是第一位的需求, 效能是第二位的需求. Memory Model的具体实现在两者之间摇摆, 偏近极端A的实现, 我们称为强模型(strong model); 向极端B的方向靠拢(相对于前一种实现)的实现, 我们称其为弱模型(weak model).  从CPU架构的3大类来讲, x86架构和AMD64架构上的内存访问模型, 都是强内存模型. IA-64架构上的内存访问模型, 是弱内存模型.

AMD64内存模型 - 规规矩矩的实现

      因为X86架构的内存模型原则和实现同AMD64内存模型的原则和实现极为相近, 剥离AMD64文档上无碍我们讨论的新特性之后, 可以只讨论AMD64的内存模型, 并以此来代表x86方面的讨论.

AMD64内存模型多核心实现的原则是:

读操作不能被重排序到更旧的读操作之前 Loads do not pass previous loads (loads are not re-ordered).

写操作不能被重新排序到更旧的写操作之前 Stores do not pass previous stores(stores are not re-ordered)

写操作不能被重新排序到读操作之前 Stores do not pass loads

写操作可以看成是按照编程顺序提交主存更改的, 但是由于写缓冲的存在, 这个提交过程可以滞后.Stores from a processor appear to be committed to the memory system in program order; however,stores can be delayed arbitrarily by store buffering while the processor continues operation.

读操作可以重新排序到对不同主存地址的写操作之前 Non-overlapping Loads may pass stores.

写操作可以被外部逻辑CPU以不同顺序观察到. Stores to different locations in memory observed from two (or more) processors may be interleaved in different ways for different observers.

 

MemoryCoherency      这个内存模型是比较严格的. 而且对于Store Buffer的问题, AMD文档给出了Memory Coherency and Protocol的保证, 即一个逻辑CPU核心内的Store Buffer中排队的写操作, 是可以被动地被其他CPU观察到, 或者主动向其他CPU核心广播该缓存的写操作对应的主存位置的缓存项无效. 有关这一章节, 可以具体参看AMD64规范的7.3章节. 缓存一致性的几个状态转换的示意图如右:

      所以综合这些原则以及AMD64对于Store Buffering的处理, AMD64的内存模型基本表现为可以认为是按照编程顺序执行的. 除了对于程序中多线程共享数据需要加上必要的同步机制之外, .NET程序员们应该不需要细致的考虑到如此底层的实现细节上.

 

 

IA-64内存模型 - 要命的Store Buffer

      Intel的规格文档 <Intel® 64 and IA-32 Architectures Software Developer’s Manual - Volume 3A:System Programming Guide, Part 1>的第八章第2节, 对于IA-64的内存模型, 以及内存访问重新排序的原则和实现有比较详细的介绍. 限于篇幅只摘抄并翻译单处理器内存访问排序的原则:

• 读操作之间不能重新排序 - Reads are not reordered with other reads.
• 写操作不能跟旧的读操作排序 - Writes are not reordered with older reads.
• 主存写操作不能跟其他的写操作排序 - Writes to memory are not reordered with other writes.
• 不同内存地址的读可以与较早的写排序, 同一地址的情况除外. - Reads may be reordered with older writes to different locations but not with older writes to the same location.
• 对I/O指令, 锁指令, 序列化指令读写不能重排序. - Reads or writes cannot be reordered with I/O instructions, locked instructions, or serializing instructions.
• 读不能越过较早的读栅障或者全栅障 - Reads cannot pass earlier LFENCE and MFENCE instructions.
• 邪不能越过较早的读栅障,写栅障和全栅障 - Writes cannot pass earlier LFENCE, SFENCE, and MFENCE instructions.
• 读栅障指令不能越过较早的读 - LFENCE instructions cannot pass earlier reads.
• 写栅障指令不能越过较早的写 - SFENCE instructions cannot pass earlier writes.
• 全栅障子陵不能越过较早的读和写 - MFENCE instructions cannot pass earlier reads or writes.

      在多处理器的情况下, 单处理器内部的内存访问排序仍然依照以上的原则, 并且规定处理器与处理器之间遵循如下的原则:

• 某个处理器的全部写操作以同样的顺序被其它处理器观察到. - Writes by a single processor are observed in the same order by all processors.
• 不同处理器之间的写操作不重排序 - Writes from an individual processor are NOT ordered with respect to the writes from other processors.
• 排序遵循逻辑上的因果关系 - Memory ordering obeys causality (memory ordering respects transitive visibility).
• 第三方总是观察到一致的写操作顺序. - Any two stores are seen in a consistent order by processors other than those performing the stores
• 锁指令存在一个总的顺序 - Locked instructions have a total order.

      单单从这些原则看来, IA-64采用的是一个较弱的内存模型. 但这不是问题的关键, 问题的关键在于维护内存一致性的机制上. IA-64出于性能的考虑, 内存一致性机制对内存访问排序的干预比较少. 这是IA-64弱内存模型的关键. 一个相当明显的例子表现在对写缓存的同步处理上. IA-64内存访问模型有这样一个规定; “Intra-Processor Forwarding Is Allowed”,

Capture32

      大致的意思是, 任何一个写缓存内缓存的写操作, 在最终提交之前, 仅仅能被自己所属的处理器观察到. 这意味着, 在最终提交之前, 其它处理器根本无法预测和判断该处理器的写操作. 如果一系列的写操作是为了创建一个内存对象, 而这些写操作都被缓存了, 那么在其他的处理器看来, 该内存对象根本没有被创建!

 

.NET单例模型实现的改进

 

      考虑了写缓存, 那么本文开题引入的使用Volatile关键字的.NET单例实现, 其实是有问题的. Volatile关键字只保证对于被标记该关键字的字段, 将不被优化至缓存到高速缓存中.在IA-64架构下这仅仅保证了高速缓存和主存的内存一致性, 而没有考虑到写缓存对于多处理器架构下对象创建过程的影响. 这是其一.

      其次, 被标记了Volatile关键字的字段, 自始至终不被缓存到高速缓存中. 相对于我们的需求来说这个代价太高了.我们回头想一下单例模型的需求, 它要求内存对象的唯一性. 如果使用Volatile, 那么其实只在创建对象(使用new操作符)的时候, Volatile的特性才是我们想用的. 一旦这个对象创建以后, Volatile的特性反而成了性能的累赘.

      根据对AMD64和IA-64架构内存访问规范的分析, 使用全栅障(MFENCE), 对以上两点来说, 可以起到有效地改进作用.

1. 使用全栅障, 将保证该栅障前后不能重新排序; 并使所有的写操作写回主存.

2. 使用全栅障, 将避免字段一直不能被高速缓存带来的代价, 仅仅在创建单例对象的时候需要额外的CPU和总线时间.

Improvement
  1.  public class Singleton
  2. {
  3.      private static object syncRoot = new object();
  4.      private Singleton instance;
  5.  
  6.      private Singleton() { }
  7.  
  8.      public Singleton Instance
  9.     {
  10.          get
  11.         {
  12.              if (instance == null)
  13.             {
  14.                  lock (syncRoot)
  15.                 {
  16.                      if (instance == null)
  17.                     {
  18.                          Singleton singleObj = new Singleton();
  19.                          System.Threading.Thread.MemoryBarrier();
  20.                          instance = singleObj;
  21.                     }
  22.                 }
  23.             }
  24.  
  25.              return instance;
  26.         }
  27.     }
  28. }

 

本文没有谈到什么

为了不将本文变成又臭又长的裹脚布, 本文将关注点放在CPU内存访问模型方面, 而没有涉及到:

1. ECMA CLI对于.NET弱内存模型的规定

2. .NET 2.0以来实现的强内存模型

3. InterLocked类一系列操作及其重要作用

参考文档

1. Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3A: System Programming Guide, Part 1

2. AMD64 Architecture Programmer’s Manual Volume 2: System Programming

3. Memory Models : Understand the Impact of Low-Lock Techniques in Multithreaded Apps

4. Memory Model – Cbrumme

5. "Loads are not reorderd with other loads" is a FACT!! 续:不要指望 volatile - 园子里的同学写的

 

尾记

      写这篇博文花了不少时间. 实在是因为想保证严谨性, 所以小心翼翼读了AMD64和IA-64技术规格文档有关多处理器和内存系统的章节.期间偶尔看到了"0BUG门"的事情, 笔者希望有问题能多一些讨论, 少一些攻击, 所谓理不辩不明, 尊重事实. 本文限于作者拙劣的写作水平, 心有惴惴焉, 希望各位看到废话能多包涵并不吝指正.

致谢!

posted @ 2010-02-03 22:47  Jeffrey Sun  阅读(4863)  评论(26编辑  收藏  举报