74

C#中的多线程超时处理实践 - 阿子

 6 years ago
source link: https://www.cnblogs.com/yayazi/p/8328468.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

最近我正在处理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();
}

结果应该是:

Starting with timeout of 5 seconds Operation started Starting with timeout of 5 but cancelling after 2 seconds Finished...

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

解决方案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();
    }
}

执行结果如下:

Starting with timeout of 5 seconds Operation started Starting with timeout of 5 but cancelling after 2 seconds Finished...

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

解决方案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();
    }
}

执行结果:

Starting with timeout of 5 seconds Operation started Starting with timeout of 5 but cancelling after 2 seconds Finished...

我个人非常倾向于这个解决方案,它比我们使用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();
        });
    }
}

输出结果跟预想的一样:

Starting with timeout of 10 seconds, 3 times Operation started Operation started Operation started Starting with timeout of 10 seconds 3 times, but cancelling after 5 seconds Finished...

很开森对不对?

当我检查这段代码时,我发现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();
    }
}

执行结果是:

Starting with timeout of 10 seconds, 3 times Operation started Operation started Operation started Starting with timeout of 10 seconds 3 times, but cancelling after 5 seconds Operation started Operation started Finished...

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK