.NET内存模型(翻译)

209 阅读12分钟

本文为.NET Memory Model的翻译

ECMA 335 vs. .NET内存模型

ECMA 335标准定义了一个非常弱的内存模型。二十年后,由于硬件更加严格,拥有一个较为灵活的模型的想法并没有带来更多好处。另一方面,针对ECMA模型的编程需要额外的复杂度来处理那些难以理解且难以测试的场景。

在历次的版本迭代中,.NET运行时的实现围绕着同一个内存模型,这是在保证开发者易于理解的同时,还能在当前硬件上高效实现的实践性的折衷。本文档将详细说明.NET运行时在其当前实现中提供并期望的特性,同时希望它们在将来的版本中依然有效。

对齐

当由.NET运行时托管时,内置基元类型的变量会根据数据类型的大小进行正确对齐。这同时适用于堆上与栈上分配的内存。

基元类型: bool, char, int8, uint8, int16, uint16, int32, uint32, int64, uint64, float32, float64, native int, native unsigned int.

1字节,2字节,4字节的变量分别存储到1字节,2字节,4字节对齐的地址上。 8字节的变量在64位平台上是8字节对齐的。 本机大小的整数类型和指针具有与给定平台上的大小匹配的对齐方式。

当使用FieldOffsetAttribute特性显式指定字段的偏移量时,字段将不保证会被对齐。

原子内存访问

正确对齐且大小不超过平台指针大小的基元类型与枚举类型的数据的内存访问总是原子的。所观察到的值总是完整读写操作的结果。

非托管指针的值被视为native int类型。对非托管指针的正确对齐的值的内存访问是原子的。

托管的引用在给定平台上总是对齐到其大小,且其内存访问是原子的。

当变量的位置由运行时管理时,无论平台如何,以下方法都执行原子内存访问。

  • System.Threading.Interlocked 系列方法
  • System.Threading.Volatile 系列方法

例如: Volatile.Read<double>(ref location) 在32位平台上是原子的, 而一般的对location的读取可能不是。

非托管内存访问

由于非托管指针可以指向任何可寻址内存,因此使用此类指针的操作可能会违反运行时提供的保证,并暴露未定义的或特定于平台的行为。

例如: 通过指针进行的内存访问,如果其目标地址与数据访问大小没有正确对齐,那么就可能不是原子性的,或者会导致错误,这取决于平台和硬件配置。

尽管并不常见,但存在非对齐访问的需求,因此对非对齐内存访问提供有限的支持,例如:

  • unaligned. IL前缀
  • Unsafe.ReadUnalignedUnsafe.WriteUnaligned Unsafe.CopyBlockUnaligned方法

这些设施确保了对潜在的非对齐位置的无错访问,但不能保证原子性。

截至本文撰写之时,对于非一致内存、设备内存或类似设备的操作没有具体的支持。通过指针操作或本机互操作将非普通内存传递给运行时会导致未定义行为。

内存访问的副作用及优化

.NET运行时假设内存读取和写入的副作用只包含观测和修改指定内存位置的值。这适用于所有读写操作,无论其是否volatile。这与ECMA模型不同。

因此:

  • 不允许投机写入。
  • 读取不能被插入。
  • 无用的读取可被省略。(注意:如果读取操作可能引起错误,那它就不是"无用的"。)
  • 对同一位置的相邻的非易失性读取可被合并。
  • 对同一位置的相邻的非易失性写入可被合并。

这些规则的实际动机是:

  • 不允许投机写入,因为我们认为改变数值是可观测的,因此投机写入的影响可能无法撤销。
  • 读取不能重做,因为可能会得到一个不同的值,从而引入程序中不存在的数据竞争。
  • 读取变量但并不观测读取的副作用,就相当于不读取,因此可以删除无用的读取。
  • 合并同一位置的相邻普通内存访问是可行的,因为大多数程序并不依赖于数据竞争的出现,因此,与引入不同,删除数据竞争是可行的。依靠观测数据竞争的程序应使用volatile访问。

Thread-local内存访问

优化编译器有可能证明某些数据只被一个线程访问。在这种情况下,可以进行进一步的优化,如重复或删除内存访问。

对局部变量的跨线程访问

  • 对于一个线程访问另一个线程的堆栈,没有类型安全的机制。
  • 通过不安全的代码访问位于不同线程堆栈上的托管引用将导致未定义行为。

内存操作的顺序

  • 一般内存访问 只要能保持单线程一致性,一般读写的效果可以被重新排序。这类重排可能会由编译器的代码生成策略,或者硬件的弱内存顺序引发。

  • Volatile读取 具有''Acquire''语义,这意味着其后的读写操作都不能被重排到其前面。 具有''Acquire''语义的操作:

    • 当支持volatile.前缀时,带有volatile.前缀的IL读取指令
    • System.Threading.Volatile.Read
    • System.Thread.VolatileRead
    • 获取锁的操作(System.Threading.Monitor.Enter或进入同步方法)
  • Volatile写入 具有''Release''语义,这意味着只有所有在其之前的读写可见之前,volatile写入的结果都不可见。具有''Release''语义的操作:

    • 当支持volatile.前缀时,带有volatile.前缀的IL写入指令
    • System.Threading.Volatile.Write
    • System.Thread.VolatileWrite
    • 释放锁的操作(System.Threading.Monitor.Exit或退出同步方法)
  • volatile. initblk 具有''Release''语义,.volatile initblk的结果不会比之前的读写的结果更早可见。

  • volatile. cpblk 具有''Acquire''语义,结合了Volatile读写的排序语义,涉及读写的内存位置。

    • volatile.cpblk执行的写入不会比其之前的读写更早被观察到。
    • volatile.cpblk执行的读取之后的读写操作不能被投机地重排到其前面。
    • cpblk可以由一系列的读写来实现。这种读写的粒度和相互顺序是没有具体规定的。

请注意,Volatile语义本身并不意味着操作是原子性的,也不对操作需要多长时间被提交到一致性内存产生任何影响。它只规定了最终可观察到的效果的顺序。

volatile.unaligned.IL前缀在两者都允许的情况下可以合并。

优化编译器有可能证明某些数据只会由一个线程访问。在这种情况下,可以在访问这些数据时省略volatile语义。

C#的volatile特性

引入volatile内存访问的常见方式是使用C#的volatile语言特性。将一个字段声明为volatile对.NET运行时如何处理该字段没有任何影响。这种装饰是对C#编译器本身(以及其他.Net语言的编译器)的一种提示,将这种字段的读写生成为带有volatile.前缀的读写。

进程范围的屏障

进程范围屏障具有full-fence语义,并保证程序中的每个线程在任意点上有效地执行full fence,与进程范围屏障同步,这样,在这两个屏障之前的写操作的效果就对屏障之后的内存操作可见。

实际的实现可能会根据平台的不同而不同。例如,中断当前进程的亲和性掩码(affinity mask)中的每个内核的执行就是一个合理的实现。

同步方法

使用MethodImpl(MethodImplOptions.Synchronized)特性标记的方法等同于在方法入口处获取锁,并在方法退出处释放锁。

数据依赖的读取是保序的

内存排序遵循数据的依赖顺序。当从引用中获取的位置进行间接读取时,保证数据的读取不会发生在获得引用之前。这个保证同时适用于托管引用和非托管指针。

例如:读取一个字段,不会使用在获得实例的引用之前从该字段的位置获取的缓存值。

var x = nonlocal.a.b;
var y = nonlocal.a;
var z = y.b;

// 不能有下面的执行顺序:

var x = nonlocal.a.b;
var y = nonlocal.a;
var z = x;

对象的赋值

将对象赋值到一个可能被其他线程访问的位置是对实例的字段/元素和元数据的访问的release。优化编译器必须保持对象赋值和数据依赖的内存访问的顺序。

其目的是为了确保将对象引用存储到共享内存中,作为一个''commit point'',使我们可以通过实例引用到达所有对其的修改。它还保证了当其他线程,包括后台GC线程能够访问该实例时,一个新赋值的实例是有效的(例如,设置好的方法表和必要的标志)。 读取线程不需要在访问实例的内容之前进行获取性读取,因为运行时保证了数据依赖性读取的顺序。

引用赋值的排序的副作用不应该被用于一般的排序目的,因为:

  • 独立的对非volatile引用的赋值可以由编译器重新排序。
  • 优化编译器可以省略''Release''语义,如果它能证明该实例不与其他线程共享。

在对象赋值所提供的保证方面有很多暧昧之处。今后,运行时将只提供本文档中描述的保证。

相信编译器的优化并没有违反关于数据依赖读取对象的赋值部分的排序保证,但需要进一步调查以确保其符合规定并修复潜在的对模型的违背。这是由以下issue跟踪的github.com/dotnet/runt…

实例的构造函数

.NET运行时没有对实例的构造函数指定任何排序的影响。

静态构造器

静态构造函数执行的所有副作用的可见都不晚于对该类型的成员的访问。当调用该类型的其他成员方法时,总能观察到该类型的静态构造函数的完整执行结果。

硬件方面的考虑

目前支持的.NET运行时和系统库的实现对硬件内存模型有一些期望。这些条件在所有支持的平台上都满足,并透明地传递给运行时的用户。未来支持的平台也应支持这些,因为大量已经存在的软件将使我们难以违背共通的假设。

  • 自然对齐的读写,在大小不超过平台指针的大小时是原子的。这甚至适用于重叠的,不同大小的对齐读写。例如:对4字节对齐的int32变量的读取将得到一个在某次写入之前或之后的值,但它绝不会是前/后字节的混合。
  • 内存是缓存一致的,对单一位置的写入将被所有内核以相同的顺序看到(multi-copy atomic)。例如:当一个值以升序值更新时(例如:1,2,3,……),任何核心都不能观察到降序序列。
  • 只要不违反单线程的一致性,一个线程有可能在其他核心之前看到自己的写操作(store buffer forwarding)。
  • 运行时管理的内存是一般内存(不是设备寄存器堆或类似的),内存操作的唯一副作用是存储和读取值。
  • 可以实现Release一致性内存模型。要么平台默认为Release一致性或更强(例如x64就是更强的TSO),要么提供通过屏障操作实现Release一致性的方法。
  • 可以保证数据依赖的读取的顺序。要么平台默认遵循数据依赖性(目前支持的所有平台),要么提供通过屏障操作排序数据依赖性读取的方法。

例子和常见用法

下面的例子在所有支持的.NET运行时的实现上都能正确工作,无论目标操作系统或架构如何。

  • 构建实例并与其他线程共享是安全的,不需要显式的屏障。
static MyClass obj;

// thread #1
void ThreadFunc1()
{
    while (true)
    {
        obj = new MyClass();
    }
}

// thread #2
void ThreadFunc2()
{
    while (true)
    {
        obj = null;
    }
}

// thread #3
void ThreadFunc3()
{
    MyClass localObj = obj;
    if (localObj != null)
    {
        // 访问局部对象的成员是安全的,因为
        // - 读取不能引入,因此localObj不能重新读取并变为null
        // - 赋值给obj不会比MyClass构造函数中的写操作更早可见
        // - 通过实例的间接访问是数据依赖性读取,因此我们将看到构造函数写入的结果
        System.Console.WriteLine(localObj.ToString());
    }
}
  • 单例 (使用锁)
public class Singleton
{
    private static readonly object _lock = new object();
    private static Singleton _inst;

    private Singleton() { }

    public static Singleton GetInstance()
    {
        if (_inst == null)
        {
            lock (_lock)
            {
                // 拿到锁是acquire,_inst的读取将在拿到锁之后发生
                // 释放锁是release,如果另一个线程赋值了_inst,写入的可见将不会晚于锁的释放
                // 因此,如果另一个线程初始化了_inst,当前线程保证在这里看到它。
                if (_inst == null)
                {
                    _inst = new Singleton();
                }
            }
        }

        return _inst;
    }
}
  • 单例 (使用Interlocked操作)
public class Singleton
{
    private static Singleton _inst;

    private Singleton() { }

    public static Singleton GetInstance()
    {
        Singleton localInst = _inst;
        if (localInst == null)
        {
            // 与使用锁的示例不同,我们可能会构造多个实例
            // 只有一个会“成功”并成为唯一的单例对象
            Interlocked.CompareExchange(ref _inst, new Singleton(), null);

            // 由于Interlocked.CompareExchange是完整的屏障,
            // 我们不可能读取null或其他不是单例的虚假实例
            localInst = _inst;
        }

        return localInst;
    }
}
  • 通过检查flag与另一个线程通信
internal class Program
{
    static bool flag;

    static void Main(string[] args)
    {
        Task.Run(() => flag = true);

        // 重复读取将最终看到“flag”的值已经改变,
        // 但是读取必须是Volatile的,以确保所有读取不会合并到循环之前的读取中。
        while (!Volatile.Read(ref flag))
        {
        }

        System.Console.WriteLine("done");
    }
}