來源:阿子
cnblogs.com/yayazi/p/8328468.html
最近我正在處理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(),而不是等待第一個超時完成後呼叫。
但是這裡的預期行為是什麼?實際上存在以下幾種可能性:
-
在以前的StartWithTimeout超時期間呼叫StartWithTimeout時:忽略第二次啟動。
-
在以前的StartWithTimeout超時期間呼叫StartWithTimeout時:停止初始話Start並使用新的StartWithTimeout。
-
在以前的StartWithTimeout超時期間呼叫StartWithTimeout時:在兩個啟動中呼叫DoOperation。 在StopOperationIfNotStartedYet中停止所有尚未開始的操作(在超時時間內)。
-
在以前的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是非常強大的類,我在處理執行緒通訊時一直使用它們。這兩個類非常實用。正在跟執行緒通訊打交道的朋友們,快把它們加入到專案裡面吧!
●本文編號114,以後想閱讀這篇文章直接輸入114即可
●輸入m獲取到文章目錄
資料庫開發
更多推薦《18個技術類公眾微信》
涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。