[置顶] .NET线程同步之Volatile构造

时间:2021-06-20 23:31:55

本篇博客我主要讨论一下为什么需要Volatile,以及Volatile能够保证什么。

编译器优化

c#编译器会在不改变我们的意图的情况下做一些优化,比如:

a = 1;
a = 2;

编译器编译之后,可能就只剩下第二行了。
再比如:

a = 1;
b = a;

编译器优化后,可能会把第二行优化成b = 1
再比如:

a = m;
b = n;

编译器生成IL时,有可能会改变两行代码的顺序。
以上变化都是在编译器认为不改变作者意图的前提下做的,如果在单线程环境下这也没有问题,但是如果是多线程环境操作一个公共资源的话,先读后读或先写后写,都可能会造成不确定的结果。

运行时优化

在运行时,为了提高运行速度,CPU读取一个变量时,可能会从内存中把它加载到CPU的寄存器内,下次再读取这个变量的话直接从寄存器中读取。但在多线程环境下,如果这是一个公共变量的话,第二次读取之前这个变量可能会在别的线程中被修改了而它却不知道。

使用Volatile实现线程同步

System.Threading.Volatile类提供了两个静态方法

public static bool Read (ref bool location);
public static void Write (ref int location, int value);

Volatile.Write方法保证两点:

  1. 将值写入到变量所对应的内存地址中。由ref关键字可以看得出来。
  2. 如果Volatile.Write方法之前有读写location的操作,那么编译器生成的IL代码也保证这些代码必须出现在Volatile.Write之前。Volatile.Write之后的代码则不保证。

Volatile.Read也方法保证了两点

  1. 总是从location的内存地址中读取值。(而不会从CPU的寄存器内读取)
  2. 如果Volatile.Read方法之后有读写location的操作,那么编译器生成的IL代码也保证这些代码必须出现在Volatile.Read之后。Volatile.Read之前的代码则不保证。

Volatile.WriteVolatile.Read的第1点结合起来,就保证了对一个公共变量的读取总是可以得到它最新的值。第2点结合起来的意思就是,总是最后调用Volatile.Write写入最后一个值,并且总是最先调用Volatile.Read读取最新的值,这就保证了我们的代码能够不被编译器搞乱了。

使用Volatile的例子

下面我们用一个例子来展示一下Volatile的强大之处。下面的例子同时运行着两个线程,一个线程只打印单数,另一个线程只打印双数,通过Volatile的静态方法来读写一个公共资源来控制两个线程交叉打印。

using System;
using System.Threading;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace HelloConsole
{
class Program
{
static bool isSingle = true;
static void Main(string[] args)
{
Parallel.Invoke(()=>PrintList1(),()=>PrintList2());
}

static void PrintList1()
{
for (int i = 0; i < 100; i = i + 2)
{
while (true)
{
if (Volatile.Read(ref isSingle) == true)
{
Console.WriteLine(i);
Volatile.Write(ref isSingle, false);
break;
}
Thread.Yield();
}
}
}

static void PrintList2()
{
for (int i = 1; i < 100; i = i + 2)
{
while (true)
{
if (Volatile.Read(ref isSingle) == false)
{
Console.WriteLine(i);
Volatile.Write(ref isSingle, true);
break;
}
Thread.Yield();
}
}
}
}
}

上面代码的运行结果是以非常快的速度从0打印到99。

我解释一下Thread.Yield()方法的含义:如果windows发现有另一个线程已经准备好在当前CPU上执行,则会结束调用该方法的线程的剩余CPU时间而被选中的线程会得到一个CPU时间片,这种情况下返回true,然后,调用Yield的线程会再次被调度,开始一个全新的CPU时间片。如果windows发现没有已经准备好在当前CPU执行的线程,则调用Yield的线程会继续运行它的剩余时间片,这种情况下返回false。

由于这个方法比较依赖windows api,所以在dotnet core的版本中还没有提供,如果你使用的是dotnet core,则可以使用Thread.Sleep(1)来达到我们本例中的目的。Thread.Sleep()方法的含义是:调用该方法的线程自动放弃自己当前剩余的CPU时间并且在指定的时间内不再被CPU调度。但.net并不保证指定时间过去后该线程会立刻被调度。

volatile关键字

c#还提供了volatile关键字来简化对Volatile的静态方法的调用,但我觉得这个关键字虽然简化了使用,但是却会迷惑我们,会掩盖真相。所以,我更喜欢直接使用Volatile.Write()Volatile.Read()这两个静态方法。

对Volatile的误解

我看过一些人写的博客,包括一些老外写的博客,很多人都对Volatile有一些不太正确的理解。

有人认为编译器会保证Volatile上下文中代码的顺序,其实不对,Volatile只保证Write()方法之前的代码一定出现在Write()之前,Read()方法之后的代码一定出现在Read()之后

有些人认为Volatile很神奇,一个线程使用Volatile更新一个共有变量后,这个共有变量的变化会通知给所有读取这个变量的其它线程,其实也不对,真相是,Write()保证把值更新到内存中,Read()保证从内存地址读取值,而不从CPU寄存器或其它缓存中读取。

看下面的代码

int value = 0;
void ChangeValue(int addValue)
{
int temp = Volatile.Read(ref value);
Volatile.Write(ref value, temp + addValue);
}

有人认为Volatile.Read总能对读取到最新的值,所以上面的ChangeValue()方法在多线程环境下并发执行也不会有问题,其实也不对,因为有可能两个线程同时读取到原来的值,并且同时更新这个值,就造成了最终的值是错误的,因为有一次更新丢失了。

那么,如果解决上面的问题呢?其实在上面的代码中,读取是一个原子级的操作,写入是一个原子级的操作,但读取和写入不是一个原子级的操作,这就造成了并发时的更新丢失。正确的方法是使用Interlocked.Add(ref value,addValue),这个方法实现了原子级的读写。

Interlocked也是一种线程同步的构造,封装了一些原子级的读写操作,我回头会用专门的博客来讨论这个话题。