感谢你遇到的问题(2)

2、线程池线程阻塞

上一篇提到我为了防止定时器事件重入而加了锁。某一天我再打开监控页面发现监控频率为27秒的监控项的最后监控时间都停止在了前一天晚上的零点左右。

在本机也没有重现这种情况,我觉得可能是偶然现象,但持续观察了几天之后发现持续运行24小时左右这种情况是毕现,更严重的情况是所有的定时器貌似都是跑着跑着跑“累了”就不跑了,而且通过任务管理器查看发现进程创建的线程达到了200多个,至于原因我的第一反应是死锁导致线程池的线程被阻塞(block)了,因为前面我为了防止出现定时器事件重入而加了锁。而当线程池的线程被阻塞,线程池会创建额外的线程。

锁和线程同步

以前交通不发达村与村之间只有一条马路相连,村民之间来往是很不方便也不需要什么管理,后来发展了修了很多马路为了维护秩序十字路口有了红绿灯,某一天“领导”下来视察工作还会封路。多线程环境下为了维护秩序和同步线程本质上就有上面的“红绿灯”和“封路”的思想,也就是信号灯和加锁机制。信号量机制更关注共享资源,锁则更关注同步和一致性,个人理解它们本质还是一样的东西只是概念上不一样而已。C#中封装信号量和锁机制的类有Moniotr、Mutex、Semaphore、ManualResetEvent和AutoResetEvent以及上面我使用到的一个关键字lock,其中Mutex、Semaphore、ManualResetEvent和AutoResetEvent这些类都是继承自WaitHandle。而lock关键字本质就是Monitor的用法,只是个类似using语法糖用以保证锁得以释放不至于导致死锁。这从IL代码里可以很明显的看出来:

 private static object lockObj = new object();
        static void Main(string[] args)
        {
            lock (lockObj)
            {
            }
        }

.

Monitor除了提供Enter方法外还提供了一个TryEnter方法,下面是MSDN上的一个例子:

// Try to add an element to the queue: Add the element to the queue 
// only if the lock becomes available during the specified time
// interval.
public bool TryEnqueue(T qValue, int waitTime)
{
   // Request the lock.
   if (Monitor.TryEnter(m_inputQueue, waitTime))
   {
      try
      {
         m_inputQueue.Enqueue(qValue);
      }
      finally
      {
         // Ensure that the lock is released.
         Monitor.Exit(m_inputQueue);
      }
      return true;
   }
   else
   {
      return false;
   }
}

在指定的时间内获取排他锁,如果获取到的话返回true否则返回false,主要就是为了防止出现死锁。于是我为了防止出现死锁我一开始用了下面这样的一个封装了Montor.TryEnter方法的类,它实现了IDisposable接口保证资源能够释放:

/// <summary>  
    /// 会自动释放的锁,可设置等待超时  
    /// </summary>  
    public class Lock : IDisposable
    {
        /// <summary>  
        /// 默认超时设置  
        /// </summary>  
        public static int DefaultMillisecondsTimeout = 15000; // 15S  
        private object _obj;

        /// <summary>  
        /// 构造   
        /// </summary>  
        /// <param name="obj">想要锁住的对象</param>  
        public Lock(object obj)
        {
            TryGet(obj, DefaultMillisecondsTimeout, true);
        }

        /// <summary>  
        /// 构造  
        /// </summary>  
        /// <param name="obj">想要锁住的对象</param>  
        /// <param name="millisecondsTimeout">超时设置</param>  
        public Lock(object obj, int millisecondsTimeout)
        {
            TryGet(obj, millisecondsTimeout, true);
        }

        /// <summary>  
        /// 构造  
        /// </summary>  
        /// <param name="obj">想要锁住的对象</param>  
        /// <param name="millisecondsTimeout">超时设置</param>  
        /// <param name="throwTimeoutException">是否抛出超时异常</param>  
        public Lock(object obj, int millisecondsTimeout, bool throwTimeoutException)
        {
            TryGet(obj, millisecondsTimeout, throwTimeoutException);
        }

        private void TryGet(object obj, int millisecondsTimeout, bool throwTimeoutException)
        {
            if (System.Threading.Monitor.TryEnter(obj, millisecondsTimeout))
            {
                _obj = obj;
            }
            else
            {
                if (throwTimeoutException)
                {
                    throw new TimeoutException();
                }
            }
        }

        /// <summary>  
        /// 销毁,并释放锁  
        /// </summary>  
        public void Dispose()
        {
            if (_obj != null)
            {
                System.Threading.Monitor.Exit(_obj);
            }
        }

        /// <summary>  
        /// 在获取锁时是否发生等待超时  
        /// </summary>  
        public bool IsTimeout
        {
            get
            {
                return _obj == null;
            }
        }
    }

但是简单的运行程序一段时间后发现它有没有解决之前的“死锁”现象并不确定,但是之前的事件重入的问题又重现了,我想原因很容易明白。于是我放弃了使用这种看似很“高明”方法。那么在定时器的事件重入和死锁之间就没有什么很好的方法了吗?我在网上找到关于定时器死锁和重入说的很好的一段话:

“……Thread Timers是基于回调函数我更喜欢Thread Timer,比较轻量级方便易用。但是这样的Timer也有问题,就是由于是多线程定时器,就会出现如果一个Timer处理没有完成,到了时间下一个照样会发生,这就会导致重入问题对付重入问题通常的办法是加锁,但是对于 Timer却不能简单的这样做,你需要评估一下首先Timer处理里本来就不应该做太需要时间的事情,或者花费时间无法估计的事情,比同远方的服务器建立一个网络连接,这样的做法尽量避免如果实在无法避免,那么要评估Timer处理超时是否经常发生,如果是很少出现,那么可以用lock(Object)的方法来防止重入如果这种情况经常出现呢?那就要用另外的方法来防止重入了我们可以设置一个标志……”这段话给我们在使用定时器的时候提了几点建议:

  • 不要在定时器事件中做太耗时的操作尤其是网络请求
  • 不要随便的加锁
  • 不要依赖定时器做特别精确的事情

在我的实际应用中在定时器事件中的确要做大量的网络请求,其实对时间的精确度要求并不高,但是不允许频繁的出现重入,因为我的监控组件是长时间运行的,如果不停的从线程池开辟新线程的话,那么线程会越来越多,占用的系统资源也会越来越多。后来发现解决我这个问题的方法很简单,在进入定时器事件的时候将定时器的Enabled属性设为false(停止它引发Elasped事件),离开后再重新置为true。

try
 {
        //防止事件重入
        _setTimer.Enabled = false;
        foreach (MonitorItem item in _anaList)
         {
                   
          }
  }
  finally
  {
        if (_setTimer != null)
          {
                _setTimer.Enabled = true; 
           }
  }

在运行了一段时间后一切看似很正常,通过任务管理器没有发现有大量的线程被开辟出来,的确是解决了定时器重入的问题了。但正在我庆幸貌似也解决了之前出现的死锁现象的时候,整个收集组件又停止运转了,这次不同的是几乎所有定时器又都僵死在一个时刻了,给人的感觉是所有的定时器线程都在等”某个东西“。整个收集组件除了一大堆的定时器线程外(ThreadPool线程)还有一个主线程以及一个用来刷新各个定时器数据的线程(Refresh thread),如下图所示:

主线程创建一个Refresh thread(非线程池线程)和多个定时器(ThreadPool thread)。现在的情况是主线程和刷新线程依然运行正常,但是所有的定时器线程都“僵死”掉了。那么会不会是主线程或者刷新线程占用了某个资源一直没有释放,而线程池线程都在等待这个资源而形成了“死锁”。那么到底什么是死锁?造成死锁的原因有哪些呢?网上关于这方面的讨论有很多(看这)。关于早成死锁的原因网上一般都说有四个必要条件:

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

上面说的进程其实线程是一样的道理。个人理解造成死锁的无非是”天灾“和”人祸“,”天灾“是指有些系统资源时有限的必须加以控制的,而“人祸”是指我们在使用多线程尤其是进行同步的时候操作不当导致的。前面提到的Mutex、Semaphore、ManualResetEvent和AutoResetEvent以及Monitor类都是用来进行线程间的同步,那么他们之间的区别是什么呢?MSDN说Monitor和WaitHandle对象区别:”Monitor 对象是纯托管的,并且完全可移植,从操作系统资源要求来看可能更为高效。WaitHandle 对象表示操作系统可等待的对象,对于在托管和非托管代码之间实现同步非常有用,并提供一些高级的操作系统功能,例如同时等待多个对象的功能。“另外Mutex相对Monitor能够实现进程间的同步。而几个WaitHandle对象之间的区别只是对系统信号量机制不同的封装而已(可以看这)。于是我怀疑我的程序出现的那个情况会不会是”人祸“所致,某个地方不当的使用了这些类,于是我在整个项目中Ctrl+F挨个将这些对象找了一遍,最后定为在了记录日志的代码上:

public static Mutex mutex = new Mutex();
        /// <summary>
        /// 将日志信息记录到文件中
        /// </summary>
        /// <param name="fileName"></param>
        /// <param name="logContent"></param>
        public static void WriteLogToFile(string logContent)
        {
            FileStream fs = null;
            StreamWriter streamWriter = null;

            try
            {
                string directory = logPath;

                if (!Directory.Exists(directory))
                {
                    Directory.CreateDirectory(directory);
                }

                DirectoryInfo directoryInfo = new DirectoryInfo(directory);
                FileInfo[] fileInfos = directoryInfo.GetFiles();
                
                string filePath = string.Format("{0}\\{1}.log", logPath, DateTime.Now.ToString("yyyyMMdd"));

                
                mutex.WaitOne();

                fs = new FileStream(filePath, FileMode.Append);
                streamWriter = new StreamWriter(fs);
                streamWriter.BaseStream.Seek(0, SeekOrigin.End);
                streamWriter.WriteLine(string.Format("{0}:{1}", DateTime.Now.ToString(CultureInfo.CurrentCulture), logContent));
                
                mutex.ReleaseMutex();

            }
            catch (Exception)
            {
                //SmtpHelper.SendSysAlarmEmail("记录日志出现异常:" + ex.ToString());
                return;
            }
        }

如果一个线程在mutex.WaitOne()后抛出异常导致没有执行mutex.ReleaseMutex()这行代码那么会不会阻塞另一个执行这个方法的线程呢?为了验证我的想法写了一个简单的例子:

class Program
    {
        static Mutex mutex = new Mutex();
        static void Main()
        {
            //非线程池线程调用
            Method();

            //创建10个定时器(线程池线程)
            for (int i = 0; i < 10; i++)
            {
                System.Timers.Timer timer = new System.Timers.Timer();
                timer.Elapsed += new ElapsedEventHandler(TimerCallBack);
                timer.Interval = 2000;
                timer.Enabled = true;
            }

            Console.Read();
        }

        //定时器事件处理程序
        private static void TimerCallBack(object obj, EventArgs agrs)
        {
            Method();
        }

        private static void Method()
        {
            try
            {
                mutex.WaitOne();
                Console.WriteLine("TimerCallBack:" + DateTime.Now + "Thread ID:"
                    + Thread.CurrentThread.GetHashCode() + " IsThreadPoolThread:" + Thread.CurrentThread.IsThreadPoolThread);
                //throw new Exception();
                mutex.ReleaseMutex();
            }
            catch (Exception)
            {
                return;
            }
          
        }
    }

这个例子很简单在Main方法里直接执行Method方法和启动10个定时器去回掉这个方法,前者是在主线程中执行Method后者则是在线程池线程中执行,Method方法里打印出当前执行线程的一些信息。并且在输出信息前利用一个全局的静态的Mutex去阻塞线程,在输出信息之后再释放这个互斥体。正常的情况下不会有什么问题。

但是如果去掉//throw new Exception();的注释并且糟糕的是这里吞噬掉了异常你会发先整个线程池的线程无声无息的被阻塞了。其实解决方法很简单将mutex.ReleaseMutex()这行放入finally块就可以了。说实话我在心底是有点想骂写这个代码人的冲动啊,但心想终于找到原因所在了也就释怀了。但真的只是写这个代码的人的错吗?

原文地址:https://www.cnblogs.com/zhanjindong/p/2835040.html