林德熙 小伙伴希望保存一個文件,並且希望如果出錯了也要不斷地重試。然而我認為如果一直錯誤則應該對外拋出異常讓調用者知道為什么會一直錯誤。
這似乎是一個矛盾的要求。然而最終我想到了一個辦法:讓重試一直進行下去,誰需要關心異常誰就去 catch 異常,不需要關心異常的模塊則跟着一直重試直到成功。
我們通過編寫一個自己的 Awaiter 來實現,本文將說明其思路和最終實現的代碼。
本文內容
Awaiter 系列文章
入門篇:
- .NET 中什么樣的類是可使用 await 異步等待的?
- 定義一組抽象的 Awaiter 的實現接口,你下次寫自己的 await 可等待對象時將更加方便
- .NET 除了用 Task 之外,如何自己寫一個可以 await 的對象?
實戰篇:
遇到了什么問題
有一個任務,可能會出錯,然而重試有可能可以解決。典型的例子是寫入文件,你可能因為其他進程占用的問題而導致無法寫入,然而一段時間之后重試是可以解決的。
現在,不同業務對這同一個操作有不同的需求:
- 有的業務不關心寫入結果到底如何
- 有的業務由於時間有限,只能接受幾次的重試
- 有的業務關心寫入過程中的異常
- 而有的業務非常閑,只要一直寫入就行了,最終成功告訴我就好

可是,我們如何在一個任務中同時對所有不同的業務需求進行不同種類的響應呢?
思路
我的思路是:
- 當有業務發起請求之后,就開啟一個不斷重試的任務;
- 針對這個請求的業務,返回一個專為此業務定制的可等待對象;
- 如果在重試完成之前,還有新的業務請求發起,那么則返回一個專為此新業務定制的可等待對象;
- 一旦重試任務成功完成,那么所有的可等待對象強制返回成功;
- 而如果重試中有的可等待對象已經等待結束但任務依舊沒有成功,則在可等待對象中引發任務重試過程中發生過的異常。
這樣,任務不斷重試。而且,無論多少個業務請求到來,都只是加入到循環中的一部分來,不會開啟新的循環任務。每個業務的等待時長和異常處理都是自己的可等待對象中處理的,不影響循環任務的繼續執行。
關於源代碼說明
本文所述的所有源代碼可以在 https://gist.github.com/walterlv/d2aecd02dfad74279713112d44bcd358 查看和下載到最新版本。
期望如何使用這個新的 Awaiter
public class WalterlvDemo
{
// 記錄一個可以重試的循環。
private readonly PartialAwaitableRetry _loop;
public WalterlvDemo()
{
// 初始化一個可以重試的循環,循環內部執行的方法是 TryCoreAsync。
_loop = new PartialAwaitableRetry(TryCoreAsync);
}
// 如果外界期望使用這個類試一下,那么就調用此方法。默認嘗試 10 次,但也可以指定為 -1 嘗試無數次。
public ContinuousPartOperation TryAsync(int tryCount = 10)
{
// 加入循環中,然后返回一個可以異步等待 10 次循環的對象。
return _loop.JoinAsync(tryCount);
}
// 此方法就是循環的內部執行的方法。
private async Task<OperationResult> TryCoreAsync(PartialRetryContext context)
{
// 每 1 秒執行一次循環重試,當然你也可以嘗試指數退避。
await Task.Delay(1000).ConfigureAwait(false);
// 執行真正需要重試而且可能出現異常的方法。
await DoSomethingIOAsync().ConfigureAwait(false);
// 如果執行成功,那么就返回 true。當然,上面的代碼如果出現了異常,也是可以被捕獲到的。
return true;
}
// 這就是那個有可能會出錯,然后出錯了需要不斷重試的方法。
private async Task DoSomethingIOAsync()
{
// 省略實際執行的代碼。
}
}
寫一個可以不斷循環的循環,並允許不同業務加入等待
上面的代碼中,我們使用到了兩個新的類型:用於循環執行某個委托的 PartialAwaitableRetry,以及用於表示單次執行結果的 OperationResult。
以下只貼出此代碼的關鍵部分,全部源碼請至本文末尾查看或下載。
public class PartialAwaitableRetry
{
// 省略構造函數和部分字段,請至本文文末查看完整代碼。
private readonly List<CountLimitOperationToken> _tokens = new List<CountLimitOperationToken>();
public ContinuousPartOperation JoinAsync(int countLimit)
{
var token = new CountLimitOperationToken(countLimit);
// 省略線程安全代碼,請至本文文末查看完整代碼。
_tokens.Add(token);
if (!_isLooping)
{
Loop();
}
return token.Operation;
}
private async void Loop()
{
while(true)
{
await _loopItem.Invoke(context).ConfigureAwait(false);
// 省略線程安全處理和異常處理,請至本文文末查看完整代碼。
foreach (var token in _tokens)
{
token.Pass(1);
}
}
}
}
維護一個 CountLimitOperationToken 的集合,然后在每次循環的時候更新集合中的所有項。這樣,通過 JsonAsync 創建的每一個可等待對象就能更新其狀態 —— 將異常傳入或者將執行的次數傳入。
由於我們在創建可等待對象 CountLimitOperationToken 的時候,傳入了等待循環的次數,所以我么可以在 CountLimitOperationToken 內部實現每次更新循環執行次數和異常的時候,更新其等待狀態。如果次數已到,那么就通知異步等待完成。
關於 OperationResult 類,是個簡單的運算符重載,用於表示單次循環中的成功與否的狀態和異常情況。可以在本文文末查看其代碼。
寫一個可等待對象,針對不同業務返回不同的可等待對象實例
我寫了三個不同的類來完成這個可等待對象:
CountLimitOperationToken- 上面的代碼中我們使用到了這個類型,目的是為了生成
ContinuousPartOperation這個可等待對象。 - 我將這個 Token 和實際的 Awaitable 分開,是為了隔離執行循環任務的代碼和等待循環任務的代碼,避免等待循環任務的代碼可以修改等待的過程。
- 上面的代碼中我們使用到了這個類型,目的是為了生成
ContinuousPartOperation- 這個是實際的可等待對象,這個類型的實例可以直接使用
await關鍵字進行異步等待,也可以使用Wait()方法進行同步等待。 - 我把這個 Awaitable 和 Awaiter 分開,是為了隔離
await關鍵字的 API 和編譯器自動調用的方法。避免編譯器的大量方法干擾使用者對這個類的使用。
- 這個是實際的可等待對象,這個類型的實例可以直接使用
ContinuousPartOperation.Awaiter- 這是實際上編譯器自動調用方法的一個類,有點類似於我們為了支持
foreach而實現的IEnumerator。(而集合應該繼承IEnumerable)
- 這是實際上編譯器自動調用方法的一個類,有點類似於我們為了支持
所以其實這三個類是在干同一件事情,都是為了實現一個可 await 異步等待的對象。
關於如何編寫一個自己的 Awaiter,可以參考我的 Awaiter 入門篇章:
- .NET 中什么樣的類是可使用 await 異步等待的?
- 定義一組抽象的 Awaiter 的實現接口,你下次寫自己的 await 可等待對象時將更加方便
- .NET 除了用 Task 之外,如何自己寫一個可以 await 的對象?
以及實戰篇章:
這幾個類的實際代碼可以在文末查看和下載。
附全部源碼
我的博客會首發於 https://walterlv.com/,而 CSDN 和博客園僅從其中摘選發布,而且一旦發布了就不再更新。

本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。歡迎轉載、使用、重新發布,但務必保留文章署名呂毅(包含鏈接:https://blog.csdn.net/wpwalter),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。如有任何疑問,請與我聯系。
