最近遇到一些多线程的问题,很多东西大概知道,这次总结整理一下。
1.1 多线程冲突的几种案例
1.1.1 整形的非原子操作导致数据冲突
下面我要初始化十个线程来对类中的整形字段进行加一操作,要注意在大多数计算机上,增加变量不是一个原子操作,它需要以下步骤
- 将实例变量中的值加载到寄存器中。
- 增加或减少该值。
- 在实例变量中存储该值。
先看代码:
public class InterlockedStudy { private int num = 0; Random ran = new Random(); public void Count() { for (int i = 0; i < 10; i++) { Thread thread = new Thread(AddHandler); thread.Start(); } Console.WriteLine("执行完成"); Console.ReadKey(); } private void AddHandler() { var threadId = Thread.CurrentThread.ManagedThreadId.ToString(); for (int i = 0; i < 10; i++) { Thread.Sleep(ran.Next(100, 200)); num++; //Interlocked.Increment(ref num); Console.WriteLine($"线程:{threadId} --num变化后为{num}"); } } }
1.1.2 函数内局部变量导致数据混乱
Thread[] threads = new Thread[10]; printer p = new printer(); for (int i = 0; i < threads.Length; i++) { threads[i] = new Thread(new ThreadStart(p.PrintNumbers)); threads[i].Name = string.Format("work thread {0}", i); } //十个线程同时访问共享的p.printNumbers(*)方法,导致输出的i值混乱,这就是并发的问题 foreach (var item in threads) { item.Start(); Console.ReadLine(); } Console.ReadKey();
public class printer { //锁 private object ThreadLock = new object(); public void PrintNumbers() { //lock (ThreadLock) //{ Console.WriteLine("-> {0} is excuting printNumbers()", Thread.CurrentThread.Name); for (int i = 0; i < 10; i++) { Random r = new Random(); Thread.Sleep(1000 * r.Next(5)); Console.Write(i + ","); } //} } }
其他的有遇到的再加。
代码中注释的部分,其实就是处理这类问题的方式。
2.1 多线程数据同步的几种方式
2.1.0 处理的抽象方式
线程同步有:临界区、互斥区、事件、信号量四种方式
临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)、事件(Event)的区别:
- 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程对共享资源进行访问,如果有多个线程试图访问公共资源,那么在有一个线程进入后,其他试图访问公共资源的线程将被挂起,并一直等到进入临界区的线程离开,临界区在被释放后,其他线程才可以抢占。
- 互斥量:采用互斥对象机制。 只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享
- 信号量:它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目
- 事 件: 通过通知操作的方式来保持线程的同步,还可以方便实现对多个线程的优先级比较的操作
2.1.1 Interlocked
定义:为多个线程共享的变量提供原子操作。
根据经验,那些需要在多线程情况下被保护的资源通常是整型值,且这些整型值在多线程下最常见的操作就是递增、递减或相加操作。Interlocked类提供了一个专门的机制用于完成这些特定的操作。这个类提供了Increment、Decrement、Add静态方法用于对int或long型变量的递增、递减或相加操作。此类的方法可以防止可能在下列情况发生的错误:计划程序在某个线程正在更新可由其他线程访问的变量时切换上下文;或者当两个线程在不同的处理器上并发执行时。 此类的成员不引发异常。
代码请见案例一。
2.1.2 lock
lock 关键字将语句块标记为临界区,方法是获取给定对象的互斥锁,执行语句,然后释放该锁。
lock 确保当一个线程位于代码的临界区时,另一个线程不进入临界区。如果其他线程试图进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。public void Function(){ System.Object locker= new System.Object(); lock(locker) { // Access thread-sensitive resources. }}
2.1.3 lock死锁的问题
提供给 lock 关键字的参数必须为基于引用类型的对象,该对象用来定义锁的范围。在上例中,锁的范围限定为此函数,因为函数外不存在任何对该对象的引用。严格地说,提供给 lock 的对象只是用来唯一地标识由多个线程共享的资源,所以它可以是任意类实例。然而,实际上,此对象通常表示需要进行线程同步的资源。例如,如果一个容器对象将被多个线程使用,则可以将该容器传递给 lock,而 lock 后面的同步代码块将访问该容器。只要其他线程在访问该容器前先锁定该容器,则对该对象的访问将是安全同步的。通常,最好避免锁定 public 类型或锁定不受应用程序控制的对象实例,例如,如果该实例可以被公开访问,则 lock(this) 可能会有问题,因为不受控制的代码也可能会锁定该对象。这可能导致死锁,即两个或更多个线程等待释放同一对象。出于同样的原因,锁定公共数据类型(相比于对象)也可能导致问题。锁定字符串尤其危险,因为字符串被公共语言运行库 (CLR)“暂留”。这意味着整个程序中任何给定字符串都只有一个实例,就是这同一个对象表示了所有运行的应用程序域的所有线程中的该文本。因此,只要在应用程序进程中的任何位置处具有相同内容的字符串上放置了锁,就将锁定应用程序中该字符串的所有实例。因此,最好锁定不会被暂留的私有或受保护成员。某些类提供专门用于锁定的成员。例如,Array 类型提供 SyncRoot。许多集合类型也提供 SyncRoot。
常见的结构 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 违反此准则:
- 如果实例可以被公共访问,将出现 lock (this) 问题。
- 如果 MyType 可以被公共访问,将出现 lock (typeof (MyType)) 问题。
- 由于进程中使用同一字符串的任何其他代码将共享同一个锁,所以出现 lock(“myLock”) 问题。
- 最佳做法是定义 private 对象来锁定, 或 private static 对象变量来保护所有实例所共有的数据。
lock死锁的案例:
public class lockStudy { private object a = new object(); private object b = new object(); public void DeadLock() { RunTask1(); RunTask2(); Console.WriteLine("执行完成"); Console.ReadKey(); } private void RunTask1() { Task.Run(() => { lock (a) { Console.WriteLine("runtask1 locked objcet a"); Thread.Sleep(1000);//延时保证RunTask2 lock b lock (b) { Console.WriteLine("runTask1 locked object b"); } } }); } private void RunTask2() { Task.Run(() => { lock (b) { Console.WriteLine("runtask2 locked object b"); Thread.Sleep(1000);//延时保证RunTask1 lock a lock (a) { Console.WriteLine("runtask2 locked objcet a"); } } }); } }
这里造成死锁的分析:
- task1 locked a
- task2 locked b
- task1 尝试lock b,发现task2已经锁定了b,只好等待b释放锁
- task2 尝试lock a,发现task1已经锁定了a,只好登台a释放锁
- task1 和task2 互相等待,造成死锁
2.1.4 AutoResetEvent
这个我有点存疑,感觉和Task很相似,不知道他们各自的使用场景有什么区别。
两个线程共享相同的AutoResetEvent对象,线程可以通过调用AutoResetEvent对象的WaitOne()方法进入等待状态,然后另外一个线程通过调用AutoResetEvent对象的Set()方法取消等待的状态。
using System;using System.Threading;namespace MutiThreadSample.ThreadSynchronization{ ////// 案例:做饭 /// 今天的Dinner准备吃鱼,还要熬粥 /// 熬粥和做鱼,是比较复杂的工作流程, /// 做粥:选材、淘米、熬制 /// 做鱼:洗鱼、切鱼、腌制、烹调 /// 我们用两个线程来准备这顿饭 /// 但是,现在只有一口锅,只能等一个做完之后,另一个才能进行最后的烹调 /// class CookResetEvent { ////// /// private AutoResetEvent resetEvent = new AutoResetEvent(false); ////// 做饭 /// public void Cook() { Thread porridgeThread = new Thread(new ThreadStart(Porridge)); porridgeThread.Name = "Porridge"; porridgeThread.Start(); Thread makeFishThread = new Thread(new ThreadStart(MakeFish)); makeFishThread.Name = "MakeFish"; makeFishThread.Start(); //等待5秒 Thread.Sleep(5000); resetEvent.Reset(); } ////// 熬粥 /// public void Porridge() { //选材 Console.WriteLine("Thread:{0},开始选材", Thread.CurrentThread.Name); //淘米 Console.WriteLine("Thread:{0},开始淘米", Thread.CurrentThread.Name); //熬制 Console.WriteLine("Thread:{0},开始熬制,需要2秒钟", Thread.CurrentThread.Name); //需要2秒钟 Thread.Sleep(2000); Console.WriteLine("Thread:{0},粥已经做好,锅闲了", Thread.CurrentThread.Name); resetEvent.Set(); } ////// 做鱼 /// public void MakeFish() { //洗鱼 Console.WriteLine("Thread:{0},开始洗鱼",Thread.CurrentThread.Name); //腌制 Console.WriteLine("Thread:{0},开始腌制", Thread.CurrentThread.Name); //等待锅空闲出来 resetEvent.WaitOne(); //烹调 Console.WriteLine("Thread:{0},终于有锅了", Thread.CurrentThread.Name); Console.WriteLine("Thread:{0},开始做鱼,需要5秒钟", Thread.CurrentThread.Name); Thread.Sleep(5000); Console.WriteLine("Thread:{0},鱼做好了,好香", Thread.CurrentThread.Name); resetEvent.Set(); } }}
#### 2.1.5 其他技术手段的补充
ReaderWriterLockSlim类允许多个线程同时读取一个资源,但在向该资源写入时要求线程等待以获得独占锁。
可以在应用程序中使用 ReaderWriterLockSlim,以便在访问一个共享资源的线程之间提供协调同步。 获得的锁是针对 ReaderWriterLockSlim 本身的。
设计您应用程序的结构,让读取和写入操作的时间尽可能最短。因为写入锁是排他的,所以长时间的写入操作会直接影响吞吐量。长时间的读取操作会阻止处于等待状态的编写器,并且,如果至少有一个线程在等写入访问,则请求读取访问的线程也将被阻止。参考的文章地址: