1. 程式人生 > >關於C#中async/await中的異常處理(下)-(轉載)

關於C#中async/await中的異常處理(下)-(轉載)

上一篇文章裡我們討論了某些async/await的用法中出現遺漏異常的情況,並且談到該如何使用WhenAll輔助方法來避免這種情況。WhenAll輔助方法將會彙總一系列的任務物件,一旦其中某個出錯,則會丟擲“其中一個”異常。那麼究竟是哪個異常?如果我們要處理所有的異常怎麼辦?我們這次就來詳細討論await操作在異常分派時的相關行為。

 

 

await丟擲異常時的行為



要理解await的行為,還是從理解Task物件的異常表現開始。Task物件有一個Exception屬性,型別為AggregateException,在執行成功的情況下該屬性返回null,否則便包含了“所有”出錯的物件。既然是AggregateException,則意為著可能包含多個子異常,這種情況往往會在任務的父子關係中出現,

具體情況可以參考MSDN中的相關說明。在許多情況下一個Task內部只會出現一個異常,此時這個AggregateException的InnerExceptions屬性自然也就只一個元素。

 

Task物件本身還有一個Wait方法,它會阻塞當前執行程式碼,直到任務完成。在出現異常的時候,它會將自身的AggregateException丟擲:

try
{
    t.Wait();
}
catch (AggregateException ex)
{
    ...
}

Wait方法是“真阻塞”,而await操作則是使用阻塞語義的程式碼實現非阻塞的效果,這個區別一定要分清。與Wait方法不同的是,await操作符效果並非是“丟擲”Task物件上的Exception屬性,而只是丟擲這個AggregateException物件上的“其中一個”元素。我在內部郵件列表中詢問這麼做的設計考慮,C#開發組的同學回答道,這個決策在內部也經歷了激烈的爭論,最終的選擇這種方式而不是直接丟擲Task物件上的AggregateException是為了避免編寫出冗餘的程式碼,並讓程式碼與傳統同步程式設計習慣更為接近。


他們舉了一個簡單的示例,假如一個Task物件t可能丟擲兩種異常,現在的錯誤捕獲方式為:

try
{
    await t1;
}
catch (NotSupportedException ex)
{
    ...
}
catch (NotImplementedException ex)
{
    ...
}
catch (Exception ex)
{
    ...
}

假如await操作丟擲的是AggregateException,那麼程式碼就必須寫為:

try
{
    await t1;
}
catch (AggregateException ex)
{
    
var innerEx = ex.InnerExceptions[0]; if (innerEx is NotSupportedException) { ... } else if (innerEx is NotImplementedException) { ... } else { ... } }

顯然前者更貼近傳統的同步程式設計習慣。但是問題在於,如果這個Task中包含了多個異常怎麼辦?之前的描述是丟擲“其中一個”異常,對於開發者來說,“其中一個”這種模糊的說法自然無法令人滿意,但事實的確如此。從內部郵件列表中的討論來看,C#開發團隊提到他們“故意”不提供文件說明究竟會丟擲哪個異常,因為他們並不想做出這方面的約束,因為這部分行為一旦寫入文件,便成為一個規定和限制,為了類庫的相容性今後也無法對此做出修改。


他們也提到,如果單論目前的實現,await操作會從Task.Exception.InnerExceptions集合中挑出第一個異常,並對外“丟擲”,這是System.Runtime.CompilerServices.TaskAwaiter類中定義的行為。但是既然這並非是“文件化”的固定行為,開發人員也儘量不要依賴這點。

 

 

WhenAll的異常彙總方式



其實這個話題跟async/await的行為沒有任何聯絡,WhenAll返回的是普通的Task物件,TaskAwaiter也絲毫不關心當前等待的Task物件是否來自於WhenAll,不過既然WhenAll是最常用的輔助方法之一,也順便將其講清楚吧。


WhenAll得到Task物件,其結果是用陣列存放的所有子Task的結果,而在出現異常時,其Exception屬性返回的AggregateException集合會包含所有子Task中丟擲的異常。請注意,每個子Task中丟擲的異常將會存放在它自身的AggregateException集合中,WhenAll返回的Task物件將會“按順序”收集各個AggregateException集合中的元素,而並非收集每個AggregateException物件。


我們使用一個簡單的例子來理解這點:

Task all = null;
try
{
    await (all = Task.WhenAll(
        Task.WhenAll(
            ThrowAfter(3000, new Exception("Ex3")),
            ThrowAfter(1000, new Exception("Ex1"))),
        ThrowAfter(2000, new Exception("Ex2"))));
}
catch (Exception ex)
{
    ...
}

這段程式碼使用了巢狀的WhenAll方法,總共會出現三個異常,按其丟擲的時機排序,其順序為Ex1,Ex2及Ex3。那麼請問:

  1. catch語句捕獲的異常是哪個?
  2. all.Exception這個AggregateException集合中異常按順序是哪些?

結果如下:

  1. catch語句捕獲的異常是Ex3,因為它是all.Exception這個AggregateException集合中的第一個元素,但還是請牢記這點,這只是當前TaskAwaiter所實現的行為,而並非是由文件規定的結果。
  2. all.Exception這個AggregateException集合中異常有三個,按順序是Ex3,Ex1和Ex2。WhenAll得到的Task物件,是根據輸入的Task物件順序來決定自身AggreagteException集合中異常物件的存放順序。這個順序跟異常的丟擲時機沒有任何關係。

這裡我們也順便可以得知,如果您不想捕獲AggregateException集合中的“其中一個”異常,而是想處理所有異常的話,也可以寫這樣的程式碼:

Task all = null;
try
{
    await (all = Task.WhenAll(
        ThrowAfter(1000, new Exception("Ex1")),
        ThrowAfter(2000, new Exception("Ex2"))));
}
catch
{
    foreach (var ex in all.Exception.InnerExceptions)
    {
        ...
    }
}

當然,這裡使用Task.WhenAll作為示例,是因為這個Task物件可以明確包含多個異常,但並非只有Task.WhenAll返回的Task物件才可能包含多個異常,例如Task物件在建立時指定了父子關係,也會讓父任務裡包含各個子任務裡出現的異常。

 

 

假如異常未被捕獲



最後再來看一個簡單的問題,我們一直在關注一個async方法中“捕獲”異常的行為,假如異常沒有成功捕獲,直接對外丟擲的時候,對任務本身的有什麼影響呢?且看這個示例:

static async Task SomeTask()
{
    try
    {
        await Task.WhenAll(
            ThrowAfter(2000, new NotSupportedException("Ex1")),
            ThrowAfter(1000, new NotImplementedException("Ex2")));
    }
    catch (NotImplementedException) { }
}

static void Main(string[] args)
{
    _watch.Start();

    SomeTask().ContinueWith(t => PrintException(t.Exception));

    Console.ReadLine();
}

這段程式碼的輸出結果是:

System.AggregateException: One or more errors occurred. ---> System.NotSupportedException: Ex1
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at AsyncErrorHandling.Program.d__3.MoveNext() in ...\Program.cs:line 30
   --- End of inner exception stack trace ---
---> (Inner Exception #0) System.NotSupportedException: Ex1
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at AsyncErrorHandling.Program.d__3.MoveNext() in ...\Program.cs:line 30<---

AggregateException的列印內容不那麼容易讀,我們可以關注它Inner Exception #0這樣的資訊。從時間上說,Ex2先於Ex1丟擲,而catch的目標是NotImplementedException。但從之前的描述我們可以知道,WhenAll返回的Task內部的異常集合,與各異常丟擲的時機沒有關係,因此await操作符丟擲的是Ex1,是NotSupportedException,而它不會被catch到,因此SomeTask返回的Task物件也會包含這個異常——也僅僅是丟擲這個異常,而Ex2對於外部就不可見了。


如果您想在外部處理所有的異常,則可以這樣:

Task all = null;
try
{
    await (all = Task.WhenAll(
        ThrowAfter(2000, new NotSupportedException("Ex1")),
        ThrowAfter(1000, new NotImplementedException("Ex2"))));
}
catch
{
    throw all.Exception;
}

此時列印的結果便是一個AggregateException包含著另一個AggregateException,其中包含了Ex1和Ex2。為了“解開”這種巢狀關係,AggregateException也提供了一個Flatten方法,可以將這種巢狀完全“鋪平”,例如:

SomeTask().ContinueWith(t => PrintException(t.Exception.Flatten()));

此時列印的結果便直接是一個AggregateException包含著Ex1與Ex2了。

 

 

相關文章



關於C#中async/await中的錯誤處理(上)
關於C#中async/await中的錯誤處理(下)

 

 

原文連結