这一篇接着上一篇来继续学习多线程。
线程同步
在大多数情况下,计算机中的线程会并发运行。有些线程之间没有联系,独立运行,像这种线程我们称为无关线程。但也有一些线程,之间需要传递结果,需要共享资源。像这种线程,我们称为有关线程。比如,我们网上观看电影,一个线程负责下载电影,一个线程负责播放电影。它们只有共同合作我们才能观看到电影,它们之间共享资源。由此,我们可以看出,线程的相关性体现在对同一资源的访问上。我们把这种供多个线程访问的资源成为临界源(Critical Resource)、访问临界源的代码称为临界区(Critical Region)。我们看个程序:
//缓冲区,只能容纳一个字符 private static char buffer; static void Main(string[] args) { //线程:写者 Thread writer = new Thread(delegate() { string sentence = "无可奈何花落去,似曾相识燕归来,小园香径独徘徊。"; for (int i = 0; i < 24; i++) { buffer = sentence[i]; //向缓冲区写入字符 Thread.Sleep(25); } }); //线程:读者 Thread Reader = new Thread(delegate() { for (int i = 0; i < 24; i++) { char ch = buffer; //从缓存区读取数据 Console.Write(ch); Thread.Sleep(25); } }); //启动线程 writer.Start(); Reader.Start(); } }
我们创建两个线程,一个Writer线程负责向缓存区写入字符,一个Reader线程负责从缓存区读取字符。我们假设,缓存区一次只能存放一个字符。也就说,如果Reader不能及时从缓存区读取字符,那么就会被Writer下次要写入的字符覆盖掉。我们来看一下程序的运行效果,如下图:
出现了混乱,为什么会这样呢?原因很简单,因为Writer向缓存中写入字符,Reader要马上去读取,Writer、Reader步调一致,才能得出完整的效果。但是,线程往往是交替执行的,不能确保时间的一致。像这种需要两个线程协同合作才能完成一项任务的情况叫做线程同步(Synchronization)。如何才能确保两个线程的同步呢?C#为我们提供了一系列的同步类.
互锁(Interlocked类)
看程序:
//缓冲区,只能容纳一个字符 private static char buffer; //标识量(缓冲区中已使用的空间,初始值为0) private static long numberofUsedSpace = 0;
static void Main(string[] args) { string sentence = "无可奈何花落去,似曾相识燕归来,小园香径独徘徊。"; //线程:写者 Thread writer = new Thread(delegate() { for (int i = 0; i < 24; i++) { //写入程序前检查缓冲区中是否已满 //如果已满,就进行等待。如果未满,就写入字符. while (Interlocked.Read(ref numberofUsedSpace) == 1) { Thread.Sleep(10); } buffer = sentence[i]; //向缓冲区写入字符 Thread.Sleep(25); //写入数据后,将numberofUsedSpace由0变1 Interlocked.Increment(ref numberofUsedSpace); } }); //线程:读者 Thread Reader = new Thread(delegate() { for (int i = 0; i < 24; i++) { //读取之前检查缓冲区是否已满 //如果已满,进行读取,如果未满,进行等待。 while (Interlocked.Read(ref numberofUsedSpace) == 0) { Thread.Sleep(25); } char ch = buffer; //从缓存区读取数据 Console.Write(ch); //读取完字符,将numberofUsedSpace由1设为0 Interlocked.Decrement(ref numberofUsedSpace); } }); //启动线程 writer.Start(); Reader.Start(); }
我们通过一个numerofUsedSpace的变量作为计数器,假设numberofUsedSpace=1已满,numberofUsedSpace=0未满。每当Writer线程向缓存区写入字符时,需要通过Interlocked的Read方法来检查numberofUsedSpace是否已满。如果未满,吸入字符,如果已满,进行等待。同样,当Read线程需要向缓冲区读取字符时,也是通过Interlocked的Rread方法来检查numberofUsedSpace是否已满,已满,进行读取,未满进行等待。
管程(Monitor类)
另一种实现线程同步的方法,是通过Monitor类。看程序:
//缓冲区,只能容纳一个字符 private static char buffer; //用于同步的对象(独占锁) private static object lockForBuffer = new object(); static void Main(string[] args) { //线程:写者 Thread writer = new Thread(delegate() { string sentence = "无可奈何花落去,似曾相识燕归来,小园香径独徘徊。"; for (int i = 0; i < 24; i++) { try { //进入临界区 Monitor.Enter(lockForBuffer); buffer = sentence[i]; //向缓冲区写入字符 //唤醒睡眠在临界资源上的线程 Monitor.Pulse(lockForBuffer); //让当前的线程睡眠在临界资源上 Monitor.Wait(lockForBuffer); } catch (ThreadInterruptedException) { Console.WriteLine("线程writer被中止"); } finally { //推出临界区 Monitor.Exit(lockForBuffer); } } }); //线程:读者 Thread Reader = new Thread(delegate() { for (int i = 0; i < 24; i++) { try { //进入临界区 Monitor.Enter(lockForBuffer); char ch = buffer; //从缓存区读取数据 Console.Write(ch); //唤醒睡眠在临界资源上的线程 Monitor.Pulse(lockForBuffer); //让当前线程睡眠在临界资源上 Monitor.Wait(lockForBuffer); } catch (ThreadInterruptedException) { Console.WriteLine("线程reader被中止"); } finally { //退出临界区 Monitor.Exit(lockForBuffer); } } }); //启动线程 writer.Start(); Reader.Start(); }
当线程进入临界区,会调用Monitor的Entry方法来获取独占锁,如果得到,就进行操作,如果被别的线程占用,就睡眠在临界资源上,直到独占锁被释放。如果此时,别的线程进入临界区,会发现独占锁被占用,他们会睡眠在临界资源上。Monitor会记录有哪些线程睡眠在临界资源上,当线程执行完操作,调用Pulse()方法,唤醒睡眠在临界资源上的线程。因为,线程还需要下次操作,所以需要调用Wait()方法,令自己睡眠在临界资源上。最后通过调用Exit()方法释放独占锁。
Note that:Monitor只能锁定引用类型的变量,如果使用值类型变量。每调用一次Entry()方法,就进行一次装箱操作,每进行一次装箱操作就会得到一个新的object对象。相同的操作执行在不同的对象上,得不得同步的效果。为了确保退出临界区时临界资源得到释放,我们应把Monitor类的代码放入Try语句,把调用Exit()方法放入finally语句。为了方便,C#为我们提供了更加简洁的语句。
lock(要锁定的对象) { //临界区的代码 。。。。。 。。。。。。 }
lock语句执行完,会自动调用Exit()方法,来释放资源。它完全等价于:
try { Monitor.Entry(要锁定的对象); //临界区代码 。 。。。。。 。。。。。。 。。。。。。 } finally { Monitor.Exit(要锁定的对象); }
当线程以独占锁的方式去访问资源时,其他线程是不能访问的。只有当lock语句结束后其他线程才可以访问。从某种方面可以说,lock语句相当于暂定了程序的多线程功能。这就相当于在资源上放了一把锁,其他线程只能暂定,这样会使程序的效率大打折扣。所以只有必要时才可以设置独占锁。(我们回想一下Interlocked,当通过Interlocked的Read()方法来读取计数器,如果不符合条件,就会等待,线程状态变为SleepWaitJoin状态。但是,Monitor的Entry()方法获取独占锁,如果得不到,线程会被中止,状态会变为Stopped。这是二者的一点区别)。
互斥体(Mutex类)
在操作系统中,线程往往需要共享资源,而这些资源往往要求排他性的使用。即一次只能由一个线程使用,这种排他性的使用资源称为线程之间的互斥(Mutual Exclusion)。互斥,从某种角度也起到了线程同步的目的,所以互斥是一种特殊的同步。与Monitor类似,只有获得Mutex对象的所属权的线程才可以进入临界区,没有获得所属权的只能在临界区外等候。使用Mutex要比使用Monitor消耗资源。但它可以在系统中的不同程序间实现线程同步。
互斥分为局部互斥、系统互斥。顾名思义,局部互斥,只在创建的程序中有效。系统互斥,会在整个系统中有效。
看程序:
static void Main(string[] args) { Thread threadA = new Thread(delegate() { //创建互斥体 Mutex fileMutex = new Mutex(false, "MutexForTimeRecordFile"); string fileName = @"E:\TimeRecord.txt"; for (int i = 1; i <= 10; i++) { try { //请求互斥体的所属权,若成功,则进入临界区,若不成功,则等待 fileMutex.WaitOne(); //在临界区中操作临界资源,即向文件中写入数据 File.AppendAllText(fileName, "threadA: " + DateTime.Now + "\r\n"); } catch (ThreadInterruptedException) { Console.WriteLine("线程A被中断。"); } finally { fileMutex.ReleaseMutex(); //释放互斥体的所属权 } Thread.Sleep(1000); } }); threadA.Start(); }
static void Main(string[] args) { Thread threadB = new Thread(delegate() { //创建互斥体 Mutex fileMutex = new Mutex(false, "MutexForTimeRecordFile"); string fileName = @"E:\TimeRecord.txt"; for (int i = 1; i <= 10; i++) { try { //请求互斥体的所属权,若成功,则进入临界区,若不成功,则等待。 fileMutex.WaitOne(); //在临界区中操作临界资源,即向文件中写入数据 File.AppendAllText(fileName, "ThreadB: " + DateTime.Now + "\r\n"); } catch (ThreadInterruptedException) { Console.WriteLine("线程B被中断."); } finally { fileMutex.ReleaseMutex(); //释放互斥体的所属权 } Thread.Sleep(1000); } }); threadB.Start(); Process.Start("MutecA.exe"); //启动程序MutexA.exe }
这是两个程序,我们创建了一个系统互斥体(“MutexForTimeRecordFile”,在整个系统中有效,可以跨程序)。在程序B中有执行程序A的代码,通过编译,我们把两个编译后的可执行文件放在一起,执行B。效果如下图:
通过效果图,我们可以看出,两个不同的程序通过相同的系统互斥体名,达到了跨程序线程同步的效果。
我们来总结一下C#为我们带来的这三种实现多线程同步的类。
1.Interlocked类
是通过调用Read()方法,来读取计数器,判断计数器,以此达到线程同步的目的。
2.Monitor类
通过调用Entry()方法来获取独占锁,当执行完代码,要调用Pulse()方法唤醒睡眠在临界资源上的线程,同时自己调用Wait()方法,睡眠在临界资源,以便下次访问临界区。Monitor与Interlocked的不同点是,当调用Monitor.Entry()方法,未获得独占锁时,线程状态会变为Stopped。而Interlocked正是将线程处于SleepWaitJoin状态。
3.Mutex类
调用Mutex的WaitOne()方法来获取所属权,获得所属权的线程可以进入临界区,没有所属权的线程在临界区外等待。Mutex对象比Monitor对象消耗资源,但是Mutex对象可以实现跨程序的线程同步。
Mutex分为局部互斥、系统互斥。