gpt4 book ai didi

c# - C# volatile 变量:内存隔离VS。快取

转载 作者:行者123 更新时间:2023-12-02 10:51:20 25 4
gpt4 key购买 nike

因此,我已经对该主题进行了相当长时间的研究,并且我认为我了解最重要的概念,例如释放和获取内存隔离。

但是,对于volatile和主内存的缓存之间的关系,我还没有找到令人满意的解释。

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

另外,我读到一些有关强弱可变语义的知识,而C#遵循强语义,即无论是否为volatile字段,每次写入都将一直直接进入主内存。我对此感到非常困惑。

最佳答案

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

副作用的可见性与多个线程有关。不用理会CPU,核心和缓存。相反,想象一下,每个线程都有一个堆上快照的快照,该快照需要某种同步才能在线程之间传递副作用。

那么,C#怎么说呢? C# language specificationnewer draft)表示与公共语言基础结构标准(CLI; ECMA-335ISO/IEC 23271)基本相同,但有所不同。稍后再讨论。

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

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

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

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

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

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

对于基本值,在32位体系结构上最多32位,在64位体系结构上最多64位。它们也保证是原子引用。对于其他类型,例如long struct,这些操作不是原子的,它们可能需要多个独立的内存访问。

但是,即使语义不稳定,读-修改-写操作(例如v += 1或等效的++v(或副作用而言,v++))也不是原子的。

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

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

就订单而言,这一切意味着什么?

在语言级别和机器级别上,易失性读是指没有内存引用可以跨越的点,而易失性写是指没有内存引用可以跨越的点。

如果它们之间没有易失性写入,则该非易失性操作可能会在随后的易失性读取之后与之交互,而如果两者之间没有易失性读取,则该非易失性操作可能会与先前的易失性写入之前相抵触。

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

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


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#将副作用(未提及它们在线程间的可见性)指定为对volatile字段的读取或写入,对非易失性变量的写入,对外部资源的写入以及引发异常。

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

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

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

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



1。

在某些地方,Microsoft(前雇员)和MVP声明撰写的内容具有发布语义:


Memory Model, by Chris Brumme
Memory Models, Understand the Impact of Low-Lock Techniques in Multithreaded Apps, by Vance Morrison
CLR 2.0 memory model, by Joe Duffy
Which managed memory model?, by Eric Eilebrecht
C# - The C# Memory Model in Theory and Practice, Part 2, by Igor Ostrovsky


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



2。

有一个普遍的误解,认为您可以在C#和/或CLI中引入读取。


The problem with being second, by Grant Richins
The CLI memory model, and specific specifications, by Jon Skeet的评论
C# - The C# Memory Model in Theory and Practice, Part 2, by Igor Ostrovsky


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

对于静态字段和实例字段,数组或堆上的任何内容,您不能理智地引入读取,因为这样的引入可能会破坏执行顺序,从当前执行线程(从其他线程中进行的合法更改或从其他线程进行的更改)可以看出通过反思。

也就是说,您不能打开此:

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


我认为在这两个规范中都保持谨慎,指出优化的编译器可以对读取和写入进行重新排序,只要单个执行线程将其视为写入状态即可,而不是通常完全引入并完全消除它们。

请注意,可以通过C#编译器或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仅要求在释放(易失性写)之后进行获取(易失性读)。因此,原则上,您可以在.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)花费确切的时间。如果是这样,它将正确同步,因为在 DoWork2执行易失性写入(释放)之后, DoWork1将执行易失性读取(获取)。

在Java中,即使满足了这种超现实的期望,也不能保证同步。在 DoWork2中,您必须读取与在 DoWork1中写入的相同易失字段。

关于c# - C# volatile 变量:内存隔离VS。快取,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/44692780/

25 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com