每一個 Task 的例外都應該被處理,射後不理是不道德的;應予以譴責。
Task 類別提供了一個簡單使用的介面,讓開發者操作執行緒。但萬一 Task 中執行的程式拋出了例外該怎麼捕捉,如何能夠即時的處理例外?在 C# mscorlib 提供了 TaskScheduler.UnobservedTaskException 事件可觀察由 Task 中執行方法拋出的例外。但使用的方式並非這麼直覺,以下來說明該如何正確的捕捉 Task 產生的例外。
1. 利用 AppDomain.CurrentDomain.UnhandledException 來捕捉未處理的例外。
void Main()
{
AppDomain.CurrentDomain.UnhandledException += (o, e ) =>
Console.WriteLine($"Unhandled exception occurs {e.ExceptionObject}.");
Task.Run(() => throw new Exception("DoWork failed."));
Console.Read();
}
執行此程式後會發現啥都沒發生,程式不會跳錯,主控台也不會印出錯誤訊息。發生了什麼事呢?依照此篇 How to catch/observe an unhandled exception thrown from a Task 的說法,要觸發 UnhandledExeption 先決條件是呼叫 Task.Wait() 或 Task.Result。
void Main()
{
AppDomain.CurrentDomain.UnhandledException += (o, e ) =>
Console.WriteLine($"Unhandled exception occurs {e.ExceptionObject}.");
Task.Run(() => throw new Exception("DoWork failed.")).Wait(); // 呼叫 Wait()
Console.Read();
}
執行結果:
程式崩潰並印出錯誤。
Unhandled exception occurs System.AggregateException: 發生一或多項錯誤。 ---> System.Exception: DoWork failed.
// 以下略..
但有時使用 Task 就是不希望程式是用阻斷式的方式執行(Task.Wait(), Task.Result)皆為阻斷式呼叫。於是需要引入 TaskScheduler.UnobservedTaskException 的錯誤處理事件。
2. 用 TaskScheduler.UnobservedTaskException 來捕捉由 Task 中執行方法拋出的例外。
void Main()
{
TaskScheduler.UnobservedTaskException += (o, e) =>
{
e.SetObserved();
Console.WriteLine($"Task unobservedTaskExeption occurs {e.Exception}.");
};
Task.Run(() => throw new Exception("DoWork failed."));
Console.Read();
}
執行結果:
什麼事都不會發生。原因是要讓 UnobservedTaskException 捕捉到 Task 發出的例外,必須在該 Task 物件執行 finalizer 後(Task 有實作解構子)。
改成以下這樣就可以捕捉到例外:
void Main()
{
TaskScheduler.UnobservedTaskException += (o, e) =>
{
e.SetObserved(); // 將例外設定為已處理
Console.WriteLine($"Task unobservedTaskExeption occurs {e.Exception}.");
};
var t = Task.Run(() => throw new Exception("DoWork failed."));
Thread.Sleep(1000); // Delay 讓 Task 有足夠時間拋出例外。
t = null; // 將根參考設定為 null。
GC.Collect();
GC.WaitForPendingFinalizers();
Console.Read();
}
執行結果:
程式不會崩潰,並印出以下訊息。
Task unobservedTaskExeption occurs System.AggregateException: 正在等候此工作或正在存取其 Exception 屬性的動作未觀察到工作的例外狀況。因此,完成項執行緒將重新擲回未觀察到的例外狀況。 ---> System.Exception: DoWork failed.
// 以下略..
<configuration>
<runtime>
<ThrowUnobservedTaskExceptions enabled="false" /> // 預設為 false,改為 true 將結束程式。
</runtime>
</configuration>
即便如此一來可以不藉由阻斷式呼叫捕捉例外,但這樣寫還是不夠即時。實務上,我們並無法去確定 GC.Collect 執行的時間點;在依賴解構子才能處理例外的情況下,並無法立刻處理例外。
3. 利用 tay catch 區塊捕捉非同步方法例外(async / await )。
async Task Main()
{
try
{
await Task.Run(() => throw new Exception("DoWork failed."));
}
catch ( Exception e )
{
Console.WriteLine( $"Main exception occurs {e}." );
}
Console.Read();
}
輸出結果:
程式繼續運行,輸出例外訊息。
Main exception occurs System.Exception: DoWork failed.
// 以下略..
利用 try catch 區塊捕捉非同步方法拋出的例外很簡潔,也能夠即時反應例外。但並不是所有方法都適用宣告為 async / await,此時就需要自行定義當例外發生時的處理方式。
4. 利用 Task 可串接的特性組合錯誤處理方式。
定義兩個 Task 的擴充方法,FailFastOnException 的作用是讓該 Task 拋出例外時立刻結束程式;而 IgnoreExceptions 只會印出例外訊息。
public static class TaskExtensions
{
public static Task FailFastOnException(this Task task)
{
task.ContinueWith(c => Environment.FailFast("Task faulted", c.Exception),
TaskContinuationOptions.OnlyOnFaulted); // 例外發生時才執行。
return task;
}
public static Task IgnoreExceptions(this Task task)
{
task.ContinueWith(c => Console.WriteLine(c.Exception),
TaskContinuationOptions.OnlyOnFaulted); // 例外發生時才執行。
return task;
}
}
測試程式一:
void Main()
{
Task.Run(() => throw new Exception("DoWork failed.")).FailFastOnException();
Console.Read();
}
執行結果:
程式結束,並且在事件檢視器 → 應用程式可以找到錯誤訊息。
測試程式二:
void Main()
{
Task.Run(() => throw new Exception("DoWork failed.")).IgnoreExceptions();
Console.Read();
}
執行結果:
印出錯誤訊息,程式繼續運行。
結論:
會寫這一篇文章主要是因為在 Code review 的過程中,常看到射後不理的 Task(我自己以前也會這樣寫,越方便的東西坑越大);進而導致客戶端程式因不明原因停下來,也沒有跳出錯誤。回到 RD 這裡以後用 IDE 才重現(Debug 模式)。利用這篇文章作個記錄,讓重複的錯誤不要再發生。
範例程式碼:
https://github.com/SeanLiao7/TaskExceptionsHandlingDemo.git
參考資料:
http://www.huanlintalk.com/2013/01/aspnet-45-fire-and-forget-async-call.html
https://blogs.msdn.microsoft.com/pfxteam/2009/06/01/tasks-and-unhandled-exceptions/