感谢你遇到的问题

我们是多么希望我们写的代码一直完美的运行下去,但是这个行业的一条真理就是“没有不存在bug的软件”。最近这几个月一有空就打开我写的代码,目的只是为了苦苦寻找各种bug所在,而所有这些bug必定都源于我学艺不精。在这个过程中我发现我是多么的“善变”。截至到目前仍然没确定我干掉了所有bug,但是我得感谢它们和我的“无知”。好了废话到此结束,把我的debug过程和大家分享下。

背景

这些bug都出自一直在做的一个用在我们系统内部的监控组件,主要用来监控一些组件比如业务接口、sqlserver、redis、mongodb是否运行正常。

监控组件整体架构图

主要抽象出来有以下几个对象:sqlserver之类抽象为监控对象(MonitorObject),用来判定这些对象是否正常的指标(MonitorQuota),还有监控对象和监控指标的组合监控项(MonitorItem)。MonitorItem作为最小的监控实体存在,一个监控项包含了我要进行监控的资源和信息,比如监控频率(Frequency)、监控方法(MonitorMethod)等等。

监控方法类图

监控组件是作为服务7*24小时运行的,根据监控频率和监控方法(httpget、httppost、mongostat……)不停的对监控对象发起监控请求(MoniorRequest),将收集到的监控数据(MonitorLog)插入到数据库供分析组件(AnalysisService)分析和监控页面展示。“根据监控频率不停的对监控对象发起监控请求”我使用了定时器(.net中的System.Timers.Timer),因为监控项众多,所以以监控频率分组,每组起一个定时器(简单贴点代码)。

IEnumerable<IGrouping<int, MonitorItem>> groups= itemList.GroupBy(f => f.Frequency);
 foreach (IGrouping<int, MonitorItem> group in groups)
            {
                List<MonitorItem> list = group.ToList();
                TimerHandler handler = new TimerHandler(list[0].Frequency, list, mpid);//TimerHandler是对System.Timers.Timer的简单封装。
            }
public class TimerHandler
    {
        private System.Timers.Timer _setTimer;
        private List<MonitorItem> _anaList;

        public TimerHandler(int frequency, List<MonitorItem> anaList, int mpid)
        {
            _mpid = mpid;
            _logs = new List<string>();
            _anaList = anaList;
            _setTimer = new System.Timers.Timer();
            _setTimer.Interval = _frequency * 1000;
            _setTimer.Elapsed += new ElapsedEventHandler(MonitorTimeEvent);
            _setTimer.Enabled = true;

            //绑定数据刷新事件
            CollectHandler.Instance.RefreshEvent += this.OnRefresh;
        }

        //定时器事件
        private void MonitorTimeEvent(object source, ElapsedEventArgs e)
        {
            if (_anaList.Count == 0)
            {
                //取消数据刷新事件
                CollectHandler.Instance.RefreshEvent -= this.OnRefresh;
                //销毁定时器
                _setTimer.Enabled = false;
                _setTimer.Close();
                _setTimer.Dispose();
                _setTimer = null;
                return;
            }

            foreach (MonitorItem item in _anaList)
            {
                //依次对监控项发起请求
            }
            //将收集到的监控结果插入数据库
            CollectHandler.Instance.InsertMonitorLogs(_logs);

        }

        private void OnRefresh(object sender, MonitorEventArgs e)
        {
            //由收集组件主线程刷新定时器线程的数据
            _anaList = e.Items.Where(f => f.Frequency == _frequency).ToList();
        }
    }

最后通过页面可也简单的了解对象的最后监控状态:

监控页面

问题

1、定时器事件重入

刚开始监控项很少,一切还比较正常,虽然在出现真正的麻烦之前也碰到过一大堆问题(鉴于自己的能力早有心理准备),但最终都解决了,但定时器事件重入这个问题在我解决另外一个bug的时候却误导了我很长时间,另外一个原因是我总是喜欢想当然。在决定采用定时器执行监控任务之前,我知道.net里面有三个定时器:System.Windows.Forms.Timer、System.Timers.Timer和System.Threading.Timer,第一个是基于windows消息循环的是单线程的,后两者是基于多线程能提供更精确的定时执行任务的能力。毫无疑问在我的场景下排除了System.Windows.Forms.Timer,最后我选择了System.Timers.Timer。System.Timers.Timer是对System.Threading.Timer的封装使用起来更简单,通过将一个方法简单绑定到System.Timers.Timer实例的Elapsed事件上并通过设置几个简单的属性就可以使它预期工作了,注意AutoSet属性的用法:“当 AutoReset 设置为 false 时,Timer 只在第一个 Interval 过后引发一次 Elapsed 事件。若要保持以 Interval 时间间隔引发 Elapsed 事件,请将 AutoReset 设置为 true。”

_setTimer.Interval = _frequency * 1000;//定时器的频率
_setTimer.Elapsed += new ElapsedEventHandler(MonitorTimeEvent);//定时器线程回掉的方法_setTimer.Enabled = true;//启动定时器

选择System.Timers.Timer的另外一个原因是微软说它是专门为服务设计的。一开始我并不知道定时器有事件冲入这回事情,直到有一天我发现数据库中同一个频率的定时器收集到的数据有很多都是同一时刻发生的,网上一搜才发现原来因为System.Timers.Timer和System.Threading.Timer都是基于线程池ThreadPool类)。而引发Elapsed事件的线程正是来自ThreadPool,也就是执行MonitorEvent这个方法的线程。于是出现了一个问题,当Elapsed事件的执行时间大于指定的Interval值,之前执行回掉方法的线程一直处于阻塞状态(WaitSleepJoin),在另一个 ThreadPool 线程上将会再次引发此事件。这就出现了所谓的定时器事件重入,在我的代码中这种情况尤为突出,有的监控方法(httppost)会进行大量耗时的网络请求。

static void Main(string[] args)
        {
            Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);
            System.Timers.Timer timer = new System.Timers.Timer();
            timer.Interval = 2000;
            timer.Elapsed += new ElapsedEventHandler(timer_Elapsed);
            timer.Enabled = true;

            Console.Read();
        }

        static void timer_Elapsed(object source, ElapsedEventArgs e)
        {
            Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);
            Console.WriteLine(DateTime.Now);
        }

上面的代码运行很正常每隔2秒打印出时间:

想看到定时器事件重入很简单,在Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread)这行代码打上断点,F5进入调试模式,当运行到断点的时候执行这次回掉的线程将被挂起,等待足够长的时间(超过两秒)然后在运行,如此反复多次就会在多个ThreadPool线程上引发Elapsed事件,你会看到打印出的时间间隔小于2秒甚至出现两个完全相同的时间:

定时器事件重入

SynchronizingObject属性

上面有关线程池和事件重入的讨论忽略了System.Timers.Timer的一个属性SynchronizingObject。我们都知道Win32有条的金科玉律:“对象的实例只应该被实例化此对象的线程访问”,为了方便达成此目的,System.Timers.Timer提供了SynchronizingObject 属性,能够保证此线程都一直用的是SynchronizingObject 指定的线程处理。通俗的说就是用指定的线程去引发Elapsed事件而不是ThreadPool里的线程,MSDN解释:“获取或设置对象,该对象用于在间隔过后封送发出的事件处理程序调用。"这里的事件处理程序就是指事件触发回掉的方法,封送的意思就是指指定某个线程去执行。这个主要用在WinForm开发中,我们都知道在非UI线程去更新UI控件会报异常,这正是因为VS遵循了win32那条金科玉律。下面的代码简单的演示了SynchronizingObject属性的作用。

private void Form1_Load(object sender, EventArgs e)
        {
            System.Timers.Timer timer = new System.Timers.Timer();
            timer.Interval = 2000;
            timer.Elapsed += new ElapsedEventHandler(timer_Elapsed);
            timer.Enabled = true;
            //timer.SynchronizingObject = this;
        }

        private void timer_Elapsed(object source, ElapsedEventArgs e)
        {
            textBox2.Text = Thread.CurrentThread.IsThreadPoolThread.ToString() ;
            textBox1.Text = DateTime.Now.ToString();
        }

VS2010下F5运行会报下面的异常:

但是直接运行(Ctrl+F5)的话却没有问题,而且更新TextBox控件的线程确实是线程池的线程,可见这是VS给我们的”警告“,事实上VS较早的版本的确只是个警告。

现在我们去掉 //timer.SynchronizingObject = this;这行注释再F5运行VS不再报异常而且发现更新TextBox的线程不是线程池的线程了。

事实上现在执行定时器事件的线程就是主线程(UI线程),这正是”封送“的结果,这时自然也不会出现重入的问题。那到底如何完成封送的呢?上面的例子更新UI控件的线程来自线程池是因为我们使用了定时器,而System.Timers.Timer给我提供了一个属性SynchornizingObject帮我们完成了任务。如果我们不是使用定时器呢,解决方法就是利用控件提供的Invoke和BeginInvoke把调用封送回UI线程,也就是让控件属性修改在UI线程上执行。修改上面的代码如下:

private void Form1_Load(object sender, EventArgs e)
        {
            System.Timers.Timer timer = new System.Timers.Timer();
            timer.Interval = 2000;
            timer.Elapsed += new ElapsedEventHandler(timer_Elapsed);
            timer.Enabled = true;
            //timer.SynchronizingObject = this;
        }

        private void timer_Elapsed(object source, ElapsedEventArgs e)
        {
            //这里使用了lambda
            this.BeginInvoke(new Action(() => { 
                textBox1.Text = DateTime.Now.ToString();
                textBox2.Text = Thread.CurrentThread.IsThreadPoolThread.ToString();
            }));
        }

F5运行效果和使用SynchronizingObject属性是一样的。除了BeginInvoke还可以使用Invoke,我们在回头看看SynchronizingObjct,在VS中F12查看会发现其实它是一个实现了ISynchronizeInvoke接口的对象,而这个接口有两个方法就是BeginInvoke和Invoke,窗口Form继承自Control,Control实现了这个接口。

  // 摘要:
    //     提供同步或异步执行委托的方法。
    public interface ISynchronizeInvoke
    {
        bool InvokeRequired { get; }

        // 摘要:
        //     在创建了此对象的线程上异步执行委托。
        //
        // 参数:
        //   method:
        //     对方法的 System.Delegate,采用 args 中包含的相同数字和类型的参数。
        //
        //   args:
        //     作为给定方法的参数传递的 System.Object 类型数组。如果不需要参数,则可以为 null。
        //
        // 返回结果:
        //     System.IAsyncResult 接口,表示通过调用此方法启动的异步操作。
        IAsyncResult BeginInvoke(Delegate method, object[] args);
        object EndInvoke(IAsyncResult result);
        object Invoke(Delegate method, object[] args);
    }

"ISynchronizeInvoke提供了一个普通的标准机制用于在其他线程的对象中进行方法调用。例如,如果一个对象实现了ISynchronizeInvoke,那么在线程T1上的客户端可以在该对象中调用ISynchronizeInvoke的Invoke()方法。Invoke()方法的实现会阻塞(block)该线程的调用,它将调用打包发送(marshal)到 T2,并在T2中执行调用,再将返回值发送会T1,然后返回到T1的客户端。Invoke()方法以一个代理来定位该方法在T2中的调用,并以一个普通的对象数组做为其参数。"

Control实现这两个方法的本质其实是对应win32的SendMessage(阻塞)和PostMessage(非阻塞),”这两个方法向UI线程的消息队列中放入一个消息,当UI线程处理这个消息时,就会在自己的上下文中执行传入的方法,换句话说凡是使用BeginInvoke和Invoke调用的线程都是在UI主线程中执行的,所以如果这些方法里涉及一些静态变量,不用考虑加锁的问题“。所有的这些都是因为那条金科玉律,但是我不解的是为什么微软只对跨线程调用UI线程对象加以警告甚至限制,而其实FCL里非线程安全的类一大堆。

弄明白Synchronizing、BeginInvoke和Invoke回头再看看我遇到的问题”定时器事件重入“的问题。这样看来解决这个问题的一个可行方案就是自己实现ISynchronizingInvoke接口将定时器事件处理程序(回掉的方法)封送到一个线程去执行。但是自己实现ISynchronizingInvoke还是有点麻烦的(可以看这)。所以我一开始解决方法很简单lock,我想只要加锁一次保证只有一个线程去执行MonitorTimerEvent这个事件处理程序就不会出现重入的问题。

lock

private void MonitorTimeEvent(object source, ElapsedEventArgs e)
        {
            lock(this)
             {
                foreach (MonitorItem item in _anaList)
                {
                    //依次对监控项发起请求
                }
            }
        }

几秒钟的时间很简单解决了事件重入,这个时候程序里只有两个频率的定时器线程在运行,只是简单的对一些页面和接口的可用性和响应时间进行监控,在我写其他代码的时候这个组件在一个多月里运行还算正常。但是接下的问题就麻烦大了。

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