LOGO OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 开发文档 其他文档  
 
网站管理员

C#.NET中的多线程超时处理实践

admin
2023年8月28日 15:2 本文热度 474

    最近我正在处理C#中关于timeout行为的一些bug。解决方案非常有意思,所以我在这里分享给广大博友们。

我要处理的是下面这些情况:


  • 我们做了一个应用程序,程序中有这么一个模块,它的功能向用户显示一个消息对话框,15秒后再自动关闭该对话框。但是,如果用户手动关闭对话框,则在timeout时我们无需做任何处理。


  • 程序中有一个漫长的执行操作。如果该操作持续5秒钟以上,那么请终止这个操作。


  • 我们的的应用程序中有执行时间未知的操作。当执行时间过长时,我们需要显示一个“进行中”弹出窗口来提示用户耐心等待。我们无法预估这次操作会持续多久,但一般情况下会持续不到一秒。为了避免弹出窗口一闪而过,我们只想要在1秒后显示这个弹出窗口。反之,如果在1秒内操作完成,则不需要显示这个弹出窗口。


这些问题是相似的。在超时之后,我们必须执行X操作,除非Y在那个时候发生。


为了找到解决这些问题的办法,我在试验过程中创建了一个类:


public class OperationHandler

{

    private IOperation _operation;

    

    public OperationHandler(IOperation operation)

    {

        _operation = operation;

    }    

    public void StartWithTimeout(int timeoutMillis)

    {

        //在超时后需要调用 "_operation.DoOperation()" 

    }    

    public void StopOperationIfNotStartedYet()

    {

        //在超时期间需要停止"DoOperation" 

    }

}


我的操作类:


public class MyOperation : IOperation

{

    public void DoOperation()

    {

        Console.WriteLine("Operation started");

    }

}

public class MyOperation : IOperation

{

    public void DoOperation()

    {

        Console.WriteLine("Operation started");

    }

}


我的测试程序:


static void Main(string[] args)

{

    var op = new MyOperation();

    var handler = new OperationHandler(op);

    Console.WriteLine("Starting with timeout of 5 seconds");

    handler.StartWithTimeout(5 * 1000);

    Thread.Sleep(6 * 1000);

    

    Console.WriteLine("Starting with timeout of 5 but cancelling after 2 seconds");

    handler.StartWithTimeout(5 * 1000);

    Thread.Sleep(2 * 1000);

    handler.StopOperationIfNotStartedYet();

    

    Thread.Sleep(4 * 1000);

    Console.WriteLine("Finished...");

    Console.ReadLine();

}


结果应该是:



现在我们可以开始试验了!



解决方案1:在另一个线程上休眠


我最初的计划是在另一个不同的线程上休眠,同时用一个布尔值来标记Stop是否被调用。


public class OperationHandler

{

    private IOperation _operation;

    private bool _stopCalled;


    public OperationHandler(IOperation operation)

    {

        _operation = operation;

    }

    public void StartWithTimeout(int timeoutMillis)

    {

        Task.Factory.StartNew(() =>

        {

            _stopCalled = false;

            Thread.Sleep(timeoutMillis);

            if (!_stopCalled)

                _operation.DoOperation();

        });

    }

    public void StopOperationIfNotStartedYet()

    {

        _stopCalled = true;

    }

}


针对正常的线程执行步骤,这段代码运行过程并没有出现问题,但是总是感觉有些别扭。仔细探究后,我发现其中有一些猫腻。首先,在超时期间,有一个线程从线程池中取出后什么都没做,显然这个线程是被浪费了。


其次,如果程序停止执行了,线程会继续休眠直到超时结束,浪费了CPU时间。


但是这些并不是我们这段代码最糟糕的事情,实际上我们的程序实还存在一个明显的bug:


如果我们设置10秒的超时时间,开始操作后,2秒停止,然后在2秒内再次开始。


当第二次启动时,我们的_stopCalled标志将变成false。然后,当我们的第一个Thread.Sleep()完成时,即使我们取消它,它也会调用DoOperation。


之后,第二个Thread.Sleep()完成,并将第二次调用DoOperation。结果导致DoOperation被调用两次,这显然不是我们所期望的。


如果你每分钟有100次这样的超时,我将很难捕捉到这种错误。


当StopOperationIfNotStartedYet被调用时,我们需要某种方式来取消DoOperation的调用。


如果我们尝试使用计时器呢?



解决方案2:使用计时器


.NET中有三种不同类型的记时器,分别是:


  • System.Windows.Forms命名空间下的Timer控件,它直接继承自Componet。


  • System.Timers命名空间下的Timer类。


  • System.Threading.Timer类。


这三种计时器中,System.Threading.Timer足以满足我们的需求。这里是使用Timer的代码:


public class OperationHandler

{

    private IOperation _operation;

    private Timer _timer;


    public OperationHandler(IOperation operation)

    {

        _operation = operation;

    }

    public void StartWithTimeout(int timeoutMillis)

    {

        if (_timer != null)

            return;


        _timer = new Timer(

            state =>

            {

                _operation.DoOperation();

                DisposeOfTimer();

            }, null, timeoutMillis, timeoutMillis);

    }        

    public void StopOperationIfNotStartedYet()

    {

        DisposeOfTimer();

    }

    private void DisposeOfTimer()

    {

        if (_timer == null)

            return;

        var temp = _timer;

        _timer = null;

        temp.Dispose();

    }

}


执行结果如下:



现在当我们停止操作时,定时器被丢弃,这样就避免了再次执行操作。这已经实现了我们最初的想法,当然还有另一种方式来处理这个问题。


解决方案3:ManualResetEvent或AutoResetEvent


ManualResetEvent/AutoResetEvent的字面意思是手动或自动重置事件。


AutoResetEvent和ManualResetEvent是帮助您处理多线程通信的类。 


基本思想是一个线程可以一直等待,知道另一个线程完成某个操作, 然后等待的线程可以“释放”并继续运行。


ManualResetEvent类和AutoResetEvent类请参阅MSDN:


ManualResetEvent类:

https://msdn.microsoft.com/zh-cn/library/system.threading.manualresetevent.aspx


AutoResetEvent类:

https://msdn.microsoft.com/zh-cn/library/system.threading.autoresetevent.aspx


言归正传,在本例中,直到手动重置事件信号出现,mre.WaitOne()会一直等待。mre.Set()将标记重置事件信号。 


ManualResetEvent将释放当前正在等待的所有线程。AutoResetEvent将只释放一个等待的线程,并立即变为无信号。WaitOne()也可以接受超时作为参数。 


如果Set()在超时期间未被调用,则线程被释放并且WaitOne()返回False。


以下是此功能的实现代码:


public class OperationHandler

{

    private IOperation _operation;

    private ManualResetEvent _mre = new ManualResetEvent(false);


    public OperationHandler(IOperation operation)

    {

        _operation = operation;

    }

    public void StartWithTimeout(int timeoutMillis)

    {

        _mre.Reset();

        Task.Factory.StartNew(() =>

        {

            bool wasStopped = _mre.WaitOne(timeoutMillis);

            if (!wasStopped)

                _operation.DoOperation();

        });

    }        

    public void StopOperationIfNotStartedYet()

    {

        _mre.Set();

    }

}


执行结果:



我个人非常倾向于这个解决方案,它比我们使用Timer的解决方案更干净简洁。

对于我们提出的简单功能,ManualResetEvent和Timer解决方案都可以正常工作。现在让我们增加点挑战性。



新的改进需求


假设我们现在可以连续多次调用StartWithTimeout(),而不是等待第一个超时完成后调用。


但是这里的预期行为是什么?实际上存在以下几种可能性:


  1. 在以前的StartWithTimeout超时期间调用StartWithTimeout时:忽略第二次启动。


  2. 在以前的StartWithTimeout超时期间调用StartWithTimeout时:停止初始话Start并使用新的StartWithTimeout。


  3. 在以前的StartWithTimeout超时期间调用StartWithTimeout时:在两个启动中调用DoOperation。在StopOperationIfNotStartedYet中停止所有尚未开始的操作(在超时时间内)。


  4. 在以前的StartWithTimeout超时期间调用StartWithTimeout时:在两个启动中调用DoOperation。在StopOperationIfNotStartedYet停止一个尚未开始的随机操作。


可能性1可以通过Timer和ManualResetEvent可以轻松实现。事实上,我们已经在我们的Timer解决方案中涉及到了这个。


public void StartWithTimeout(int timeoutMillis)

{

    if (_timer != null)

    return;

    ...

    

    public void StartWithTimeout(int timeoutMillis)

    {

    if (_timer != null)

    return;

    ...

}


可能性2  也可以很容易地实现。


可能性3  不可能通过使用Timer来实现。我们将需要有一个定时器的集合。一旦停止操作,我们需要检查并处理定时器集合中的所有子项。 


这种方法是可行的,但通过ManualResetEvent我们可以非常简洁和轻松的实现这一点!


可能性4  跟可能性3相似,可以通过定时器的集合来实现。



可能性3:使用单个ManualResetEvent停止所有操作


让我们了解一下这里面遇到的难点:


假设我们调用StartWithTimeout 10秒超时。


1秒后,我们再次调用另一个StartWithTimeout,超时时间为10秒。


再过1秒后,我们再次调用另一个StartWithTimeout,超时时间为10秒。


预期的行为是这3个操作会依次10秒、11秒和12秒后启动。


如果5秒后我们会调用Stop(),那么预期的行为就是所有正在等待的操作都会停止, 后续的操作也无法进行。


我稍微改变下Program.cs,以便能够测试这个操作过程。这是新的代码:


class Program

{

    static void Main(string[] args)

    {

        var op = new MyOperation();

        var handler = new OperationHandler(op);


        Console.WriteLine("Starting with timeout of 10 seconds, 3 times");

        handler.StartWithTimeout(10 * 1000);

        Thread.Sleep(1000);

        handler.StartWithTimeout(10 * 1000);

        Thread.Sleep(1000);

        handler.StartWithTimeout(10 * 1000);


        Thread.Sleep(13 * 1000);


        Console.WriteLine("Starting with timeout of 10 seconds 3 times, but cancelling after 5 seconds");

        handler.StartWithTimeout(10 * 1000);

        Thread.Sleep(1000);

        handler.StartWithTimeout(10 * 1000);

        Thread.Sleep(1000);

        handler.StartWithTimeout(10 * 1000);


        Thread.Sleep(5 * 1000);

        handler.StopOperationIfNotStartedYet();


        Thread.Sleep(8 * 1000);

        Console.WriteLine("Finished...");

        Console.ReadLine();

    }

}


下面就是使用ManualResetEvent的解决方案:


public class OperationHandler

{

    private IOperation _operation;

    private ManualResetEvent _mre = new ManualResetEvent(false);


    public OperationHandler(IOperation operation)

    {

        _operation = operation;

    }

    public void StartWithTimeout(int timeoutMillis)

    {

        Task.Factory.StartNew(() =>

        {

            bool wasStopped = _mre.WaitOne(timeoutMillis);

            if (!wasStopped)

                _operation.DoOperation();

        });

    }        

    public void StopOperationIfNotStartedYet()

    {

        Task.Factory.StartNew(() =>

        {

            _mre.Set();

            Thread.Sleep(10);//This is necessary because if calling Reset() immediately, not all waiting threads will 'proceed'

            _mre.Reset();

        });

    }

}


输出结果跟预想的一样:



很开森对不对?


当我检查这段代码时,我发现Thread.Sleep(10)是必不可少的,这显然超出了我的意料。如果没有它,除3个等待中的线程之外,只有1-2个线程正在进行。很明显的是,因为Reset()发生得太快,第三个线程将停留在WaitOne()上。



可能性4:单个AutoResetEvent停止一个随机操作


假设我们调用StartWithTimeout 10秒超时。


1秒后,我们再次调用另一个StartWithTimeout,超时时间为10秒。


再过1秒后,我们再次调用另一个StartWithTimeout,超时时间为10秒。然后我们调用StopOperationIfNotStartedYet()。


目前有3个操作超时,等待启动。预期的行为是其中一个被停止, 其他2个操作应该能够正常启动。


我们的Program.cs可以像以前一样保持不变。OperationHandler做了一些调整:


public class OperationHandler

{

    private IOperation _operation;

    private AutoResetEvent _are = new AutoResetEvent(false);


    public OperationHandler(IOperation operation)

    {

        _operation = operation;

    }

    public void StartWithTimeout(int timeoutMillis)

    {

        _are.Reset();

        Task.Factory.StartNew(() =>

        {

            bool wasStopped = _are.WaitOne(timeoutMillis);

            if (!wasStopped)

                _operation.DoOperation();

        });

    }        

    public void StopOperationIfNotStartedYet()

    {

        _are.Set();

    }

}


执行结果是:




结语


在处理线程通信时,超时后继续执行某些操作是常见的应用。我们尝试了一些很好的解决方案。一些解决方案可能看起来不错,甚至可以在特定的流程下工作,但是也有可能在代码中隐藏着致命的bug。当这种情况发生时,我们应对时需要特别小心。


AutoResetEvent和ManualResetEvent是非常强大的类,我在处理线程通信时一直使用它们。这两个类非常实用。正在跟线程通信打交道的朋友们,快把它们加入到项目里面吧!





- EOF -


该文章在 2023/8/28 15:02:39 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2024 ClickSun All Rights Reserved