线程同步手记
一、前言
线程同步其实很简单,但是往往被老师教的很复杂。这是之前上课受的伤。脑袋瓜当人人家的跑马场,被蹂躏一番,最后老师留下的是先入为主的错误,以至于后面不停的干扰我的理解,纠起错来,真是不知道浪费了多少精力。
二、什么是线程同步
一直想要找一个良好的方式来表达什么是线程同步。
先看一个模拟线程同步的图:
假如这个盒子一次只能放一个东西,并且接力赛又要保持顺畅,该是怎样的情景?
首先对于Reader来说,取货的时候,箱子必须有货,如果没有货,要在旁边等候;
其次对于Writer来说,存货的时候,箱子必须为空,如果不为空,也要在旁边等候;
两个人要步调一致,并且配合默契,才能顺利的搬运东西。反过来,如果Reader执行了好几次,Writer才执行一次,或者Write执行了好几次,Reader才执行一次,最后都不能很好的保持步调的一致。
把两个人看成是两个线程,这时候线程要同步,也必须要满足上面的要求。两个线程要协同一致的工作,才能完成一项任务。
三、代码演示:
public class ThreadSyn { //缓存区,假设一次只能缓存一个字符 private static char buffer; //线程1:写操作 public Thread thread1 = new Thread(()=> { string str = "横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。"; for (int i = 0; i < 32; i++) { buffer = str[i]; Thread.Sleep(26); } }); //线程2:读操作 public Thread thread2 = new Thread(() => { for (int i = 0; i < 32; i++) { char ch = buffer; Console.WriteLine(ch); Thread.Sleep(36); } }); } public class Program { static void Main(string[] args) { ThreadSyn threadSyn=new ThreadSyn(); threadSyn.thread1.Start(); threadSyn.thread2.Start(); Console.Read(); } }
运行效果图:
四、原因和方案:
此时线程还是没有协同工作。因为如果写一个,读一个,再写一个,再读一个,那么这首诗应该是一首完整的显示。但是效果图的诗句却是紊乱的。
如何才能解决真正的同步,.net为我们提供了一系列的同步类。包括:互锁(Interlocked),管程(Monitor)和互斥体(Mutex).
4.1下面用互锁来解决上面的问题。
public class ThreadSyn { //缓存区 private static char _buffer; //标示盒子,即缓冲区使用的空间,盒子初始化为0 private static long _box = 0; //线程1:写操作 public Thread thread1 = new Thread(()=> { string str = "横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。"; for (int i = 0; i < 32; i++) { //写入之前检查缓冲区 //如果缓冲区已满,就进行等待,直到缓冲区的数据被进程读取为止 while(Interlocked.Read(ref _box) == 1) { Thread.Sleep(10); } //向缓冲区写数据 _buffer = str[i]; //写完数据,标记缓冲区已满 Interlocked.Increment(ref _box); } }); //线程2:读操作 public Thread thread2 = new Thread(() => { for (int j = 0; j < 32; j++) { //写入之前检查缓冲区 //如果缓冲区为空,就进行等待,直到缓冲区的数据被进程填充为止 while(Interlocked.Read(ref _box) == 0) { Thread.Sleep(10); } //向缓冲区读数据 char ch = _buffer; Console.Write(ch); //读完数据,标记缓冲区已空 Interlocked.Decrement(ref _box); } }); }
运行效果图:
InterLocked提供了单个指令的操作,因此他提供了性能非常高的同步。
4.2用Monitor来解决问题
Monitor的原理是这样的:先执行的线程,独占锁,进入临界区,执行临界区资源代码。其他线程,只能在集中在临界资源上等待被叫唤。当独占锁推出资源区,也可以继续让自己等待,等待下一次被叫唤。
//缓存区 private static char _buffer; //用于同步的对象 private static object _objForLock = new object(); //线程1:写操作 public Thread thread1 = new Thread(() => { string str = "横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。"; for (int i = 0; i < 32; i++) { try { //进入临界区,获取独占锁 Monitor.Enter(_objForLock); //向缓冲区写数据 _buffer = str[i]; //写完后,唤醒在临界资源上睡眠的线程 Monitor.Pulse(_objForLock); //让当前线程睡眠在临界资源上 Monitor.Wait(_objForLock); //整个流程有点像轮班吃饭, //第一个人先去吃饭,第二个在值班等待,第一个吃完了,唤醒第二个吃饭,自己则在等待下一次吃饭。 } catch (ThreadInterruptedException ex) { Console.WriteLine("线程被中断……"); } finally { //退出临界区 Monitor.Exit(_objForLock); } } }); //线程2:读操作 public Thread thread2 = new Thread(() => { for (int j = 0; j < 32; j++) { try { //进入临界区,获取独占锁 Monitor.Enter(_objForLock); //向缓冲区读数据 char ch = _buffer; Console.Write(ch); //写完后,唤醒在临界资源上睡眠的线程 Monitor.Pulse(_objForLock); //让当前线程睡眠在临界资源上 Monitor.Wait(_objForLock); } catch (ThreadInterruptedException ex) { Console.WriteLine("线程被中断……"); } finally { //退出临界区 Monitor.Exit(_objForLock); } } });
不同的是Monitor只能锁定引用类型的对象,值类型会被装箱,等于生成另外一个对象,不能达到同步。为了保证推出临界区资源得到释放,使用了finally。为了方便使用,C#专门使用了lock语句。
所以我们可以完全更简洁的重写上面的try{}finally{}中的关键代码,如下所示:
lock (_objForLock) { //进入临界区,获取独占锁 Monitor.Enter(_objForLock); //向缓冲区写数据 _buffer = str[i]; //写完后,唤醒在临界资源上睡眠的线程 Monitor.Pulse(_objForLock); //让当前线程睡眠在临界资源上 Monitor.Wait(_objForLock); }
独占锁注意:
因为独占锁,其他线程就不能再访问,只有Lock结束后,其他线程才可以访问,这保证了访问的正确性。但是,如果有多个线程对同一个资源进行写操作,在独占锁解开前,其他线程只能被临时暂停,这使得程序的效率大打折扣。所以应该慎用锁,只有必要时才使用。