C# volatile 变量:内存栅栏 VS。缓存

C# volatile variable: Memory fences VS. caching

所以我研究这个话题已经有一段时间了,我想我理解了最重要的概念,比如 释放和获取内存栅栏

但是,对于volatile和主存缓存的关系,我还没有找到满意的解释

所以,我知道每个读取和写入 to/from 一个 volatile 字段都强制执行严格的读取顺序以及在它之前和之后的写入操作(读-获取和写-发布)。但这只能保证操作的顺序。它没有说明 这些更改对其他 threads/processors 可见的时间。特别是,这取决于刷新缓存的时间(如果有的话)。我记得读过 Eric Lippert 的评论,他说了一些类似 "the presence of volatile fields automatically disables cache optimizations" 的内容。但我不确定这到底是什么意思。这是否意味着整个程序的缓存被完全禁用只是因为我们在某处有一个 volatile 字段?如果不是,缓存被禁用的粒度是多少?

此外,我阅读了一些关于强和弱易失性语义的内容,并且 C# 遵循强语义,无论它是 volatile 字段与否。我对这一切感到很困惑。

是的,volatile 是关于围栏的,而围栏是关于排序的。 所以 when? 不在范围内,实际上是所有层(编译器、JIT、CPU 等)的实现细节。 )结合起来, 但是每个实现都应该对这个问题有体面和实用的答案。

I read the specs, and they say nothing about whether or not a volatile write will EVER be observed by another thread (volatile read or not). Is that correct or not?

让我改一下问题:

Is it correct that the specification says nothing on this matter?

没有。规范在这件事上写的很清楚。

Is a volatile write guaranteed to be observed on another thread?

是,如果另一个线程有关键执行点特殊副作用 保证被观察到关于关键执行点

易失性写入是一种特殊的副作用,许多事情都是关键的执行点,包括启动和停止线程。请参阅规范以获取此类列表。

假设例如线程 Alpha 将 volatile int 字段 v 设置为 1 并启动线程 Bravo,它读取 v,然后加入 Bravo。 (也就是说,阻止 Bravo 完成。)

此时我们有一个特殊的副作用——写入——一个关键执行点——线程启动——以及第二个特殊的副作用——易失性读取。因此 Bravo 需要v 读一个。 (假设同时没有其他线程写入它。)

Bravo 现在将 v 递增到 2 并结束。那是一个特殊的副作用——一个写——和一个关键的执行点——一个线程的结束。

当线程 Alpha 现在恢复并执行 v 的易失性读取时,需要 它读取两个。 (假设同时没有其他线程写入它。)

必须保留 Bravo 写入和 Bravo 终止的副作用的顺序;显然 Alpha 不会 运行 直到 Bravo 终止后,因此需要观察写入。

我先解决最后一个问题。 Microsoft 的 .NET 实现在 writes1 上具有发布语义。它本身不是 C#,因此无论使用何种语言,相同的程序在不同的实现中都可能具有弱的非易失性写入。

副作用的可见性与多线程有关。忘记 CPU、核心和缓存。相反,想象一下,每个线程都有堆上内容的快照,需要某种同步来传达线程之间的副作用。

那么,C# 说了什么? C# language specification (newer draft) says fundamentally the same as the Common Language Infrastructure standard (CLI; ECMA-335 and ISO/IEC 23271) 有一些区别。以后再说。

那么,CLI 是怎么说的?只有不稳定的操作是可见的副作用。

请注意,它还说堆上的非易失性操作也是副作用,但不能保证可见。同样重要的是2,它并没有声明它们保证可见。

volatile 操作到底发生了什么?易失性读取具有获取语义,它先于任何后续内存引用。易失性写入具有释放语义,它遵循任何先前的内存引用。

获取锁执行易失性读取,释放锁执行易失性写入。

Interlocked 操作具有获取和释放语义。

还有一个重要的术语需要学习,那就是原子性

读取和写入,无论是否为易失性,都保证在 32 位架构上最多 32 位的原始值和 64 位架构上最多 64 位的原始值上是原子的。它们也保证对于引用是原子的。对于其他类型,例如 long structs,操作不是原子的,它们可能需要多次独立的内存访问。

然而,即使使用可变语义,读取-修改-写入操作,例如 v += 1 或等效的 ++v(或 v++,就副作用而言),不是原子的。

互锁操作保证某些操作的原子性,通常是加法、减法和比较和交换 (CAS),即当且仅当当前值仍然是某个预期值时才写入某个值。 .NET 还有一个用于 64 位整数的原子 Read(ref long) 方法,即使在 32 位体系结构中也可以使用。

我将继续将获取语义称为易失性读取,将释放语义称为易失性写入,以及其中一个或两者都称为易失性操作。

顺序而言,这一切意味着什么?

无论是在语言级别还是在机器级别,易失性读取都是一个点,在该点之前没有内存引用可以交叉,而易失性写入是一个点之后没有内存引用可以交叉。

如果中间没有易失性写入,非易失性操作可能会跨越到易失性读取之后,如果中间没有易失性读取,则可能会跨越到前面的易失性写入之前。

线程中的易失性操作是顺序的,不能重新排序。

一个线程中的易失性操作以相同的顺序对所有其他线程可见。但是,不存在来自所有线程的易失性操作的总顺序,即如果一个线程执行 V1 然后执行 V2,而另一个线程执行 V3 然后执行 V4,那么任何顺序都可以观察到 V1 在 V2 之前和 V3 在 V4 之前线。在这种情况下,它可以是以下之一:

  • V1 V2 V3 V4

  • V1 V3 V2 V4

  • V1 V3 V4 V2

  • V3 V1 V2 V4

  • V3 V1 V4 V2

  • V3 V4 V1 V2

也就是说,观察到的副作用的任何可能顺序对于单次执行的任何线程都是有效的。对总排序没有要求,这样所有线程只观察单次执行的可能顺序之一。

事物如何同步?

从本质上讲,它可以归结为:同步点是在易失性写入之后发生易失性读取的位置。

实际上,如果一个线程中的易失性读取发生在另一线程中的易失性写入之后,您必须检测3。这是一个基本示例:

public class InefficientEvent
{
    private volatile bool signalled = false;

    public Signal()
    {
        signalled = true;
    }

    public InefficientWait()
    {
        while (!signalled)
        {
        }
    }
}

但是通常效率不高,您可以 运行 两个不同的线程,例如一个调用 InefficientWait() 而另一个调用 Signal(),而后者的副作用是 [来自 Signal() 的 =326=] 当来自 InefficientWait().

的 returns 时对前者可见

易失性访问通常不如互锁访问有用,互锁访问通常不如同步原语有用。我的建议是,您应该首先安全地开发代码,根据需要使用同步原语(锁、信号量、互斥量、事件等),如果您根据实际数据(例如分析)找到提高性能的理由,然后并且仅在那时看看你能不能改进。

如果您曾经达到高 contention 快速锁定(仅用于少数读写而不阻塞),根据争用量,切换到互锁操作可能要么提高或降低性能。尤其是当您不得不求助于比较和交换循环时,例如:

var currentValue = Volatile.Read(ref field);
var newValue = GetNewValue(currentValue);
var oldValue = currentValue;
var spinWait = new SpinWait();
while ((currentValue = Interlocked.CompareExchange(ref field, newValue, oldValue)) != oldValue)
{
    spinWait.SpinOnce();
    newValue = GetNewValue(currentValue);
    oldValue = currentValue;
}

意思是,您还必须分析解决方案并与当前状态进行比较。并注意 A-B-A problem.

还有 SpinLock,您必须真正针对基于监视器的锁进行分析,因为尽管它们可能会使当前线程产生收益,但它们不会使当前线程进入休眠状态,类似于显示的用法SpinWait.

切换到 volatile 操作就像在玩火。你必须通过分析证明你的代码是正确的,否则你可能会在最不经意的时候被烧毁。

通常,在高争用情况下进行优化的最佳方法是避免争用。例如,要并行地对一个大列表执行转换,最好将问题划分并委托给多个工作项,这些工作项生成的结果在最后一步合并,而不是让多个线程锁定列表以进行更新。这有内存成本,所以它取决于数据集的长度。


关于可变操作的 C# 规范和 CLI 规范之间有什么区别?

C# 将副作用指定为读取或写入易失性字段、写入非易失性变量、写入外部资源以及抛出它们的线程间可见性一个例外。

C# 指定在线程之间保留这些副作用的关键执行点:对可变字段的引用、lock 语句以及线程创建和终止。

如果我们将关键执行点作为副作用变得可见的点,它会在 CLI 规范中添加线程创建和终止是可见 副作用,即 new Thread(...).Start() 在当前线程上有释放语义,在新线程开始时获取语义,退出线程在当前线程上有释放语义, thread.Join() 有获取语义等待线程上的语义。

C# 没有提到一般的易失性操作,例如 类 在 System.Threading 中执行,而不是仅通过使用声明为 volatile 的字段并使用 lock 陈述。我相信这不是故意的。

C# 声明捕获的变量可以同时暴露给多个线程。 CIL 没有提到它,因为闭包是一种语言结构。


1.

Microsoft(前)员工和 MVP 声明写入具有发布语义的几个地方:

在我的代码中,我忽略了这个实现细节。我假设不保证非易失性写入变得可见。


2.

有一个常见的误解,认为您可以在 C# and/or CLI 中引入读取。

但是,这仅适用于局部参数和变量。

对于静态和实例字段、数组或堆上的任何东西,您不能理智地引入读取,因为这样的引入可能会破坏从当前执行线程看到的执行顺序,或者来自其他线程的合法更改线程,或通过反射进行更改。

也就是说,你不能转这个:

object local = field;
if (local != null)
{
    // code that reads local
}

进入这个:

if (field != null)
{
    // code that replaces reads on local with reads on field
}

如果你能分辨出区别的话。具体来说,通过访问 local 的成员抛出 NullReferenceException

对于 C# 的捕获变量,它们相当于实例字段。

重要的是要注意 CLI 标准:

  • 表示不保证非易失性访问可见

  • 并没有说保证非易失性访问不可见

  • 表示易失性访问会影响非易失性访问的可见性

但是你可以转这个:

object local2 = local1;
if (local2 != null)
{
    // code that reads local2 on the assumption it's not null
}

进入这个:

if (local1 != null)
{
    // code that replaces reads on local2 with reads on local1,
    // as long as local1 and local2 have the same value
}

你可以转这个:

var local = field;
local?.Method()

进入这个:

var local = field;
var _temp = local;
(_temp != null) ? _temp.Method() : null

或者这个:

var local = field;
(local != null) ? local.Method() : null

因为您永远无法区分。但是同样,你不能把它变成这样:

(field != null) ? field.Method() : null

我认为这两个规范都指出优化编译器可以 重新排序 读取和写入,只要单个执行线程按照写入的方式观察它们,而不是通常 引入完全消除它们。

请注意,读取 消除 可能由 C# 编译器或 JIT 编译器 执行,即对同一个非volatile 字段,由不写入该字段且不执行 volatile 操作或等效操作的指令分隔,可能会折叠为单个读取。就好像一个线程从不与其他线程同步,所以一直观察同一个值:

public class Worker
{
    private bool working = false;
    private bool stop = false;

    public void Start()
    {
        if (!working)
        {
            new Thread(Work).Start();
            working = true;
        }
    }

    public void Work()
    {
        while (!stop)
        {
            // TODO: actual work without volatile operations
        }
    }

    public void Stop()
    {
        stop = true;
    }
}

无法保证 Stop() 会停止工作程序。 Microsoft 的 .NET 实现保证 stop = true; 是一个可见的副作用,但它不保证 Work() 内的 stop 上的读取不会被忽略为:

    public void Work()
    {
        bool localStop = stop;
        while (!localStop)
        {
            // TODO: actual work without volatile operations
        }
    }

这条评论说了很多。要执行此优化,编译器必须证明不存在任何易失性操作,无论是直接在块中,还是间接地在整个方法和属性调用树中。

对于这种特定情况,一种正确的实现方式是将 stop 声明为 volatile。但是还有更多选项,例如使用等效的 Volatile.ReadVolatile.Write,使用 Interlocked.CompareExchange,使用 lock 语句访问 stop,使用等效的东西到一个锁,例如 MutexSemaphoreSemaphoreSlim 如果您不希望锁具有线程亲和性,即您可以在与一个获得它的人,或者使用 ManualResetEventManualResetEventSlim 而不是 stop 在这种情况下,您可以让 Work() 在等待下一个停止信号之前超时休眠迭代等


3.

与 Java 的易失同步相比,.NET 的易失同步的一个显着差异是 Java 要求您使用相同的易失位置,而 .NET 只需要获取(易失读取)发生在释放(易失性写入)之后。因此,原则上您可以在 .NET 中使用以下代码进行同步,但不能与 Java:

中的等效代码同步
using System;
using System.Threading;

public class SurrealVolatileSynchronizer
{
    public volatile bool v1 = false;
    public volatile bool v2 = false;
    public int state = 0;

    public void DoWork1(object b)
    {
        var barrier = (Barrier)b;
        barrier.SignalAndWait();
        Thread.Sleep(100);
        state = 1;
        v1 = true;
    }

    public void DoWork2(object b)
    {
        var barrier = (Barrier)b;
        barrier.SignalAndWait();
        Thread.Sleep(200);
        bool currentV2 = v2;
        Console.WriteLine("{0}", state);
    }

    public static void Main(string[] args)
    {
        var synchronizer = new SurrealVolatileSynchronizer();
        var thread1 = new Thread(synchronizer.DoWork1);
        var thread2 = new Thread(synchronizer.DoWork2);
        var barrier = new Barrier(3);
        thread1.Start(barrier);
        thread2.Start(barrier);
        barrier.SignalAndWait();
        thread1.Join();
        thread2.Join();
    }
}

这个超现实的例子期望线程和 Thread.Sleep(int) 花费准确的时间。如果是这样,它会正确同步,因为 DoWork2DoWork1 执行易失性写入(释放)之后执行易失性读取(获取)。

在Java中,即使实现了如此超现实的期望,也不能保证同步。在 DoWork2 中,您必须从在 DoWork1.

中写入的同一个可变字段中读取