C#多线程编程(5)--线程安全1 易变构造

当你需要2个线程读写同一个数据时,就需要数据同步。线程同步的办法有:(1)原子操作;(2)锁。原子操作能够保证该操作在CPU内核中不会被“拆分”,锁能够保证只有一个线程访问该数据,其他线程在尝试获得有锁的数据时,会被拒绝,直到当前获得数据的线程将锁释放,其他线程才能够获得数据。

  • 为什么要线程同步?

  我们先看一个需要数据同步的例子,

static void Main(string[] args){
    bool flag = false;
    var t1 = new Thread(() => { if (flag) Console.WriteLine("Flag"); });
    var t2 = new Thread(() => { flag = true; });
  t1.Start();
  t2.Start(); Console.ReadLine(); }

  上述例子中,t2线程将flag置为true,有可能发生:当t2打算执行flag = true时,t1执行了if(flag)语句,这造成了不可知的情况。此时就需要在t2执行时,若t1想要获取flag的值,要等到flag=true执行完成后,再执行,这就是所谓的“线程同步”,一个线程要等待另一个线程执行到某段代码后,再执行。线程同步能保证程序的执行符合“预想”--若t2没有执行,则flag为false,t2若已执行,则flag=true。线程同步是为了防止t2正在执行flag=true的时候,t1开始执行,此时flag应该是true,因为t2已经开始执行了,但是实际上flag=false,因为t2的flag=true没有执行完。解决的办法就是当t2执行flag=true时,将任何尝试读取flag的线程都阻塞,直到flag=true执行结束后,其他线程再执行。类似下面的代码。

var m_lock = GetSomeLock();
pulick void Go(){
  var t1 = new Thread(()=>Go1());
  var t2 = new Thread(()=>Go2());
  t1.Star();
  t2.Start();
}
public void Go1(){ m_lock.lock(); if (flag) //dosomething;   Console.WriteLine(flag);
   m_lock.Unlock(); }
public void Go2(){ m_lock.lock(); flag = true; m_lock.Unlock(); }

在flag=true和if(flag)外面添加m_lock.lock()和m_lock.Unlock()就是为了保证线程同步。但是这样的同步带来的问题就是性能的下降,还有可能造成死锁。摘要中说过,线程同步有2个手段,上面介绍了锁,还有原子操作我没有介绍。在介绍原子操作之前,我介绍下关键字volatile。

  • 关键字volatile

  该关键字能够作用在变量前,其意义是对该变量的读写操作都是原子操作,这种特性被称作“易变性”。

  编译器在编译过程中,会根据代码的具体情况进行适当“优化”,例如:

public void Go(){
    int value = 100 * 1 - 50 * 2;
    for (int i = 0; i < value; i++)
        Console.WriteLine(i);
}

编译器在看到有地方调用该方法,会跳过其中的语句,因为这段语句毫无意义,这当然是好的,编译器弥补了我们的错误。但是有的时候这种优化会造成我们不想要的效果。

private static bool s_stopWorker = false;
static void Main(string[] args){
    Console.WriteLine("Main:letting worker run for 5s");
    var t = new Thread(Worker);
    t.Start();
    Thread.Sleep(5000);
    s_stopWorker = true;
    Console.WriteLine("Main: waiting for worker to stop.");
    t.Join();
}
private static void Worker(object o){
    int x = 0;
    while (s_stopWorker) x++;
    Console.WriteLine("Worker: stopped when x = {0}", x);
}

该段代码中,主线程阻塞5秒,然后s_stopWorker=true,本意是要中断t线程,让其显示数到的数后返回。但实际上编译器在看到while(s_stopWorker)时,又看到s_stopWorker在Worker方法中没有任何改变,因此该方法中对s_stopWorker的判断只会在最开始判断一次,若s_stopWorker=true,则进入死循环,若是false,则显示Worker stopped when x = 0之后该线程就返回了。若想实际看到运行效果,需要将改短代码放在.cs文件中,利用命令行编译该段代码。利用命令行编译代码要添加环境变量,变量的路径是C:\Windows\Microsoft.NET\Framework\v4.0.30319。然后就可以在命令行中编译该文件,注意要打开/platform:x86,其意义在《CLR via C#》29章中有解释,x86编译器比x64编译器更成熟,优化也更大胆。在命令行中输入 csc /platform:x86  你的cs文件的路径,之后在输入Program.exe(假设你的文件名字叫Program.cs),之后你会看到程序一直卡死在Main: waiting for worker to stop.之后一直没有出现数到的数字。

  下面来讨论如何解决这个问题。在System.Threading.Volatile中提供了2个静态方法,

public static class Volatile{
    public static bool Read(ref bool location);
    public static bool Write(ref bool location,  bool value);
}

这两个方法能够阻止编译器对读和写进行优化,修改后的代码如下:

private static bool s_stopWorker = false;
static void Main(string[] args){
    Console.WriteLine("Main:letting worker run for 5s");
    var t = new Thread(Worker);
    t.Start();
    Thread.Sleep(5000);
    //防止优化
    Volatile.Write(ref s_stopWorker, true);
    Console.WriteLine("Main: waiting for worker to stop.");
    t.Join();
    Console.Read();
}
private static void Worker(object o){
    int x = 0;
    //防止优化
    while (Volatile.Read(ref s_stopWorker)) x++;
    Console.WriteLine("Worker: stopped when x = {0}", x);
}

在s_stopWorker的读写处,都改用了Volatile类中的Read和Write方法。再次利用命令行编译该代码,会发现运行正常。很多时候我们搞不清到底该什么时候调用Volatile中的读写,什么时候该正常读写,于是C#提供了volatile关键字,该关键字能够保证对该变量的读写都是原子的,并且能够阻止对该方法进行优化。由于为了提高CPU的运行效率,现在的程序都是乱序执行,但是volatile能够保证该关键字之前的代码会在该关键字的变量读写时已经执行完成,该关键字修饰的变量以后的代码一定会在之后执行,而不会因乱序优化而在之前执行。我们去掉Volatile.Write和Read,然后将s_stopWorker前加上volatile关键字,运行上述代码,会发现结果正确。

  volatile关键字能够保证变量的线程安全,但是其缺点也是很明显的,将变量的每次读写都变成易变的读写,是对性能的浪费,因为这种情况极少发生。

volatile int m = 5;
m=m+m;//volatile会阻止优化

通常,将一个变量增大一倍,只需要将该变量左移一位,就可以,但是volatile会阻止该优化。CPU会将m读入一个寄存器,然后读入另一个寄存器,然后在执行add,再将结果写入m。如果m不是int类型,而是更大的类型,则造成更大的浪费,如果在循环中,那真是杯具。

另外C#不支持将有volatile修饰的变量以引用的形式传入方法,如Int32.TryParse("123", m);会得到一个警告,对volatile字段的引用将不被视为volatile。

  • 变量捕获(闭包)

  第一段代码中,flag变量被lamda表达式包含。程序并没有在主线程中执行,而是在t1和t2中执行,该变量已经脱离了它的作用域,为了保证flag变量能够生效,编译器负责延长flag的生命周期,以保证在t1和t2线程执行时,该变量能够被访问,这就是变量捕获,也叫“闭包”,可以利用IL反编译器查看上述代码的IL指令来验证。

  上图可以看到为了保证flag的生命周期编译器将2个lamda表达式(b_0和b_1)和flag用一个类包了起来,这样这3个的生命周期就一致了。这很好,因为不需要我们去关心在t1和t2获取flag值时,flag是否有效,编译器已经帮我们全做了。

  本文讲了线程安全的必要性以及线程安全的手段之一:volatile(易变性),还简单介绍了变量捕获。线程安全的内容还没讲完,预计分3-4篇博客来讲线程安全。欢迎小伙伴在评论区与我交流。

posted @ 2018-03-11 23:35  JAZzzzzzzz  阅读(1187)  评论(1编辑  收藏  举报