C#中的多線程超時處理實踐

最近我正在處理C#中關於timeout行爲的一些bug。解決方案很是有意思,因此我在這裏分享給廣大博友們。多線程

我要處理的是下面這些狀況:測試

  • 咱們作了一個應用程序,程序中有這麼一個模塊,它的功能向用戶顯示一個消息對話框,15秒後再自動關閉該對話框。可是,若是用戶手動關閉對話框,則在timeout時咱們無需作任何處理。線程

  • 程序中有一個漫長的執行操做。若是該操做持續5秒鐘以上,那麼請終止這個操做。code

  • 咱們的的應用程序中有執行時間未知的操做。當執行時間過長時,咱們須要顯示一個「進行中」彈出窗口來提示用戶耐心等待。咱們沒法預估此次操做會持續多久,但通常狀況下會持續不到一秒。爲了不彈出窗口一閃而過,咱們只想要在1秒後顯示這個彈出窗口。反之,若是在1秒內操做完成,則不須要顯示這個彈出窗口。orm

這些問題是類似的。在超時以後,咱們必須執行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" 
    }
}

個人操做類:get

public class MyOperation : IOperation
{
    public void DoOperation()
    {
        Console.WriteLine("Operation started");
    }
}
public class MyOperation : IOperation
{
    public void DoOperation()
    {
        Console.WriteLine("Operation started");
    }
}

個人測試程序:string

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();
}

結果應該是:
it


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是很是強大的類,我在處理線程通訊時一直使用它們。這兩個類很是實用。正在跟線程通訊打交道的朋友們,快把它們加入到項目裏面吧!

相關文章
相關標籤/搜索