深入多线程之:内存栅栏与volatile关键字的使用分析
以前我们说过在一些简单的例子中,比如为一个字段赋值或递增该字段,我们需要对线程进行同步,
虽然lock可以满足我们的需要,但是一个竞争锁一定会导致阻塞,然后忍受线程上下文切换和调度的开销,在一些高并发和性能比较关键的地方,这些是不能忍受的。
.net framework 提供了非阻塞同步构造,为一些简单的操作提高了性能,它甚至都没有阻塞,暂停,和等待线程。
Memory Barriers and Volatility (内存栅栏和易失字段 )
考虑下下面的代码:
代码如下:
int _answer;
bool _complete;
void A()
{
_answer = 123;
_complete = true;
}
void B()
{
if (_complete)
Console.WriteLine(_answer);
}
如果方法A和B都在不同的线程下并发的执行,方法B可能输出 “0” 吗?
回答是“yes”,基于以下原因:
编译器,clr 或 cpu 可能会为了性能而重新为程序的指令进行排序,例如可能会将方法A中的两句代码的顺序进行调整。
编译器,clr 或 cpu 可能会为变量的赋值采用缓存策略,这样这些变量就不会立即对其他变量可见了,例如方法A中的变量赋值,不会立即刷新到内存中,变量B看到的变量并不是最新的值。
C# 和运行时非常小心的保证这些优化策略不会影响正常的单线程的代码和在多线程环境下加锁的代码。
除此之外,你必须显示的通过创建内存屏障(Memory fences) 来限制指令重新排序和读写缓存对程序造成的影响。
Full fences:
最简单的完全栅栏的方法莫过于使用Thread.MemoryBarrier方法了。
以下是msdn的解释:
Thread.MemoryBarrier: 按如下方式同步内存访问:执行当前线程的处理器在对指令重新排序时,不能采用先执行 MemoryBarrier 调用之后的内存访问,再执行 MemoryBarrier 调用之前的内存访问的方式。
按照我个人的理解:就是写完数据之后,调用MemoryBarrier,数据就会立即刷新,另外在读取数据之前调用MemoryBarrier可以确保读取的数据是最新的,并且处理器对MemoryBarrier的优化小心处理。
代码如下:
int _answer;
bool _complete;
void A()
{
_answer = 123;
Thread.MemoryBarrier(); //在写完之后,创建内存栅栏
_complete = true;
Thread.MemoryBarrier();//在写完之后,创建内存栅栏
}
void B()
{
Thread.MemoryBarrier();//在读取之前,创建内存栅栏
if (_complete)
{
Thread.MemoryBarrier();//在读取之前,创建内存栅栏
Console.WriteLine(_answer);
}
}
一个完全的栅栏在现代桌面应用程序中,大于需要花费10纳秒。
下面的一些构造都隐式的生成完全栅栏。
C# Lock 语句(Monitor.Enter / Monitor.Exit)
在Interlocked类的所有方法。
使用线程池的异步回调,包括异步的委托,APM 回调,和 Task continuations.
在一个信号构造中的发送(Settings)和等待(waiting)
你不需要对每一个变量的读写都使用完全栅栏,假设你有三个answer 字段,我们仍然可以使用4个栅栏。例如:
代码如下:
int _answer1, _answer2, _answer3;
bool _complete;
void A()
{
_answer1 = 1; _answer2 = 2; _answer3 = 3;
Thread.MemoryBarrier(); //在写完之后,创建内存栅栏
_complete = true;
Thread.MemoryBarrier(); //在写完之后,创建内存栅栏
}
void B()
{
Thread.MemoryBarrier(); //在读取之前,创建内存栅栏
if (_complete)
{
Thread.MemoryBarrier(); //在读取之前,创建内存栅栏
Console.WriteLine(_answer1 + _answer2 + _answer3);
}
}
我们真的需要lock 和内存栅栏吗?
在一个共享可写的字段上不使用lock 或者栅栏 就是在自找麻烦,在msdn上有很多关于这方面的主题。
考虑下下面的代码:
代码如下:
public static void Main()
{
bool complete = false;
var t = new Thread(() =>
{
bool toggle = false;
while (!complete) toggle = !toggle;
});
t.Start();
Thread.Sleep(1000);
complete = true;
t.Join();
}
如果你在Visual Studio中选择发布模式,生成该应用程序,那么如果你直接运行应用程序,程序都不会中止。
因为CPU 寄存器把 complete 变量的值给缓存了。在寄存器中,complete永远都是false。
通过在while循环中插入Thread.MemoryBarrier,或者是在读取complete的时候加锁 都可以解决这个问题。
volatile 关键字
为_complete字段加上volatile关键字也可以解决这个问题。
volatile bool _complete.
Volatile关键字会指导编译器自动的为读写字段加屏障.以下是msdn的解释:
volatile 关键字指示一个字段可以由多个同时执行的线程修改。声明为 volatile 的字段不受编译器优化(假定由单个线程访问)的限制。这样可以确保该字段在任何时间呈现的都是最新的值。
使用volatile字段可以被总结成下表:
第一条指令 |
第二条指令 |
可以被交换吗? |
Read |
Read |
No |
Read |
Write |
No |
Write |
Write |
No(CLR会确保写和写的操作不被交换,甚至不使用volatile关键字) |
Write |
Read |
Yes! |
注意到应用volatile关键字,并不能保证写后面跟读的操作不被交换,这有可能会造成莫名其妙的问题。例如:
代码如下:
volatile int x, y;
void Test1()
{
x = 1; //Volatile write
int a = y; //Volatile Read
}
void Test2()
{
y = 1; //Volatile write
int b = x; //Volatile Read
}
如果Test1和Test2在不同的线程中并发执行,有可能a 和b 字段的值都是0,(尽管在x和y上应用了volatile 关键字)
这是一个避免使用volatile关键字的好例子,甚至假设你彻底的明白了这段代码,是不是其他在你的代码上工作的人也全部明白呢?。
在Test1 和Test2方法中使用完全栅栏或者是lock都可以解决这个问题,
还有一个不使用volatile关键字的原因是性能问题,因为每次读写都创建了内存栅栏,例如
代码如下:
volatile m_amount
m_amount = m_amount + m_amount.
Volatile 关键字不支持引用传递的参数,和局部变量。在这样的场景下,你必须使用
VolatileRead和VolatileWrite方法。例如
代码如下:
volatile int m_amount;
Boolean success =int32.TryParse(“123”,out m_amount);
//生成如下警告信息:
//cs0420:对volatile字段的引用不被视为volatile.
VolatileRead 和VolatileWrite
从技术上讲,Thread类的静态方法VolatileRead和VolatileWrite在读取一个 变量上和volatile 关键字的作用一致。
他们的实现是一样是低效率的,尽管事实上他们都创建了内存栅栏。下面是他们在integer类型上的实现。
代码如下:
public static void VolatileWrite(ref int address, int value)
{
Thread.MemoryBarrier(); address = value;
}
public static int VolatileRead(ref int address)
{
int num = address; Thread.MemoryBarrier(); return num;
}
你可以看到如果你在调用VolatileWrite之后调用VolatileRead,在中间没有栅栏会被创建,这同样会导致我们上面讲到写之后再读顺序可能变换的问题。