我先解决最后一个问题。Microsoft 的 .NET 实现在写入1上有发布语义。它本身不是 C#,所以同一个程序,无论是什么语言,在不同的实现中都可能有弱的非易失性写入。
副作用的可见性与多个线程有关。忘掉 CPU、内核和缓存吧。相反,想象一下,每个线程都有一个堆上的快照,需要某种同步来在线程之间传达副作用。
那么,C# 是怎么说的呢?C# 语言规范(较新的草案)与公共语言基础设施标准(CLI;ECMA-335和ISO/IEC 23271)基本相同,但存在一些差异。稍后我会谈到它们。
那么,CLI 是怎么说的呢?只有不稳定的操作是可见的副作用。
请注意,它还表示堆上的非易失性操作也是副作用,但不能保证可见。同样重要的是2,它并没有说明它们也保证不可见。
易失性操作到底发生了什么?易失性读取具有获取语义,它在任何后续内存引用之前。易失性写入具有释放语义,它遵循任何先前的内存引用。
获取锁执行易失性读取,释放锁执行易失性写入。
Interlocked
操作具有获取和释放语义。
还有一个重要的术语要学习,那就是原子性。
读取和写入,无论是否易失,都保证在 32 位架构上最多 32 位和 64 位架构上最多 64 位的原始值上是原子的。它们也保证是原子参考。对于其他类型,例如 long struct
,操作不是原子的,它们可能需要多个独立的内存访问。
然而,即使使用 volatile 语义,read-modify-write 操作,例如v += 1
或等价的++v
(或v++
,就副作用而言),也不是原子的。
互锁操作保证某些操作的原子性,通常是加法、减法和比较和交换 (CAS),即当且仅当当前值仍然是某个预期值时才写入某个值。.NET 还具有一种Read(ref long)
用于 64 位整数的原子方法,即使在 32 位体系结构中也可以使用。
我将继续将获取语义称为易失性读取,将释放语义称为易失性写入,或者两者都称为易失性操作。
就顺序而言,这一切意味着什么?
在语言级别和机器级别,易失性读取是内存引用不能跨越的点,而易失性写入是内存引用不能跨越的点。
如果两者之间没有易失性写入,则非易失性操作可能会交叉到后续易失性读取之后,如果两者之间没有易失性读取,则可以交叉到之前的易失性写入之前。
线程内的易失性操作是顺序的,可能不会重新排序。
线程中的易失性操作以相同的顺序对所有其他线程可见。但是,所有线程的 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

- V3 V4 V1 V2

也就是说,观察到的副作用的任何可能顺序对于单次执行的任何线程都是有效的。对总排序没有要求,因此所有线程只观察一次执行的可能顺序之一。
事情是如何同步的?
从本质上讲,它归结为:同步点是在易失性写入之后发生易失性读取的地方。
在实践中,您必须检测一个线程中的 volatile 读取是否发生在另一个线程3中的 volatile 写入之后。这是一个基本示例:
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;
}
这意味着,您还必须分析解决方案并与当前状态进行比较。并注意ABA 问题。
还有SpinLock
,您必须真正针对基于监视器的锁进行分析,因为尽管它们可能使当前线程屈服,但它们不会使当前线程进入睡眠状态,类似于所示的SpinWait
.
切换到不稳定的操作就像玩火一样。你必须通过分析证明你的代码是正确的,否则你可能会在你最意想不到的时候被烧毁。
通常,在高争用情况下进行优化的最佳方法是避免争用。例如,要在一个大列表上并行执行转换,通常最好将问题划分并委托给多个工作项,这些工作项会生成在最后一步合并的结果,而不是让多个线程锁定列表以进行更新。这有一个内存成本,所以它取决于数据集的长度。
C# 规范和 CLI 规范之间关于易失性操作的区别是什么?
C# 指定了副作用,但并未提及它们的线程间可见性,例如对 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)
{
// 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.Read
and Volatile.Write
, using Interlocked.CompareExchange
,使用lock
围绕 accesses 的语句stop
,使用与锁等效的东西,例如 a ,Mutex
或者如果您不希望锁具有线程关联性,即您可以在与获取它的线程不同的线程上释放它,或者使用or代替,在这种情况下,您可以在等待下一次迭代之前的停止信号时超时休眠,等等。Semaphore
SemaphoreSlim
ManualResetEvent
ManualResetEventSlim
stop
Work()
3.
.NET 的 volatile 同步与 Java 的 volatile 同步的一个显着区别是 Java 要求您使用相同的 volatile 位置,而 .NET 只要求在发布(volatile 写入)之后发生获取(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)
需要精确的时间。如果是这样,它会正确同步,因为在执行易失性写入(释放)DoWork2
之后执行易失性读取(获取)。DoWork1
在 Java 中,即使实现了这种超现实的期望,这也不能保证同步。在DoWork2
中,您必须从您在中写入的同一个 volatile 字段中读取DoWork1
。