目录
- 基础知识
- 线程同步的问题
- 地址对齐
- 基元线程同步构造
- 用户模式
- 易失构造
- 互锁构造
- 用户模式
- 用户模式总结
1 基础知识
1.1 线程同步的目的
线程同步的目的是保证数据的准确性。因此线程同步也可以称之为“如何在多线程下保证数据的准确性”
1.2 线程同步存在的问题
若要实现线程同步,程序可能会有如下的问题:编程过程比较复杂;会降低性能;每次只允许一个线程访问数据(这也是线程同步的核心)。但这样会造成线程阻塞,导致创建更多的线程,从而又降低了性能。
因此尽量不要使用线程同步,在《CLR via C#》中介绍了几个尽量遵循的原则:1、尽量避免使用一些共享数据;2、尽量使用值类型。因为值类型传递的是其拷贝的副本;3、多线程在对共享数据只是只读时,则完全可以放心线程同步的问题。
1.3 微软的在线程同步的做法
微软FCL有一原则:所有的静态方法都是线程安全的;所有的实例方法都是非线程安全的。在这儿有两个点:1、微软如何做到所有的静态方法都是线程安全的?2、什么是线程安全?说一下个人的理解:使用只读的字段;在静态类内部使用同步锁。关于线程安全,是指对于
两个线程在试图同时访问数据时,数据不会被破坏——《CLR via C#》第三版P705
2 基元线程同步模式构造
先说一下“基元”的概念
是指在代码中使用的最简单的构造——《CLR via C#》第三版P706
说简单点就是,能够实现功能的最简单的代码片段。因此在《CLR via C#》中的基元用户模式构造和基元内核模式构造就是指能够实现线程同步的最简单的两种构造类型。
说一下用户模式和内核模式的个人理解。线程同步的本质就是每次只允许一次线程访问数据。但是如何做到“每次只允许一个线程”呢?有两种方式,第一种在硬件层面,例如CPU内部增加一个“等待区”,其作用就是使一个线程一直在等待(此线程会一直占用CPU和内存资源,只是CPU不处理它而已),直到CPU有时间调用它为止。这样可以保证“每次只允许一个线程访问数据”。这种以硬件方式实现的线程同步就称之为“用户模式”。用户模式的本质就是代码生成特殊的CPU指令,接到到特殊指令的CPU使其中一个线程处于“等待”状态,从而实现线程同步功能。用户模式有一个优点:CLR并不认为被CPU“等待”的线程处于阻塞状态,因此CLR不会创建新的线程来完成。缺点:若一个比较低阶的线程处于用户模式下,而CPU又忙于处理其他优先级较高的线程,则被“等待”的线程就是一直占用CPU的资源。
内核模式
内核模式是指window内核将线程“暂停”,使线程不占用CPU。即用户模式和内核模式是从“使线程暂停的位置节点”上区分的。若是硬件使线程暂停,则称之为用户模式;若是window内核使线程暂停,则称之为“内核模式”。
从CLR到硬件,分了三层:CLR、Window、硬件。按常理,可以使线程暂停的除了硬件和window内核之外,还应该有个CLR,所以应该还有个“CLR模式”之类的。但是CLR与window的线程时一一对应的,因此就没有了所谓的“CLR模式”。假以时日,一个window线程可以分时处理多个CLR线程时,CLR就会有“CLR模式了”。
用户模式
在用户模式中分为:易失构造(volatitle construct)和互锁构造(interlocked construct)。说一下理解:
前面提到线程同步的核心是“每次只允许一个线程访问数据”,因此在用户模式下(即硬件层面下),硬件必须保证只能允许一个线程访问数据。而为了能够保证读取的数据是准确且高效的,需要保证数据是地址对齐的。而当前硬件层面上保证的数据一定是简单的数据(否则,光用于同步的数据就占用大量的内存)。另外,地址对齐的另一个作用就是,保证硬件访问数据时,是原子性的。
因此在“每次只允许一个线程访问数据”这句话后面就包含了:1、数据是简单类型的;2、数据的地址是保证对齐的。而在硬件访问数据时,根据访问数据时,是“读或写“还是“读和写”分了刚才提到的易失构造还是互锁构造。
易失构造
易失构造,它在包含一个简单数据类型的变量上执行原子性的读或写操作——《CLR via C#》P707
为了解释清楚易失构造,作者讲述了一个例子
internal sealed class ThreadSharingData{
private int m_flag=0;
private int m_value=0;
//线程1执行本方法,完成赋值操作
public void Thread1(){
m_value=5;
m_flag=1;
}
//线程2执行本方法,完成查询及数据展示功能
public void Thread2(){
if(m_flag==1)
Console.WriteLine(m_value);
}
}
在这个方法中就会存在输出结果为0的情况。
情况1:Thread1方法内部的赋值语句可以不按照编写顺序赋值,而是先将m_flag=1
赋值,恰巧Thread2读取m_flag
及m_value
的值。因此数据0。
情况2:Thread1方法内部的赋值语句按照编写顺序赋值,Thread2线程在执行时,需要将变量从RAM中存放至寄存器中,有可能先将m_value
读取(此时m_value=0
),然后Thread1完成了m_value=5
和m_flag=1
赋值,Thread2又进行了判断if(m_flag==1)
。因此输出的结果恰好是0。
而,以上的情况就暴露了线程同步时出现的问题。为了解决这个问题,System.Threading.Thread类提供了三个静态的方法:
public sealed class Thread{
public static void VolatileWrite(ref int address, int value);
public static int VolatileRead(ref int address);
public static void MemoryBarrier();
}
这三个方法的说明如下:
VolatileWrite:Writes a value to a field immediately, so that the value is visible to all processors in the computer.
VolatileRead:Reads the value of a field. The value is the latest written by any processor in a computer, regardless of the number of processors or the state of processor cache.
MemoryBarrier:Synchronizes memory access as follows: The processor executing the current thread cannot reorder instructions in such a way that memory accesses prior to the call to MemoryBarrier execute after memory accesses that follow the call to MemoryBarrier
对于第一个、第二个方法可以用一个简单的规则:当线程通过共享内存相互通信时,调用VolatileWrite来写入最后一个值;调用VolatileRead来读取第一个值。因此,使用VolatileWrite与VolatileRead方法之后的例子如下:
internal sealed class ThreadSharingData{
private int m_flag=0;
private int m_value=0;
//线程1执行本方法,完成赋值操作
public void Thread1(){
m_value=5;
Thread.VolatileWrite(m_flag,1);
}
//线程2执行本方法,完成查询及数据展示功能
public void Thread2(){
if(Thread.VolatileRead(m_flag)==1)
Console.WriteLine(m_value);
}
}
这样就能保证读取数据的准确性。
//线程1执行本方法,完成赋值操作
public void Thread1(){
m_value=5;
Thread.VolatileWrite(m_flag,1);
}
上面的这段代码,在《CLR via C#》(第三版)P711中说,
对于Thread1方法,VolatileWrite调用确保在它之前的所有写入操作都在将1写入m_flag之前完成。
但是在MSDN的解释中:
VolatileWrite:Writes a value to a field immediately, so that the value is visible to all processors in the computer.
但并没有说,其能够保证之前的数据一定会写入。在MSDN的额外说明:
On a multiprocessor system, VolatileWrite ensures that a value written to a memory location is immediately visible to all processors. This might require flushing processor caches.
也就是说,VolatileWrite或许会flush处理器的缓存(在本例就是,会执行之前的赋值语句)。若《CLR via C#》的作者是正确的,则这句话就是这么理解的:若处理器的缓存中有内容,就会flush其缓存。若没有缓存,这个“行为”就不会发生。暂且相信这种说法吧。
在《CLR via C#》中:
VolatileRead调用之后的读取肯能被优化成任何顺序执行;唯一必须满足的条件就是所有这些数据的读取都必须调用了VolatileRead之后发生。
想想MSDN的定义,这个倒是挺符合的。
最后一点:
To provide effective synchronization for a field, all access to the field must use VolatileRead or VolatileWrite.
为了保证同步,若对某个字段使用了VolatileRead 或VolatileWrite方法,则对本字段的任何访问(读、写)都必须使用VolatileRead或VolatileWrite方法
C#中对易失构造的支持
C#中除了使用VolatileRead和VolatileWrite方法之外,C#对易失构造的支持就是特殊关键字:volatile。可以将关键字用于byte、int16、int32(以上三个为带符号和不带符号的)、char、single(C#中的float)、bool、引用类型字段、以及其基础为byte、int16、int32(以上三个为带符号和不带符号的)、char、single、bool的任何枚举类型的字段。还有文件的句柄类型等,详细如下:
注意:
1、这儿的所有的类型都是32位的,因为64位的数据不能保证原子性读取
2、C#中提供了lock关键字,与lock相比,MSDN的解释比较给力
The volatile modifier is usually used for a field that is accessed by multiple threads without using the lock statement to serialize access.
对于Volatile关键字,《CLR via C#》的作者是不喜欢的。原因如下:
Volatile关键字会告诉编译器,不要讲字段缓存到内存中,确保该字段的读取都是在RAM中。因此会导致如下的问题:
1、x+=x;
2、倍增一个整数时
以上会导致效率低下,因此作者不建议使用,而是使用Thread的VolatileRead和VolatileWrite方法
本以为能写完易失构造和互锁构造的,看来需要写在下一篇写互锁构造了,要不自己回顾时,会因为太长而不耐烦。