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

时间:2017-06-22 07:25:19

标签: c# caching volatile memory-fences

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

但是,我还没有找到volatile与主记忆缓存之间关系的令人满意的解释。

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

另外,我读了一些关于强弱易失性语义的内容,并且C#遵循强大的语义,每次写入都会直接进入主内存,无论它是否为{{1}字段与否。我对这一切感到非常困惑。

3 个答案:

答案 0 :(得分:10)

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

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

那么,C#说什么? C# language specificationnewer draft)与公共语言基础结构标准(CLI; ECMA-335ISO/IEC 23271)基本相同,但存在一些差异。我稍后会谈论它们。

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

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

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

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

Interlocked操作已获取并释放语义。

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

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

然而,即使使用volatile语义,读取 - 修改 - 写入操作(例如v += 1或等效的++v(或v++,在副作用方面)也不是原子。

互锁操作保证某些操作的原子性,通常是加法,减法和比较交换(CAS),即当且仅当当前值仍为某个预期值时写入某个值。 .NET还有一个64位整数的原子Read(ref long)方法,即使在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)
        {
        }
    }
}

然而,通常效率低下,您可以运行两个不同的线程,例如一个调用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;
}

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

还有SpinLock,您必须真正对基于监视器的锁定进行分析,因为虽然它们可能使当前线程产生,但它们不会让当前线程进入睡眠状态,类似显示SpinWait的使用情况。

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

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

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

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

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

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

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

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

1

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

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

2

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

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

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

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

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

进入这个:

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

如果你能分辨出来的话。具体而言,访问NullReferenceException的成员会引发local

对于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

我认为在两个规范中都是谨慎的,声明优化编译器可以重新排序读取和写入,只要单个执行线程将其视为已写入,而不是通常引入完全消除

请注意,读取消除 可能由

编译器或JIT编译器执行,即在同一个非易失性字段上进行多次读取,并由以下指令分隔写入该字段并且不执行易失操作或等效操作可能会折叠为单个读取。好像一个线程永远不会与其他线程同步,所以它会一直观察到相同的值:

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;是一种可见的副作用,但它并不能保证stop内的Work()上的读取不会被忽略:

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

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

对于这种特定情况,一个正确的实现是将stop声明为volatile。但是有更多选项,例如使用等效的Volatile.ReadVolatile.Write,使用Interlocked.CompareExchange,使用围绕lock访问的stop语句,使用等价物锁定,例如Mutex,或SemaphoreSemaphoreSlim,如果您不希望锁具有线程关联,即您可以在其他线程上释放它比获取它的那个,或使用ManualResetEventManualResetEventSlim而不是stop,在这种情况下,您可以在等待停止信号之前使Work()睡眠超时下一次迭代等等。

3

与Java的易失同步相比,.NET的易失同步的一个显着差异是Java要求您使用相同的易失性位置,而.NET只要求获取(易失性读取)后发生一个发布(volatile写)。因此,原则上您可以使用以下代码在.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中写入的相同易变字段中读取。

答案 1 :(得分:8)

  

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

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

  

规范在这件事上没有说明是否正确?

没有。该规范在这个问题上非常明确。

  

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

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

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

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

此时我们有一个特殊的副作用 - 写入 - 关键执行点 - 线程开始 - 以及第二个特殊副作用 - 易失性读取。因此,Bravo 必需v读取一个。 (假设当然没有其他线程写过它。)

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

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

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

答案 2 :(得分:0)

是的,volatile是围栏,围栏是关于订购的。 那么 不在范围内并且实际上是所有层(编译器,JIT,CPU等)组合的实现细节, 但是每个实施都应该对这个问题有一个体面和实际的答案。