如何正確捕捉 Task 例外

每一個 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.
// 以下略..

註:在 .NET 4.5 以後,由 TaskScheduler.UnobservedTaskException 捕捉到的例外,預設是不會主動結束程式的:若要修改為會結束程式(需搭配不呼叫 e.SetObserved),必須修改 App.config。
<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();
}

執行結果:
印出錯誤訊息,程式繼續運行。

利用指定 TaskContinuationOptions,可以自行定義 Task 在各種不同狀態下的處理方式。

結論:
會寫這一篇文章主要是因為在 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/