【C# 线程】内存模型(C#)---非常重要 【多线程、并发、异步的基础知识】


内存模型概述

MSDN:理论与实践中的 C# 内存模型

MSDN:理论与实践中的 C# 内存模型,第 2 部分

内存模型就是内存一致性模型。

以下内如来自维基百科

内存一致性模型列表

  • 线性一致性(Linearizability)或严格一致性(Strict consistency):任何对一个内存位置X的读操作,将返回最近一次对该内存位置的写操作所写入的值。
  • 原子一致性(Atomic consistency):读操作未能立即读到此前最近一次写操作的结果,但多读几次还是获得了正确结果。所有对数据的修改操作都是原子的,不会产生竞态冲突。
  • 顺序一致性(Sequential consistency ):(并发程序在多处理器上的)任何一次执行结果都相同,就像所有处理器的操作按照某个顺序执行,各个微处理器的操作按照其程序指定的顺序进行。换句话说,所有的处理器以相同的顺序看到所有的修改。读操作未必能及时得到此前其他处理器对同一数据的写更新。但是各处理器读到的该数据的不同值的顺序是一致的。
  • 缓存一致性(Cache Coherence)
  • 静态一致性(Quiescent consistency)
  • 处理器一致性(Processor consistency)/PRAM一致性(PRAM consistency,P指pipeline):在一个处理器上完成的所有写操作,将会被以它实际发生的顺序通知给所有其它的处理器;但是在不同处理器上完成的写操作也许会被其它处理器以不同于实际执行的顺序所看到。这反映了网络中不同节点的延迟可能是不相同的。对于双处理器,处理器一致性与顺序一致性是等价的。
  • 释放一致性(Release consistency ):在对一个共享变量进行普通访问之前,进程在之前所有的获得锁而进行的操作必须成功的完成。在释放一个锁操作之前,进程之前的读和写操作必须已经完成。获得和释放锁的操作必须符合“FIFO一致性”。“释放一致性”仅仅关注被锁住的共享内存内存变量,仅仅只需要将对被锁住的共享变量的修改通知给其它的处理器。C#的VolatileWrite函数即实现了释放一致性语义。
  • 因果一致性(Causal consistency )
  • TSO一致性(Total store ordering)
  • PSO一致性(Partial store ordering)
  • 弱序一致性(Weak-ordering consistency)
  • 最终一致性(Eventual consistency)

C# 内存模型是一组规则,描述允许和不允许的内存操作重新排序类型。 所有程序都应该根据在规范中定义的保证进行编写。

C# 内存模型允许在某一方法中对内存操作进行重新排序,只要单线程执行的行为不发生改变即可。
但是,即使允许编译器(JIT)和处理器(CPU)对内存操作进行重新排序,也不意味着它们在实际情况下会始终这样做。
根据这个抽象 C# 内存模型而包含“错误”的许多程序仍会在运行特定版本 .NET Framework 的特定硬件上正确执行。
值得注意的是,x86 和 x64 处理器仅在某些范围较窄的方案中对操作重新排序;
同样,CLR 实时 (JIT) 编译器不会执行所允许的许多转换。
尽管您在编写新代码时应该对这个抽象的 C# 内存模型已心中有数,但理解这个内存模型在不同体系结构上的实际实现方式是很有用的,特别是在尝试理解现有代码的行为时。

主要针对的对象是编译器(JIT)和处理器(CPU)对内存代码重新排序,原子性访问。

备注:原子性代码:如果一组变量总是在相同的锁内进行读写,就可以称为原子的(atomically)读写。假定字段xy总是在对locker对象的lock内进行读取与赋值;a++就不是原子性代码

内存模型的作用

内存模型旨在实现线程通信。 在一个线程将值写入内存而另一个线程从内存进行读取时,内存模型将会指示读取线程可看到的值。

锁定 锁定通常是在线程之间共享数据的最简单方式。 如果您正确使用了锁,则基本上不必担心任何内存模型方面的麻烦。

强顺序模型(Strong ordered) 和弱顺序模型(Weak ordered )

内存栅栏是一类同步指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。我们看下FPlatformMisc::MemoryBarrier()在不同平台下的实现:

Android和Linux:
FORCEINLINE static void MemoryBarrier()
{
    __sync_synchronize();
}

iOS和Mac:
FORCEINLINE static void MemoryBarrier()
{
    OSMemoryBarrier();
}

Windows:
void FGenericPlatformMisc::MemoryBarrier()
{

}

根据 ECMA-334 的 C# 内存模型

1、内存操作重新排序
2、
3、原子性:在 C# 中,值不一定以原子方式写入内存。支持原子性数据类型有: reference,bool,char,byte,sbyte,short,ushort,unit,int,float。不知此类型有long\double 因为他们是64位的,所有要看芯片类型,如果64位芯片那么久就支持原子性,32位不支持。
4、不可重新排序优化

线程通信模式

主要通过这些模式解决多线程、并行编程和异步编程引发的内存模式所带来的问题。

锁定

通过锁定lock(){。。。} ,防止内存操作重新排序和数据原子性问题。

通过线程 API 发布

通过线程间的API 进行,防止内存操作重新排序。例如:

class Test2 {
  static int s_value;
  static void Run() {
    s_value = 42;
    Task t = Task.Factory.StartNew(() => {
      Console.WriteLine(s_value);
    });
    t.Wait();
  }
}

通过静态类型

通过类型初始化进行发布 将一个值安全地发布到多个线程的另一个方法是将该值写入静态初始值或静态构造函数中的静态字段。 请考虑以下示例:

class Test3
{
  static int s_value = 42;
  static object s_obj = new object();
  static void PrintValue()
  {
    Console.WriteLine(s_value);
    Console.WriteLine(s_obj == null);
  }
}

如果并行从多个线程调用 Test3.PrintValue,每个线程都确保输出“42”和“false”。

通过可变字段发布--Volatile 关键字

Volatile 关键可以限制内存重拍。内容

public class DataInit {
  private int _data = 0;
  private volatile bool _initialized = false;
  void Init() {
    _data = 42;            // Write 1
    _initialized = true;   // Write 2
  }
  void Print() {
    if (_initialized) {          // Read 1
      Console.WriteLine(_data);  // Read 2
    }
    else {
      Console.WriteLine("Not initialized");
    }
  }
}

但在 _initialized 标记为 _data。因此,Print 将永远不会输出“0”,即使使用 Init 对 DataInit 的新实例进行了并发调用。

迟缓初始化

lazy initialize

联锁操作和内存屏障

Interlocked.Increment和Thread.MemoryBarrier

联锁操作是原子操作,在许多情况下可用来减少多线程程序中的锁定。 请考虑下面这个简单的线程安全的计数器类:

class Counter
{
  private int _value = 0;
  private object _lock = new object();
  public int Increment()
  {
    lock (_lock)
    {
      _value++;
      return _value;
    }
  }
}

使用 Interlocked.Increment,您可以按照如下所示重新编写该程序:

class Counter
{
  private int _value = 0;
  public int Increment()
  {
    return Interlocked.Increment(ref _value);
  }
}

使用 Interlocked.Increment 程序编写后,该方法应该更快地执行,至少在某些体系结构上会更快。 除了递增操作之外,Interlocked 类 (bit.ly/RksCMF) 还公开以下不同的原子操作的方法: 添加值、有条件地替换值、替换值和返回原始值等。

所有 Interlocked 方法都具有一个非常有趣的属性: 它们不能与其他内存操作互换顺序。 因此,无论是在联锁操作之前还是之后,没有任何内存操作可以通过联锁操作。

与 Interlocked 方法密切相关的一个操作是 Thread.MemoryBarrier,该操作可被视作虚拟联锁操作。 与 Interlocked 方法一样,Thread.Memory-Barrier 不能与任何之前或之后的内存操作互换顺序。 但与 Interlocked 方法不同的是,Thread.MemoryBarrier 没有负面影响;它只是约束内存重新排序。

中断的轮询循环

 循环读取提升 请考虑下面的轮询循环模式:

class Test
{
  private bool _flag = true;
  public void Run()
  {
    // Set _flag to false on another thread
    new Thread(() => { _flag = false; }).Start();
    // Poll the _flag field until it is set to false
    while (_flag) ;
    // The loop might never terminate!
}
}

在这个示例中,.NET 4.5 JIT 编译器可能按如下所示重写循环:

if (_flag) { while (true); }

对于单线程而言,此项转换完全合法,并且将读取提升出循环通常是一种出色的优化方法。 但如果在另一个线程上将 _flag 设置为 false,则优化可能导致挂起。

请注意,如果 _flag 字段是可变字段,则 JIT 编译器不会将读取提升出循环( 避免轮询循环)。

避免轮询循环通常,您可以使用 BlockingCollection、Monitor.Wait/Pulse、事件或异步编程

并发基元

Task \Task

尽可能使用标准 .NET 并发基元,而不是自己实现等效的功能。

总结

编写新的多线程代码时,通常最好完全避免 C# 内存模型的复杂性,方法是使用锁、并发集合、任务和并行循环等高级并发基元。 编写占用大量 CPU 资源的代码时,有时最好使用可变字段,前提是您只依赖 ECMA C# 规范保证,而非特定于体系结构的实现细节。

内容来源:https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2012/december/csharp-the-csharp-memory-model-in-theory-and-practice

                https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2013/january/csharp-the-csharp-memory-model-in-theory-and-practice-part-2

                https://zhuanlan.zhihu.com/p/43526907

C