多线程编程时,如果涉及同时读写共享数据,就要格外小心。如果共享数据是独占资源,则要对共享数据的读写进行排它访问,最简单的方式就是加锁。锁也不能随便用,否则可能会造成死锁和活锁。本文将通过示例详细讲解死锁和活锁是如何发生的,以及如何避免它们。
避免多线程同时读写共享数据在实际开发中,难免会遇到多线程读写共享数据的需求。比如在某个业务处理时,先获取共享数据(比如是一个计数),再利用共享数据进行某些计算和业务处理,最后把共享数据修改为一个新的值。由于是多个线程同时操作,某个线程取得共享数据后,紧接着共享数据可能又被其它线程修改了,那么这个线程取得的数据就是错误的旧数据。我们来看一个具体代码示例:
static int count { get; set; } static void Main(string[] args) { for (int i = 1; i <= 2; i++) { var thread = new Thread(ThreadMethod); thread.Start(i); Thread.Sleep(500); } } static void ThreadMethod(object threadNo) { while (true) { var temp = count; Console.WriteLine("线程 " + threadNo + " 读取计数"); Thread.Sleep(1000); // 模拟耗时工作 count = temp + 1; Console.WriteLine("线程 " + threadNo + " 已将计数增加至: " + count); Thread.Sleep(1000); } }示例中开启了两个独立的线程开始工作并计数,假使当 ThreadMethod 被执行第 4 次的时候(即此刻 count 值应为 4),count 值的变化过程应该是:1、2、3、4,而实际运行时计数的的变化却是:1、1、2、2...。也就是说,除了第一次,后面每次,两个线程读取到的计数都是旧的错误数据,这个错误数据我们把它叫作脏数据。
因此,对共享数据进行读写时,应视其为独占资源,进行排它访问,避免同时读写。在一个线程对其进行读写时,其它线程必须等待。避免同时读写共享数据最简单的方法就是加锁。
修改一下示例,对 count 加锁:
static int count { get; set; } static readonly object key = new object(); static void Main(string[] args) { ... } static void ThreadMethod(object threadNumber) { while (true) { lock(key) { var temp = count; ... count = temp + 1; ... } Thread.Sleep(1000); } }这样就保证了同时只能有一个线程对共享数据进行读写,避免出现脏数据。
死锁的发生上面为了解决多线程同时读写共享数据问题,引入了锁。但如果同一个线程需要在一个任务内占用多个独占资源,这又会带来新的问题:死锁。简单来说,当线程在请求独占资源得不到满足而等待时,又不释放已占有资源,就会出现死锁。死锁就是多个线程同时彼此循环等待,都等着另一方释放其占有的资源给自己用,你等我,我待你,你我永远都处在彼此等待的状态,陷入僵局。下面用示例演示死锁是如何发生的:
class Program { static void Main(string[] args) { var workers = new Workers(); workers.StartThreads(); var output = workers.GetResult(); Console.WriteLine(output); } } class Workers { Thread thread1, thread2; object resourceA = new object(); object resourceB = new object(); string output; public void StartThreads() { thread1 = new Thread(Thread1DoWork); thread2 = new Thread(Thread2DoWork); thread1.Start(); thread2.Start(); } public string GetResult() { thread1.Join(); thread2.Join(); return output; } public void Thread1DoWork() { lock (resourceA) { Thread.Sleep(100); lock (resourceB) { output += "T1#"; } } } public void Thread2DoWork() { lock (resourceB) { Thread.Sleep(100); lock (resourceA) { output += "T2#"; } } } }示例运行后永远没有输出结果,发生了死锁。线程 1 工作时锁定了资源 A,期间需要锁定使用资源 B;但此时资源 B 被线程 2 独占,恰巧资线程 2 此时又在待资源 A 被释放;而资源 A 又被线程 1 占用......,如此,双方陷入了永远的循环等待中。
死锁的避免针对以上出现死锁的情况,要避免死锁,可以使用 Monitor.TryEnter(obj, timeout) 方法来检查某个对象是否被占用。这个方法尝试获取指定对象的独占权限,如果 timeout 时间内依然不能获得该对象的访问权,则主动“屈服”,调用 Thread.Yield() 方法把该线程已占用的其它资源交还给 CUP,这样其它等待该资源的线程就可以继续执行了。即,线程在请求独占资源得不到满足时,主动作出让步,避免造成死锁。
把上面示例代码的 Workers 类的 Thread1DoWork 方法使用 Monitor.TryEnter 修改一下:
// ...(省略相同代码) public void Thread1DoWork() { bool mustDoWork = true; while (mustDoWork) { lock (resourceA) { Thread.Sleep(100); if (Monitor.TryEnter(resourceB, 0)) { output += "T1#"; mustDoWork = false; Monitor.Exit(resourceB); } } if (mustDoWork) Thread.Yield(); } } public void Thread2DoWork() { lock (resourceB) { Thread.Sleep(100); lock (resourceA) { output += "T2#"; } } }再次运行示例,程序正常输出 T2#T1# 并正常结束,解决了死锁问题。