我将首先回答最后一个问题。Microsoft的.NET实现在写入时具有发布语义
1。这不是C#本身的问题,所以相同的程序,无论使用哪种语言,在不同的实现中都可能具有弱非易失性写入。
副作用的
可见性涉及多个线程。忘记CPU、核心和缓存。相反,想象每个线程都有一个堆上的快照,需要某种同步才能在线程之间通信副作用。
那么,C#说了什么?
C#语言规范(
新版本草案)基本上与公共语言基础设施标准(CLI;
ECMA-335和
ISO/IEC 23271)说的一样,只是有些差异。我稍后会谈到它们。
那么,CLI说了什么?只有易失性操作是可见的副作用。
请注意,它还说堆上的非易失性操作也是副作用,但不能保证可见。同样重要的是
2,它也没有声明它们保证
不可见。
volatile操作到底会发生什么?volatile读具有获取语义,它在任何后续内存引用之前。volatile写具有释放语义,它在任何先前的内存引用之后。
获取锁执行volatile读取,释放锁执行volatile写入。
Interlocked
操作具有获取和释放语义。
还有一个重要的术语需要学习,那就是原子性。
对于32位架构上的基本值和64位架构上的基本值,无论是否为volatile,读取和写入都保证是原子的。对于引用,它们也保证是原子的。对于其他类型,例如长结构体
,操作不是原子的,可能需要多个独立的内存访问。
然而,即使使用volatile语义,读取-修改-写入操作,例如v += 1
或等效的++v
(或v++
,就副作用而言),也不是原子的。
Interlocked操作保证某些操作的原子性,通常是加法、减法和比较交换(CAS),即如果当前值仍然是某个期望值,则写入某个值。.NET还具有针对64位整数的原子Read(ref long)
方法,即使在32位架构上也可以使用。
我将继续将获取语义称为volatile读取,将释放语义称为volatile写入,将二者或其中任何一个称为volatile操作。
所有这些都意味着什么,就是顺序?
volatile读取是一个点,在该点之前,没有内存引用可以跨越,而volatile写入是一个点,在该点之后,没有内存引用可以跨越,这两个点在语言级别和机器级别都是如此。
如果中间没有volatile写入,则非volatile操作可以跨越到以下volatile读取之后,如果中间没有volatile读取,则可以跨越到先前的volatile写入之前。
线程内的volatile操作是顺序的,不能被重新排序。
线程中的volatile操作按照相同的顺序对所有其他线程可见。但是,没有所有线程的volatile操作的总顺序,即如果一个线程执行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
(注:该文本为HTML代码,无法直接翻译,已按原样返回。)
- 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问题。
还有
SpinLock
,你必须真正地针对基于监视器的锁进行分析,因为虽然它们可能使当前线程让出,但它们不会将当前线程置于睡眠状态,类似于
SpinWait
的使用方式。
转换为易失性操作就像玩火。您必须通过分析证明代码是正确的,否则您可能会在最不希望的时候受到伤害。
通常,在高争用情况下优化的最佳方法是避免争用。例如,要并行地对大型列表执行转换,通常最好将问题分解和委派给多个工作项,生成合并在最后一步的结果,而不是让多个线程锁定列表以进行更新。这具有内存成本,因此它取决于数据集的长度。
C#规范和CLI规范在volatile操作方面有什么区别?
C#规定副作用,但未提及它们在线程间的可见性,包括对volatile字段的读或写、对非volatile变量的写、对外部资源的写以及抛出异常。
C#指定关键执行点,在这些点上,这些副作用在线程之间得到保留:对volatile字段的引用、lock语句以及线程的创建和终止。
如果我们将关键执行点视为副作用变得“可见”的点,则它增加了CLI规范,即线程的创建和终止是“可见”的副作用,即new Thread(...).Start()在当前线程上具有释放语义,在新线程的开始处具有获取语义,并且退出线程在当前线程上具有释放语义,而thread.Join()在等待线程上具有获取语义。
C#没有一般的volatile操作的提及,例如由System.Threading中的类执行,而不仅仅是通过声明为volatile的字段和使用lock语句来执行。我认为这不是故意的。
C#说明捕获的变量可以同时暴露给多个线程。CIL没有提及它,因为闭包是一种语言结构。
1.
有一些地方的微软(前)员工和MVP声称写操作具有发布语义:
在我的代码中,我忽略了这个实现细节。我假设非易失性写入不保证可见。
2.
有一个普遍的误解,认为在C#和/或CLI中允许引入读取操作。
然而,这仅适用于局部参数和变量。
对于静态和实例字段、数组或堆上的任何内容,您不能合理地引入读取操作,因为这样的引入可能会从当前执行线程的角度打破执行顺序,无论是来自其他线程的合法更改还是通过反射进行的更改。
也就是说,您不能将此代码:
object local = field;
if (local != null)
{
}
变成这样:
if (field != null)
{
}
如果你能够分辨它们的区别。具体来说,当访问local的成员时,会抛出NullReferenceException异常。
对于C#中的捕获变量,它们相当于实例字段。
需要注意的是CLI标准:
表示非易失性访问不能保证可见性
没有表示非易失性访问一定不可见
表示易失性访问会影响非易失性访问的可见性
但你可以将其转换为:
object local2 = local1;
if (local2 != null)
{
}
变成这样:
if (local1 != null)
{
}
您可以将此转换为:
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)
{
}
}
public void Stop()
{
stop = true;
}
}
不能保证Stop()
会停止工作线程。微软的.NET实现保证stop=true;
是一个可见的副作用,但它不能保证在Work()
内对stop
的读取不会被省略为以下内容:
public void Work()
{
bool localStop = stop;
while (!localStop)
{
}
}
那条评论说了很多。为了执行此优化,编译器必须证明在该块中没有任何volatile操作,或者在整个方法和属性调用树中间接存在任何volatile操作。
对于这种情况,一个正确的实现是将“stop”声明为“volatile”。但还有更多选项,例如使用等效的Volatile.Read和Volatile.Write,使用Interlocked.CompareExchange,在访问“stop”的访问上使用lock语句,使用类似于锁定的东西,例如Mutex,如果您不希望锁定具有线程关联性,即可以在获取它的线程之外的不同线程上释放它,则可以使用Semaphore和SemaphoreSlim,或者在代替stop时使用ManualResetEvent或ManualResetEventSlim,在这种情况下,您可以使Work()在等待停止信号之前的下一次迭代之前睡眠并设置超时。
3.
.NET的volatile同步与Java的volatile同步的一个显著区别是,Java要求您使用相同的volatile位置,而.NET只需要在release(volatile write)之后发生acquire(volatile read)。因此,原则上您可以使用以下代码在.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)
需要精确地花费一定的时间。如果是这样,它将正确同步,因为
DoWork1
执行了一个易失性写操作(release)后,
DoWork2
执行了一个易失性读操作(acquire)。
在Java中,即使满足这种超现实的期望,也不能保证同步。在
DoWork2
中,您必须从与
DoWork1
写入的相同易失性字段中读取。
volatile
的原因。 - Eric Lippert