C# 多线程编程中的线程同步
3.1 理解同步块和同步块索引
同步块是.NET中解决对象同步问题的基本机制,该机制为每个堆内的对象(即引用类型对象实例)分配一个同步索引,该索引中只保存一个表明数组内索引的整数。具体过程是:.NET在加载时就会新建一个同步块数组,当某个对象需要被同步时,.NET会为其分配一个同步块,并且把该同步块在同步块数组中的索引加入该对象的同步块索引中。下图展现了这一机制的实现:
同步块机制包含以下几点:
① 在.NET被加载时初始化同步块数组;
② 每一个被分配在堆上的对象都会包含两个额外的字段,其中一个存储类型指针,而另外一个就是同步块索引,初始时被赋值为-1;
③ 当一个线程试图使用该对象进入同步时,会检查该对象的同步索引:
如果同步索引为负数,则会在同步块数组中新建一个同步块,并且将该同步块的索引值写入该对象的同步索引中;
如果同步索引不为负数,则找到该对象的同步块并检查是否有其他线程在使用该同步块,如果有则进入等待状态,如果没有则申明使用该同步块;
④ 当一个对象退出同步时,该对象的同步索引被修改为-1,并且相应的同步块数组中的同步块被视为不再使用。
3.2 C#中的lock关键字有啥作用?
lock关键字可能是我们在遇到线程同步的需求时最常用的方式,但lock只是一个语法糖,为什么这么说呢,下面慢慢道来。
(1)lock的等效代码其实是Monitor类的Enter和Exit两个方法
private object locker = new object(); public void Work() { lock (locker) { // 做一些需要线程同步的工作 } }
事实上,lock关键字时一个方便程序员使用的语法糖,它等效于安全地使用System.Threading.Monitor类型,它直接等效于下面的代码:
private object locker = new object(); public void Work() { // 避免直接使用私有成员locker(直接使用有可能会导致线程不安全) object temp = locker; Monitor.Enter(temp); try { // 做一些需要线程同步的工作 } finally { Monitor.Exit(temp); } }
(2)System.Threading.Monitor类型的作用和使用
Monitor类型的Enter和Exit方法用来实现进入和退出对象的同步,当Enter方法被调用时,对象的同步索引将被检查,并且.NET将负责一系列的后续工作来保证对象访问时的线程同步,而Exit方法的调用则保证了当前线程释放该对象的同步块。
下面的代码示例演示了如何使用lock关键字来实现线程同步:
class Program { static void Main(string[] args) { // 多线程测试静态方法的同步 Console.WriteLine("开始测试静态方法的同步:"); for (int i = 0; i < 5; i++) { Thread thread = new Thread(Lock.StaticIncrement); thread.Start(); } // 这里等待线程执行结束 Thread.Sleep(5 * 1000); Console.WriteLine("-------------------------------"); // 多线程测试实例方法的同步 Console.WriteLine("开始测试实例方法的同步:"); Lock l = new Lock(); for (int i = 0; i < 6; i++) { Thread thread = new Thread(l.InstanceIncrement); thread.Start(); } Console.ReadKey(); } } public class Lock { // 静态方法同步锁 private static object staticLocker = new object(); // 实例方法同步锁 private object instanceLocker = new object(); // 成员变量 private static int staticNumber = 0; private int instanceNumber = 0; // 测试静态方法的同步 public static void StaticIncrement(object state) { lock (staticLocker) { Console.WriteLine("当前线程ID:{0}", Thread.CurrentThread.ManagedThreadId.ToString()); Console.WriteLine("staticNumber的值为:{0}", staticNumber.ToString()); // 这里可以制造线程并行执行的机会,来检查同步的功能 Thread.Sleep(200); staticNumber++; Console.WriteLine("staticNumber自增后为:{0}", staticNumber.ToString()); } } // 测试实例方法的同步 public void InstanceIncrement(object state) { lock (instanceLocker) { Console.WriteLine("当前线程ID:{0}",Thread.CurrentThread.ManagedThreadId.ToString()); Console.WriteLine("instanceNumber的值为:{0}", instanceNumber.ToString()); // 这里可以制造线程并行执行的机会,来检查同步的功能 Thread.Sleep(200); instanceNumber++; Console.WriteLine("instanceNumber自增后为:{0}", instanceNumber.ToString()); } } }
下图是该实例的执行结果:
PS:线程同步本身违反了多线程并行运行的原则,所以我们在使用线程同步时应该尽量做到将lock加在最小的程序块上。对于静态方法的同步,一般采用静态私有的引用对象成员,而对于实例方法的同步,一般采用私有的引用对象成员。
3.3 可否使用值类型对象来实现线程同步吗?
前面已经说到,在.NET中每个堆内的对象都会有一个同步索引字段,用以指向同步块的位置。但是,对于值类型来说,它们的对象是分配在堆栈上的,也就是说值类型是没有同步索引这一字段的,所以直接使用值类型对象无法实现线程同步。
如果在程序中对于lock关键字使用了值类型对象,会直接导致一个编译错误:
3.4 可否使用引用类型对象自身进行同步?
引用类型的对象是分配在堆上的,必然会包含同步索引,也可以分配同步块,所以原则上可以在对象的方法内对自身进行同步。而事实上,这样的代码也确实能有效地保证线程同步。But,这样的代码健壮性存在一定问题。
(1)lock(this)
回顾lock(this)的设计,就可以看出问题来:this代表了执行代码的当前对象,可以预见该对象可以被任何使用者访问,这就导致了不仅对象内部的代码在争用同步块,连类型的使用者也可以有意无意地进入到争用的队伍中→这显然不符合设计意图。
下面通过一个代码示例展示了一个恶意的使用者是如何导致类型死锁的:
class Program { static void Main(string[] args) { Console.WriteLine("开始使用"); SynchroThis st = new SynchroThis(); // 模拟恶意的使用者 Monitor.Enter(st); // 正常的使用者会收到恶意使用者的影响 // 下面的代码完全正确,但却被死锁 Thread thread = new Thread(st.Work); thread.Start(); thread.Join(); // 程序不会执行到这里 Console.WriteLine("使用结束"); Console.ReadKey(); } } public class SynchroThis { private int number = 0; public void Work(object state) { lock (this) { Console.WriteLine("number现在的值为:{0}", number.ToString()); number++; // 模拟做了其他工作 Thread.Sleep(200); Console.WriteLine("number自增后值为:{0}", number.ToString()); } } }
运行这个示例,我们发现程序完全被死锁,这是因为一个恶意的使用者在使用了同步块之后却没有对其进行释放,导致了SynchroThis类型的方法被组织。
(2)lock(typeof(类型名))
这样的设计有时候会被用来在静态方法中实现线程同步,因为静态方法的访问需要通过类型来进行,但它也和lock(this)一样,缺乏健壮性。下面展示了常见的错误使用代码示例:
class Program { static void Main(string[] args) { Console.WriteLine("开始使用"); SynchroThis st = new SynchroThis(); // 模拟恶意的使用者 Monitor.Enter(typeof(SynchroThis)); // 正常的使用者会收到恶意使用者的影响 // 下面的代码完全正确,但却被死锁 Thread thread = new Thread(SynchroThis.Work); thread.Start(); thread.Join(); // 程序不会执行到这里 Console.WriteLine("使用结束"); Console.ReadKey(); } } public class SynchroThis { private static int number = 0; public static void Work(object state) { lock (typeof(SynchroThis)) { Console.WriteLine("number现在的值为:{0}", number.ToString()); number++; // 模拟做了其他工作 Thread.Sleep(200); Console.WriteLine("number自增后值为:{0}", number.ToString()); } } }
可以发现,当一个恶意的使用者对type对象进行同步时,也会造成所有的使用者被死锁。
PS:应该完全避免使用this对象和当前类型对象作为同步对象,而应该在类型中定义私有的同步对象,同时应该使用lock而不是Monitor类型,这样可以有效地减少同步块不被释放的情况。
3.5 互斥体是个什么鬼?Mutex和Monitor两个类型的功能有啥区别?
(1)什么是互斥体?
在操作系统中,互斥体(Mutex)是指某些代码片段在任意时间内只允许一个线程进入。例如,正在进行一盘棋,任意时刻只允许一个棋手往棋盘上落子,这和线程同步的概念基本一致。
(2).NET中的互斥体
Mutex类是.NET中为我们封装的一个互斥体类型,和Mutex类似的还有Semaphore(信号量)等类型。下面的示例代码展示了Mutext类型的使用:
class Program { const string testFile = "C:\\TestMutex.txt"; /// <summary> /// 这个互斥体保证所有的进程都能得到同步 /// </summary> static Mutex mutex = new Mutex(false, "TestMutex"); static void Main(string[] args) { //留出时间来启动其他进程 Thread.Sleep(3000); DoWork(); mutex.Close(); Console.ReadKey(); } /// <summary> /// 往文件里写连续的内容 /// </summary> static void DoWork() { long d1 = DateTime.Now.Ticks; mutex.WaitOne(); long d2 = DateTime.Now.Ticks; Console.WriteLine("经过了{0}个Tick后进程{1}得到互斥体,进入临界区代码。", (d2 - d1).ToString(), Process.GetCurrentProcess().Id.ToString()); try { if (!File.Exists(testFile)) { FileStream fs = File.Create(testFile); fs.Dispose(); } for (int i = 0; i < 5; i++) { // 每次都保证文件被关闭再重新打开 // 确定有mutex来同步,而不是IO机制 using (FileStream fs = File.Open(testFile, FileMode.Append)) { string content = "【进程" + Process.GetCurrentProcess().Id.ToString() + "】:" + i.ToString() + "\r\n"; Byte[] data = Encoding.Default.GetBytes(content); fs.Write(data, 0, data.Length); } // 模拟做了其他工作 Thread.Sleep(300); } } finally { mutex.ReleaseMutex(); } } }
模拟多个用户,执行上述代码,下图就是在我的计算机上的执行结果:
现在打开C盘目录下的TestMutext.txt文件,将看到如下图所示的结果:
(3)Mutex和Monitor的区别
这两者虽然都用来进行同步的功能,但实现方法不同,其最显著的两个差别如下:
① Mutex使用的是操作系统的内核对象,而Monitor类型的同步机制则完全在.NET框架之下实现,这就导致了Mutext类型的效率要比Monitor类型要低很多;
② Monitor类型只能同步同一应用程序域中的线程,而Mutex类型却可以跨越应用程序域和进程。
3.6 如何使用信号量Semaphore?
这里首先借用阮一峰的《进程与线程的一个简单解释》中的介绍来说一下Mutex和Semaphore:
一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。
还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。
这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突。
不难看出,mutex是semaphore的一种特殊情况(n=1时)。也就是说,完全可以用后者替代前者。但是,因为mutex较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计。
现在我们知道了Semaphore是干啥的了,再把目光放到.NET中的Sempaphore上。Semaphore 继承自WaitHandle(Mutex也继承自WaitHandle),它用于锁机制,与Mutex不同的是,它允许指定数量的线程同时访问资源,在线程超过数量以后,则进行排队等待,直到之前的线程退出。Semaphore很适合应用于Web服务器这样的高并发场景,可以限制对资源访问的线程数。此外,Sempaphore不需要一个锁的持有者,通常也将Sempaphore声明为静态的。
下面的示例代码演示了4条线程想要同时执行ThreadEntry()方法,但同时只允许2条线程进入:
class Program { // 第一个参数指定当前有多少个“空位”(允许多少条线程进入) // 第二个参数指定一共有多少个“座位”(最多允许多少个线程同时进入) static Semaphore sem = new Semaphore(2, 2); const int threadSize = 4; static void Main(string[] args) { for (int i = 0; i < threadSize; i++) { Thread thread = new Thread(ThreadEntry); thread.Start(i + 1); } Console.ReadKey(); } static void ThreadEntry(object id) { Console.WriteLine("线程{0}申请进入本方法", id); // WaitOne:如果还有“空位”,则占位,如果没有空位,则等待; sem.WaitOne(); Console.WriteLine("线程{0}成功进入本方法", id); // 模拟线程执行了一些操作 Thread.Sleep(100); Console.WriteLine("线程{0}执行完毕离开了", id); // Release:释放一个“空位” sem.Release(); } }
上面示例的执行结果如下图所示:
如果将资源比作“座位”,Semaphore接收的两个参数中:第一个参数指定当前有多少个“空位”(允许多少条线程进入),第二个参数则指定一共有多少个“座位”(最多允许多少个线程同时进入)。WaitOne()方法则表示如果还有“空位”,则占位,如果没有空位,则等待;Release()方法则表示释放一个“空位”。
感叹一下:人生中有很多人在你的城堡中进进出出,城中的人想出去,城外的人想冲进来。But,一个人身边的位置只有那么多,你能给的也只有那么多,在这个狭小的圈子里,有些人要进来,就有一些人不得不离开。
出处:http://edisonchou.cnblogs.com