C#volatile变量:内存栅栏VS. 高速缓存

dom*_*min 6 c# caching volatile memory-fences

所以我现在研究这个主题很长一段时间了,我想我理解最重要的概念,如发布和获取内存栅栏.

但是,我还没有找到令人满意的解释,volatile主存储器的缓存和缓存之间的关系.

因此,我理解对volatile字段的每次读写操作都会对读取以及之前和之后的写入操作执行严格的排序(读取 - 获取和写入 - 释放).但这只能保证操作的顺序.它没有说明这些更改对其他线程/处理器可见时间.特别是,这取决于刷新缓存的时间(如果有的话).我记得曾读过Eric Lippert的评论,他说" volatile字段的存在会自动禁用缓存优化".但我不确定这究竟是什么意思.这是否意味着整个程序的缓存完全被禁用,因为我们在volatile某处有一个字段?如果不是,禁用缓存的粒度是多少?

此外,我读到了一些关于强和弱的易失性语义的东西,并且C#遵循强大的语义,即每次写入都将直接进入主存储器,无论它是否是一个volatile字段.我对这一切感到非常困惑.

ace*_*ent 12

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

副作用的可见性涉及多个线程.忘记CPU,核心和缓存.相反,想象一下,每个线程都有一个快照,其中包含堆上的内容,需要某种同步来传递线程之间的副作用.

那么,C#说什么呢?在C#语言规范(新草案)认为从根本上一样的通用语言基础标准(CLI; ECMA-335ISO/IEC 23271),有一些不同.我稍后会谈论它们.

那么,CLI说什么呢?只有挥发性操作才是可见的副作用.

请注意,它还表示堆上的非易失性操作也是副作用,但不保证可见.同样重要的是2,它不会他们保证状态可见两种.

易变操作究竟发生了什么?易失性读取具有获取语义,它位于任何后续内存引用之前.易失性写入具有释放语义,它遵循任何前面的内存引用.

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

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

还有另一个需要学习的重要术语,即原子性.

对于32位体系结构中高达32位的原始值以及64位体系结构上高达64位的原始值,保证读取和写入(无论是否为volatile).它们也保证是原子的参考.对于其他类型,例如long structs,操作不是原子操作,它们可能需要多个独立的内存访问.

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

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

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

这在订单方面意味着什么?

易失性读取是在没有存储器引用可以交叉之前的点,并且易失性写入是在语言级别和机器级别之后没有存储器引用可以交叉的点.

如果在两者之间没有易失性写入,则非易失性操作可以在跟随易失性读取之后交叉,并且如果在它们之间没有易失性读取,则交叉到之前的易失性写入之前.

线程内的易失性操作是顺序的,可能不会重新排序.

线程中的易失性操作以相同的顺序对所有其他线程可见.但是,没有来自所有线程的易失性操作的总顺序,即如果一个线程执行V1然后执行V2,而另一个线程执行V3然后执行V4,则任何在V4之前具有V1之前的任何顺序都可以由任何线程执行.线.在这种情况下,它可以是以下任一种:

  • V1 V2 V3 V4 V1 V2 V3 V4

  • V1 V3 V2 V4 V1 V3 V2 V4

  • V1 V3 V4 V2 V1 V3 V4 V2

  • V3 V1 V2 V4 V3 V1 V2 V4

  • V3 V1 V4 V2 V3 V1 V4 V2

  • V3 V4 V1 V2 V3 V4 V1 V2

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

事情是如何同步的?

从本质上讲,它归结为:同步点是易失性写入后发生的易失性读取.

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

public class InefficientEvent
{
    private volatile bool signalled = false;

    public Signal()
    {
        signalled = true;
    }

    public InefficientWait()
    {
        while (!signalled)
        {
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

然而,通常效率低下,您可以运行两个不同的线程,例如一个调用InefficientWait()和另一个调用Signal(),后者返回时的副作用在返回时Signal()变为前者可见InefficientWait().

易失性访问通常不如互锁访问有用,互锁访问通常不像同步原语那样有用.我的建议是你应该首先安全地开发代码,根据需要使用同步原语(锁,信号量,互斥,事件等),如果你找到基于实际数据(例如分析)提高性能的理由,那么只有这样看看你是否可以改进.

如果您对快速锁定达到高争用(仅用于少量读取和写入而没有阻塞),则根据争用量,切换到互锁操作可能会改善或降低性能.特别是当你不得不采用比较和交换周期时,例如:

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;
}
Run Code Online (Sandbox Code Playgroud)

意思是,您还必须分析解决方案并与当前状态进行比较.并注意ABA问题.

还有SpinLock,你必须真正分析基于监视器的锁,因为虽然它们可能使当前线程产生,但它们不会使当前线程进入休眠状态,类似于所显示的用法SpinWait.

切换到易失操作就像玩火.您必须通过分析证明确保您的代码是正确的,否则您可能会在您最不期望的时候被烧毁.

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


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

C#指定副作用,不提及它们的线程间可见性,作为易失性字段的读取或写入,对非易失性变量的写入,对外部资源的写入以及抛出异常.

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

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

C#一般不提及volatile操作,例如由类执行System.Threading而不是仅通过使用声明为volatile和使用lock语句的字段来执行.我相信这不是故意的.

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


1.

Microsoft(前)员工和MVP有一些地方表示写入具有发布语义:

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


2.

有一种常见的误解,即您可以在C#和/或CLI中引入读取.

但是,这仅适用于本地参数和变量.

对于静态和实例字段,数组或堆上的任何内容,您无法理所当然地引入读取,因为这样的引入可能会破坏从当前执行线程看到的执行顺序,无论是来自其他线程中的合法更改,还是来自更改通过反思.

也就是说,你不能这样做:

object local = field;
if (local != null)
{
    // code that reads local
}
Run Code Online (Sandbox Code Playgroud)

进入这个:

if (field != null)
{
    // code that replaces reads on local with reads on field
}
Run Code Online (Sandbox Code Playgroud)

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

对于C#捕获的变量,它们等同于实例字段.

重要的是要注意CLI标准:

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

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

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

但你可以这样做:

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

进入这个:

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

你可以这个:

var local = field;
local?.Method()
Run Code Online (Sandbox Code Playgroud)

进入这个:

var local = field;
var _temp = local;
(_temp != null) ? _temp.Method() : null
Run Code Online (Sandbox Code Playgroud)

或这个:

var local = field;
(local != null) ? local.Method() : null
Run Code Online (Sandbox Code Playgroud)

因为你无法区分它们.但同样,你不能把它变成这样:

(field != null) ? field.Method() : null
Run Code Online (Sandbox Code Playgroud)

我相信在两个规范中都是谨慎的,声明优化编译器可以重新排序读取和写入,只要单个执行线程按照写入方式观察它们,而不是通常完全引入消除它们.

请注意,读取消除 可以由C#编译器或JIT编译器执行,即在同一个非易失性字段上进行多次读取,由不写入该字段且不执行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;
    }
}
Run Code Online (Sandbox Code Playgroud)

无法保证Stop()会阻止工人.Microsoft的.NET实现保证了这stop = true;是一个可见的副作用,但它并不能保证stop内部的读取Work()不会被忽略:

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

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

对于这种特定情况,一个正确的实现是声明stopvolatile.但也有更多的选择,如使用等效Volatile.ReadVolatile.Write使用Interlocked.CompareExchange,用lock身边的语句访问stop,使用的东西相当于锁,如Mutex,或SemaphoreSemaphoreSlim如果你不想锁具有线程亲和力,即您可以在与获取它的线程不同的线程上释放它,或者使用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();
    }
}
Run Code Online (Sandbox Code Playgroud)

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

在Java中,即使满足这些超现实的期望,也不能保证同步.在DoWork2,你必须从你写的相同的volatile字段中读取DoWork1.

  • 非常感谢这个详尽而明确的答案!我把它作为公认的,因为你最直接地解决了我原来的问题. (2认同)

Eri*_*ert 9

我阅读了规范,他们没有说明另一个线程是否会观察到易失性写入(无论是否为易失性读取).这是正确与否?

让我重新解释一下这个问题:

规范在这个问题上没有说什么是正确的吗?

不.规范在这个问题上非常明确.

是否保证在另一个线程上观察到易失性写入?

是的,如果另一个线程有一个关键的执行点.甲特殊副作用是保证观察到订购相对于临界执行点.

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

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

在这一点上,我们有一个特殊的副作用 - 写 - 一个关键的执行点 - 线程开始 - 和第二个特殊的副作用 - 一个易失性读.因此,Bravo 需要从中读取一个v.(假设当然没有其他线程写过它.)

Bravo现在v增加到2并结束.这是一个特殊的副作用 - 写入和关键执行点 - 线程的结束.

当线程阿尔法现在恢复和做的挥发性读v要求它读取两个.(假设当然没有其他线程写入它.)

必须保留Bravo写入和Bravo终止的副作用的顺序; 很明显Alpha直到Bravo终止后才会再次运行,因此需要观察写入.