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.Sleep、Join和 Task.Wait。
2、锁(Locking constructs):只允许一个线程进入临界区。常见的锁有 Lock(Monitor.Enter/Moitor.Exit),Mutex(互斥量)、SpinLock(自旋锁)、Semaphore(信号量)、SemaphoreSlim 和 读写锁(reader/writer locks)
3、信号(Signaling constructs):这种机制允许线程在收到外界通知之前处于暂停状态。主要有两种信号机制:事件等待句柄(event wait handles)和 Monitor的Wait和Pluse 方法。.NET 4.0 引入了 CountdownEvent 和 Barrier 等类
4、非阻塞线程同步(Nonblocking synchronization constructs):CLR和C# 提供了以下几种非阻塞同步的方式: Thread.MemoryBarrier、Thread.VolatileRead 、Thread.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 谈起然后讲 Mutex。lock 语句相对较快(消耗资源较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); } }
选择同步对象(Choosing the Synchronization Object)
任何关系线程可以访问的引用类型都可以作为同步对象(实例字段和静态字段都可以)。同步对象的访问限制符通常是private(由于需要封装逻辑需要) 。看下面例子:
class ThreadSafe { List<string> _list = new List<string>(); void Test() { lock(_list) { _list.Add("Item 1"); ... } } }
一个专门的字段是常用的(如在之前使用的_locker),因为它可以精确控制锁的范围和粒度。用对象本身(this)或类的本身类型作为一个同步对象,即:
lock(this) {...} 或 lock(typeof(Widget)) {...} // For protecting access to sattics
都是不好的!
上面写法的缺陷是暴露锁的逻辑(not encapsulating the locking logic),在公共范围都能访问这些代码(指this及类型),这容易造成死锁
什么时候用锁(When to Lock)
一条基本准则——当你访问任何可写的公共变量(any writable shared field)时需要用锁。看下面的例子:
class ThreadUnsafe { static int _x; static void Increment(){ _x++;} static void Assgin() {_x=123;} }
上面是线程不安全的,下面是线程安全的
class ThreadSafe { static readonly object _locker = new object(); static int _x; static void Increment(){ lock(_locker) _x++;} static void Assign() {lock(_locker) _x = 123;} }
死锁(deadlocks)
当两个(或更多)线程等待各自锁定的资源时这发生了死锁(A deadlock hanppens when two threads each wait for a resource held by the other)看例子:
using System; using System.Threading; class App { static object _locker1 = new object(); static object _locker2 = new object(); static void Main() { new Thread(() =>{ lock(_locker1) { Thread.Sleep(1000); Console.WriteLine("视图访问锁2"); lock(_locker2){} // 死锁 } }).Start(); lock(_locker2) { Thread.Sleep(1000); Console.WriteLine("视图访问锁1"); lock(_locker1){} // 死锁 } } }
性能(Perfermance)
锁定操作(Lock 语句)本身是很快的,在没有阻塞的情况下完成锁定以及释放锁操作大概花费几十纳秒的时间,比普通lock更快一些的是 SpinLock(它避免了上下文切换的时间)。
互斥量(Mutex)
互斥量很想C# 的Lock,但是互斥量支持跨应用程序。Mutex的操作也很快,大概比Lock慢几十倍这样吧(几微秒)。 对于 Mutex而言 WaitOne 方法锁定互斥量,ReleaseMutex 释放互斥量。看例子:
using System; using System.Threading; class OnlyOneApp { // 给互斥量取一个唯一的名称 static Mutex _mutex = new Mutex(false,"oreilly.com OnlyOneApp"); static void Main() { if(!_mutex.WaitOne(TimeSpan.FromSeconds(1),false)) { Console.WriteLine("本程序只能单开,Bye!"); return; } try { Console.WriteLine("正在运行中。按任意键退出..."); Console.ReadLine(); } finally { _mutex.ReleaseMutex(); } } }
效果:
读写锁(read/write lock)
在 .NET 框架下关于读写锁有两个实现——“ReaderWriterLock”和“ReaderWriterLockSlim”,MSDN上推荐使用后者。
我们先看看 ReaderWriterLock 的基本用法
using System; using System.Threading; class App { static ReaderWriterLock _rwl = new ReaderWriterLock(); static void WriteToResource() { try { _rwl.AcquireWriterLock(1000); // 写入超时1秒 // 写操作 } finally {_rwl.ReleaseWriterLock();} } static void ReadFromResource() { try { _rwl.AcquireReaderLock(100); // 100毫秒超时 // 读操作 } finally {_rwl.ReleaseReaderLock();} } ... }
关于超时时间需要注意下
分享一个自用的封装的读写锁, 见图
使用方式:
//--------------------------------------------------------------------- // // Copyright (C) 2011-2015 Aphasia.Cnblog // // Auther : Aphasia Zheng // //--------------------------------------------------------------------- using System; using System.Threading; using Aphasia.Core.Threading; namespace Aphasia.Test { internal class Program { private static int _res; private static IMutex _mutex; private static readonly Random _rnd = new Random(); private static void Main(string[] args) { _mutex = new ReaderWriterMutex(null); var t1 = new Thread(Method1); var t2 = new Thread(Method2); t1.Start(); t2.Start(); //var t3 = new Thread(MethodV1); //var t4 = new Thread(MethodV2); //t3.Start(); //t4.Start(); Console.Read(); } #region[演示不带超时异常] private static void Method1() { for (int i = 0; i < 10; i++) { var t = new Thread(() => { using (var rlock = new AutoReaderLock(_mutex)) { try { Thread.Sleep(20); Console.WriteLine("读 {0}", _res); } catch (Exception ex) { Console.WriteLine(ex.Message); throw; } } }); t.Start(); } } private static void Method2() { for (int i = 0; i < 10; i++) { var t = new Thread(() => { using (var rlock = new AutoWriterLock(_mutex)) { try { Thread.Sleep(20); _res = _rnd.Next(); Console.WriteLine("写 {0}", _res); } catch (Exception ex) { Console.WriteLine(ex.Message); throw; } } }); t.Start(); } } #endregion #region[演示带超时的写法] private static void MethodV1() { try { using (var rlock = new AutoWriterLock(_mutex)) { try { Thread.Sleep(20 *1000); Console.WriteLine("读 {0}", _res); } catch (Exception ex) { Console.WriteLine(ex.Message); throw; } } } catch (Exception ex) { Console.WriteLine("超时异常 {0}",ex.Message); throw; } } private static void MethodV2() { try { using (var rlock = new AutoWriterLock(_mutex, 10)) { try { Thread.Sleep(1000); Console.WriteLine("读 {0}", _res); } catch (Exception ex) { Console.WriteLine(ex.Message); throw; } } } catch (Exception ex) { Console.WriteLine("超时异常 {0}", ex.Message); throw; } } #endregion } }
线程安全(Thread Safety)
1
2
3
事件等待句柄(Signaling with Event Wait Handles)
1
2
3
同步上下文(Synchronization Contexts)
1
2
3
未完