c# 死锁和活锁的发生及避免

避免多线程同时读写共享数据

在实际开发中,难免会遇到多线程读写共享数据的需求。比如在某个业务处理时,先获取共享数据(比如是一个计数),再利用共享数据进行某些计算和业务处理,最后把共享数据修改为一个新的值。由于是多个线程同时操作,某个线程取得共享数据后,紧接着共享数据可能又被其它线程修改了,那么这个线程取得的数据就是错误的旧数据。我们来看一个具体代码示例:

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# 并正常结束,解决了死锁问题。

注意,这个解决方法依赖于线程 2 对其所需的独占资源的固执占有和线程 1 愿意“屈服”作出让步,让线程 2 总是优先执行。同时注意,线程 1 在锁定 resourceA 后,由于争夺不到 resourceB,作出了让步,把已占有的 resourceA 释放掉后,就必须等线程 2 使用完 resourceA 重新锁定 resourceA 再重做工作。

正因为线程 2 总是优先,所以,如果线程 2 占用 resourceAresourceB 的频率非常高(比如外面再嵌套一个类似 while(true) 的循环 ),那么就可能导致线程 1 一直无法获得所需要的资源,这种现象叫线程饥饿,是由高优先级线程吞噬低优先级线程 CPU 执行时间的原因造成的。线程饥饿除了这种的原因,还有可能是线程在等待一个本身也处于永久等待完成的任务。

我们可以继续开个脑洞,上面示例中,如果线程 2 也愿意让步,会出现什么情况呢?

活锁的发生和避免

我们把上面示例改造一下,使线程 2 也愿意让步:

public void Thread1DoWork()
{
 bool mustDoWork = true;
 Thread.Sleep(100);
 while (mustDoWork)
 {
  lock (resourceA)
  {
   Console.WriteLine("T1 重做");
   Thread.Sleep(1000);
   if (Monitor.TryEnter(resourceB, 0))
   {
    output += "T1#";
    mustDoWork = false;
    Monitor.Exit(resourceB);
   }
  }
  if (mustDoWork) Thread.Yield();
 }
}

public void Thread2DoWork()
{
 bool mustDoWork = true;
 Thread.Sleep(100);
 while (mustDoWork)
 {
  lock (resourceB)
  {
   Console.WriteLine("T2 重做");
   Thread.Sleep(1100);
   if (Monitor.TryEnter(resourceA, 0))
   {
    output += "T2#";
    mustDoWork = false;
    Monitor.Exit(resourceB);
   }
  }
  if (mustDoWork) Thread.Yield();
 }
}

注意,为了使我要演示的效果更明显,我把两个线程的 Thread.Sleep 时间拉开了一点点。运行后的效果如下:

通过观察运行效果,我们发现线程 1 和线程 2 一直在相互让步,然后不断重新开始。两个线程都无法进入 Monitor.TryEnter 代码块,虽然都在运行,但却没有真正地干活。

我们把这种线程一直处于运行状态但其任务却一直无法进展的现象称为活锁。活锁和死锁的区别在于,处于活锁的线程是运行状态,而处于死锁的线程表现为等待;活锁有可能自行解开,死锁则不能。

要避免活锁,就要合理预估各线程对独占资源的占用时间,并合理安排任务调用时间间隔,要格外小心。现实中,这种业务场景很少见。示例中这种复杂的资源占用逻辑,很容易把人搞蒙,而且极不容易维护。推荐的做法是使用信号量机制代替锁,这是另外一个话题,后面单独写文章讲。

总结

我们应该避免多线程同时读写共享数据,避免的方式,最简单的就是加锁,把共享数据作为独占资源来进行排它使用。

多个线程在一次任务中需要对多个独占资源加锁时,就可能因相互循环等待而出现死锁。要避免死锁,就至少得有一个线程作出让步。即,在发现自己需要的资源得不到满足时,就要主动释放已占有的资源,以让别的线程可以顺利执行完成。

大部分情况安排一个线程让步便可避免死锁,但在复杂业务中可能会有多个线程互相让步的情况造成活锁。为了避免活锁,需要合理安排线程任务调用的时间间隔,而这会使得业务代码变得非常复杂。更好的做法是放弃使用锁,而换成使用信号量机制来实现对资源的独占访问。

作者:精致码农

出处:http://cnblogs.com/willick

以上就是c# 死锁和活锁的发生及避免的详细内容,更多关于c# 死锁和活锁的资料请关注我们其它相关文章!

(0)

相关推荐

  • C#多线程编程中的锁系统(二)

    上章主要讲排他锁的直接使用方式.但实际当中全部都用锁又太浪费了,或者排他锁粒度太大了. 这一次我们说说升级锁和原子操作. 目录 1:volatile 2:  Interlocked 3:ReaderWriterLockSlim 4:总结 一:volatile 简单来说: volatile关键字是告诉c#编译器和JIT编译器,不对volatile标记的字段做任何的缓存.确保字段读写都是原子操作,最新值. 这不就是锁吗?   其这货它根本不是锁, 它的原子操作是基于CPU本身的,非阻塞的. 因为32

  • C#解决SQlite并发异常问题的方法(使用读写锁)

    本文实例讲述了C#解决SQlite并发异常问题的方法.分享给大家供大家参考,具体如下: 使用C#访问sqlite时,常会遇到多线程并发导致SQLITE数据库损坏的问题. SQLite是文件级别的数据库,其锁也是文件级别的:多个线程可以同时读,但是同时只能有一个线程写.Android提供了SqliteOpenHelper类,加入Java的锁机制以便调用.但在C#中未提供类似功能. 作者利用读写锁(ReaderWriterLock),达到了多线程安全访问的目标. using System; usin

  • C#多线程中如何运用互斥锁Mutex

    互斥锁(Mutex) 互斥锁是一个互斥的同步对象,意味着同一时间有且仅有一个线程可以获取它. 互斥锁可适用于一个共享资源每次只能被一个线程访问的情况 函数: //创建一个处于未获取状态的互斥锁 Public Mutex(): //如果owned为true,互斥锁的初始状态就是被主线程所获取,否则处于未获取状态 Public Mutex(bool owned): 如果要获取一个互斥锁.应调用互斥锁上的WaitOne()方法,该方法继承于Thread.WaitHandle类 它处于等到状态直至所调用

  • 浅谈C# async await 死锁问题总结

    可能发生死锁的程序类型 1.WPF/WinForm程序 2.asp.net (不包括asp.net core)程序 死锁的产生原理 对异步方法返回的Task调用Wait()或访问Result属性时,可能会产生死锁. 下面的WPF代码会出现死锁: private void Button_Click_7(object sender, RoutedEventArgs e) { Method1().Wait(); } private async Task Method1() { await Task.D

  • C#多线程编程中的锁系统(三)

    本章主要说下基于内核模式构造的线程同步方式,事件,信号量. 目录 一:理论 二:WaitHandle 三:AutoResetEvent 四:ManualResetEvent 五:总结 一:理论 我们晓得线程同步可分为,用户模式构造和内核模式构造. 内核模式构造:是由windows系统本身使用,内核对象进行调度协助的.内核对象是系统地址空间中的一个内存块,由系统创建维护. 内核对象为内核所拥有,而不为进程所拥有,所以不同进程可以访问同一个内核对象, 如进程,线程,作业,事件,文件,信号量,互斥量等

  • C#多线程编程中的锁系统(四):自旋锁

    目录 一:基础 二:自旋锁示例 三:SpinLock 四:继续SpinLock 五:总结 一:基础 内核锁:基于内核对象构造的锁机制,就是通常说的内核构造模式.用户模式构造和内核模式构造 优点:cpu利用最大化.它发现资源被锁住,请求就排队等候.线程切换到别处干活,直到接受到可用信号,线程再切回来继续处理请求. 缺点:托管代码->用户模式代码->内核代码损耗.线程上下文切换损耗. 在锁的时间比较短时,系统频繁忙于休眠.切换,是个很大的性能损耗. 自旋锁:原子操作+自循环.通常说的用户构造模式.

  • C#笔试题之同线程Lock语句递归不会死锁

    前几天在网上闲逛,无意中看到有这么一道题及其答案,如下: 根据线程安全的相关知识,分析以下代码,当调用test方法时i>10时是否会引起死锁?并简要说明理由. public void test(int i) { lock(this) { if (i > 10) { i--; test(i); } } } 答:不会发生死锁,(但有一点int是按值传递的,所以每次改变的都只是一个副本,因此不会出现死锁.但如果把int换做一个object,那么死锁会发生) 当我看到这道题时,我心里只有两个答案,1.

  • C#多线程编程中的锁系统基本用法

    平常在多线程开发中,总避免不了线程同步.本篇就对net多线程中的锁系统做个简单描述. 目录 一:lock.Monitor      1:基础.      2: 作用域.      3:字符串锁.      4:monitor使用 二:mutex 三:Semaphore 四:总结 一:lock.Monitor 1:基础 Lock是Monitor语法糖简化写法.Lock在IL会生成Monitor. 复制代码 代码如下: //======Example 1=====             strin

  • C#使用读写锁三行代码简单解决多线程并发的问题

    在开发程序的过程中,难免少不了写入错误日志这个关键功能.实现这个功能,可以选择使用第三方日志插件,也可以选择使用数据库,还可以自己写个简单的方法把错误信息记录到日志文件. 选择最后一种方法实现的时候,若对文件操作与线程同步不熟悉,问题就有可能出现了,因为同一个文件并不允许多个线程同时写入,否则会提示"文件正在由另一进程使用,因此该进程无法访问此文件". 这是文件的并发写入问题,就需要用到线程同步.而微软也给线程同步提供了一些相关的类可以达到这样的目的,本文使用到的 System.Thr

  • C# 串口接收数据中serialPort.close()死锁的实例

    最近在做一个有关高铁模拟仓显示系统的客户端程序,在这个程序中要运用串口serialPort传输数据,因为每次接收数据结束后要更新UI界面,所以就用到了的Invoke,将更新UI的程序代码封装到一个方法中,然后通过Incoke调用,程序跑起来没有任何问题,但是当你执行serialPort.close()是程序就会发生死锁,整个程序卡在那里动都动不了. 上网查了很多资料,有各种这样的说法,有的说定义一个接收数据的标志,如果在执行关闭程序是进行判断,如果数据接收完了就关闭串口,没有的话继续执行,但是经

  • C#中lock死锁实例教程

    在c#中有个关键字lock,它的作用是锁定某一代码块,让同一时间只有一个线程访问该代码块,本文就来谈谈lock关键字的原理和其中应注意的几个问题: lock的使用原型是: lock(X) { //需要锁定的代码.... } 首先要明白为什么上面这段话能够锁定代码,其中的奥妙就是X这个对象,事实上X是任意一种引用类型,它在这儿起的作用就是任何线程执行到lock(X)时候,X需要独享才能运行下面的代码,若假定现在有3个线程A,B,C都执行到了lock(X)而ABC因为此时都占有X,这时ABC就要停下

随机推荐