C# 多线程(二) 线程同步基础(上)

本系列的第一篇简单介绍了线程的概念以及对线程的一些简单的操作,从这一篇开始讲解线程同步,线程同步是多线程技术的难点。线程同步基础由以下几个部分内容组成

1、同步要领(Synchronization Essentials)

2、锁(Locking)

3、线程安全(Thread Safety)

4、事件等待句柄(Signaling with Event Wait Handles)

5、同步上下文(Synchronization Contexts)


 

同步要领(Synchronization Essentials)

线程同步可以分为四类:

1、简单的阻塞方法(Simple blocking methods,说个不恰当的比喻,所谓同步就是阻塞跑块的人让其停下来等跑慢的人,然后齐步走,这就是所谓的同步,多么和谐的画面 技术分享):主要有三个方法 Thread.SleepJoinTask.Wait

 

2、锁(Locking constructs)只允许一个线程进入临界区。常见的锁有 LockMonitor.Enter/Moitor.Exit),Mutex(互斥量)、SpinLock(自旋锁)、Semaphore(信号量)、SemaphoreSlim 和 读写锁(reader/writer locks)

 

3、信号(Signaling constructs):这种机制允许线程在收到外界通知之前处于暂停状态。主要有两种信号机制:事件等待句柄(event wait handles)和 MonitorWaitPluse 方法。.NET 4.0 引入了 CountdownEventBarrier 等类

 

4、非阻塞线程同步(Nonblocking synchronization constructs):CLR和C# 提供了以下几种非阻塞同步的方式: Thread.MemoryBarrierThread.VolatileReadThread.VolatileWrite、和 volatile 关键字以及 Interlocked 类。

 

下面一一述说这四种同步方法:

 

一、简单的阻塞方法

何谓线程阻塞:由于某种原因线程的执行被暂停的现象被称为线程阻塞。

常见的使线程阻塞方式为执行线程主动调用 Thread.Sleep 方法来阻塞自己以及通过 Join 和 EndInvoke 方法阻塞其他线程让其他线程等待本线程执行结束。一个被阻塞的线程会让出CPU资源给其他线程。

当一个线程被阻塞或者被唤醒(blocks or unblocks)时,操作系统完成上下文转换(context switch)的过程。

唤醒发生在以下4种情况:

1、阻塞条件被满足(by the blocking condition being satisfied) 原文这句觉得怪怪的

2、操作超时(如果指定了超时时间timeout)

3、通过 Thread.Interrupt 中断了

4、通过 Thread.Abort 放弃了

当线程通过Suspend方法暂停(该方法不建议使用),不认为是被阻塞了。

 

阻塞 VS 自旋 (Blocking Versus Spining)

有时我们需要某一个线程在指定条件满足前处于阻塞状态。信号(Signaling)和锁(Locking)的方式很好地满足我们的要求。但是,有一种更简单的实现:一个线程可以通过自旋(spining或翻译为空转)的方式来实现,如:

while(!proceed);  // ; 空语句
或者
while(DateTime.Now < nextStartTime);

 

上面的空语句的方式非常浪费 CPU 时间,最好改为如下的方式

while(!proceed) Thread.Sleep(10);

 

 

线程状态

我们可以通过 ThreadState 属性获取线程的状态,下图显式了线程状态的转换关系

 

 

二、锁(Locking)

锁提供了一种互斥访问的机制——只允许一个线程进入特殊的代码片段(临界区)。本节将从 lock 谈起然后讲 Mutexlock 语句相对较快(消耗资源较Mutex少)而且也更简单,但是Mutex能实现跨程序的互斥(如只允许程序的单开),这点是lock语句没法办到的。

.NET 4.0 引入了 SpinLock 结构用在高并发的情况

先看个例子:

using System;
using System.Threading;

class App
{
    static void Main()
    {
        AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException); // 全局异常捕获

        for (int i = 0; i < 100*10000; i++)
        {
            var t = new Thread(() => ThreadUnsafe.Go());
            t.Start();
        }
    }

    static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
    {        
        Exception error = (Exception)e.ExceptionObject;
        Console.WriteLine("MyHandler caught : " + error.Message);
    }
}

class ThreadUnsafe
{
    static int _val1 = 1, _val2 = 1;
    public static void Go()
    {
        if (_val2 != 0) { Console.WriteLine(_val1 / _val2); }    // 这存在除数为0的异常的风险
        _val2 = 0;
    }
}

 

运行结果

分析下这个程序,考虑有两个线程同时调用 ThreadUnsafe 类的 Go 方法,第一个把 _val2赋为0,第二个确用_val2 做除法,这就会包异常,虽然这个异常出现的概率很低,但在大量重复的情况下就很容易出现,我这例子重复100万次,这就是线程不安全的表现。

现在用 lock 语句解决这个问题

class ThreadUnsafe
{
    static readonly object _locker = new object();    // 用锁解决线程不安全的问题
    static int _val1 =1, _val2 =1;
    public static void Go()
    {
        lock(_locker)
        {
            if(_val2 != 0) {Console.WriteLine(_val1 /_val2);}    // 这就不会包除数为0的异常
            _val2 = 0;
        }
    }
}

 

一些锁的比较附图

 

Monitor.Enter 和 Monitor.Exit

C# 的 lock语句实际上用 try/finally 和 Monitor.Enter 和 Monitor.Exit 方法,我们看上面lock代码生成的IL,如下图所示,注意我用红框圈出的部分。

 

所以用Monitor解决我们刚才的问题也行,代码如下:

class ThreadUnsafe
{
    static readonly object _locker = new object();    
    static int _val1 =1, _val2 =1;
    public static void Go()
    {
        Monitor.Enter(_locker);    // 用 try/finally 和 Monitor 方式
        try
        {
            if(_val2 != 0) {Console.WriteLine(_val1 /_val2);}    
            _val2 = 0;
        }
        finally {Monitor.Exit(_locker);}
    }
}

 

其实上面的代码是有缺陷的,考虑一个情况,某一个线程执行了 Monitor.Enter(_locker) 后挂了(比如内存不足引发OutOfMemoryException异常),由于它没进入try/finally 所以它没法释放锁,由于该线程已经挂了,所以它永远不会再释放它占有的锁了,除非程序重启,这就导致了锁泄露(leaked lock)。

对于这个问题的解决办法视乎只要把 Monitor.Enter放入到 try 中就可以了,这样做就真的没有破绽了么?

 

try
{
    Monitor.Enter(_locker);    // 考虑在这一行之前线程就挂了,还是内存不足异常,Monitor还没来得及获取到锁对象
    if(_val2 != 0) {Console.WriteLine(_val1 /_val2);}    
    _val2 = 0;
}
finally {Monitor.Exit(_locker);}

 

由于程序以及进入了try语句块,那么它也一定会进入 finally 语句块,那么 执行 Monitor.Exit(_locker)的时候会出现 SynchronizationLockException 异常,这异常的含义是当前线程不拥有锁对象。

 

.NET 4.0为了结局了这个问题,引入方法

public static void Enter(Object obj,ref bool lockTaken)    获取指定对象上的排他锁,并自动设置一个值,指示是否获取了该锁,lockTaken 为true表示已经获取到锁,反之代表没获取到锁

所以最正确的写法是

bool lockTaken = false;
try
{
    Monitor.Enter(_locker,ref lockTaken);    
    if(_val2 != 0) {Console.WriteLine(_val1 /_val2);}    
    _val2 = 0;
}
finally { if(lockTaken) Monitor.Exit(_locker);}

其实在 .NET 4.0 中的lock语句最终在IL层面是翻译成这样的,现在再去打量下那幅 IL 截图的第二个红框 看到了么技术分享

 

TryEnter

Moniter 也提供了 TryEnter 方法,可以设置超时,看下MSDN截图

个人觉得红框圈出的那个最有用,在看下MSDN给出的例子,代码不言自明了吧 技术分享

bool acquiredLock = false;
  
  try
  {
      Monitor.TryEnter(lockObject, 500, ref acquiredLock);
      if (acquiredLock)
      {
  
          // Code that accesses resources that are protected by the lock.
  
      }
      else
      {
  
          // Code to deal with the fact that the lock was not acquired.
  
      }
  }
  finally
  {
      if (acquiredLock)
      {
          Monitor.Exit(lockObject);
      }
  }

 

本文完(付Windows Live Writer 的代码着色效果真差 -_-#)

 

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。