More Effective C# 28.永遠不要寫 async void 方法 More Effective C# 28.永遠不要寫 async void 方法(Never Write Async void Methods)

這個做法提出幾個理由讓你知道寫 async void 方法是不好的,所以應該要避免這樣寫非同步方法。

在處理 FireAndForget 這種不需要回傳值的操作時第一個想法可能就是寫一個 async void 方法, 但你會發現你沒有辦法 await 一個 async void 方法,例如下面這種寫法編譯器會提示錯誤並無法通過編譯。

async void Main()
{
	await FireAndForget();
}

private async void FireAndForget()
{
	await Task.Delay(TimeSpan.FromSeconds(1));
}

在上一個做法內有提到,當我們在一個非同步方法裡拋出錯誤時這個錯誤會保存在回傳值 Task 內的 AggregateException 裡面,並把 Task 標記為 TaskStatus.Faulted,也就是說如果你寫的是 async void 方法就沒有辦法執行這個流程,在 .net framework 的時候是會把這種錯誤 直接拋給 SynchronizationContext 並可能會直接中止應用程式,如果我們想要把這個錯誤記錄下來只能另外設定 UnhandledException 事件來處理。

AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.WriteLine(e.ExceptionObject.ToString());
};

在 .net core 的時代則是要透過 Middleware 對這種錯誤進行記錄,也就是說這種寫法第一會直接影響應用程式的執行,第二則是記錄錯誤不是用 try/catch 捕捉流程,而是要透過截然不同的機制來記錄這種錯誤這樣是非常不好的。

另一個問題是 async void 方法沒辦法被 await 會導致呼叫方不知道這個方法到底執行完了沒有,例如這個 SetSessionState 方法,在對它寫測試方法的時候 由於沒辦法 await SetSessionState 所以透過 Task.Delay 等待它完成,這個延遲方式並不能保證 SetSessionState 方法一定能設定完成, 所以就可能會導致單元測試隨時可能會成功或失敗。

public async void SetSessionState()
{
    var config = await ReadConfigFromNetwork();
    this.CurrentUser = config.User;
}
var t = new SessionManager();
t.SetSessionState();
await Task.Delay(1000);
Assert.Equal(t.User, "TestLibrary User");

唯一能使用 async void 的只有事件處理程式,因為 EventHandler 本身就不需要回傳值,不過還是要進行額外的保護,例如下面就是對 事件增加 try/catch 保護來確保所有事件都能被正常記錄到。

private async void OnCommand(object sender, RoutedEventArgs e)
{
	var viewModel = (DataContext as SampleViewModel);
	try
	{
		await viewModel.Update();
	}
	catch (Exception ex)
	{
		viewModel.Messages.Add(ex.ToString());
	}
}

或者是用以前 Effective C# 50 提到的方式,利用 side effect 紀錄所有的報錯訊息。

private async void OnCommand(object sender, RoutedEventArgs e)
{
	var viewModel = (DataContext as SampleViewModel);
	try
	{
		await viewModel.Update();
	}
	catch (Exception ex) when (logMessage(viewModel, ex))
	{
	}
}
private bool logMessage(SampleViewModel viewModel, Exception ex)
{
	viewModel.Messages.Add(ex.ToString());
	return false;
}

最好的處理方是就是避免使用 async void 都使用 Task 或 Task,這樣呼叫者才可以透過 await 確保執行結束或捕捉例外。

如果你確實需要 FireAndForget 的功能,可以建立擴充方法搭配 Func 或 Action 引數傳入非同步工作。

public static class Utilities
{
	public static async void FireAndForget(this Task task, Action<Exception> onErrors)
	{
		try
		{
			await task;
		}
		catch (Exception ex)
		{
			onErrors(ex);
		}
	}
	public static async void FireAndForget(this Task task, Func<Exception, bool> onError)
	{
		try
		{
			await task;
		}
		catch (Exception ex) when (onError(ex))
		{
		}
	}
}

就能透過這個擴充方法確保錯誤發生時能做出額外的處理。

void Main()
{
	FireAndForgetWork()
		.FireAndForget(ex => Console.WriteLine($"Error: {ex.Message}"));
}

public async Task FireAndForgetWork()
{
	await Task.Delay(TimeSpan.FromSeconds(3));
	throw new Exception("Exception");
}

或者更貼近真實需求,添加錯誤回復方法,這個技巧能夠同時記錄錯誤日誌以及透過 Action 添加錯誤回復或回滾的方法。

public static async void FireAndForget<TException>
	(this Task task,
	Action<TException> recovery, Func<Exception, bool> onError)
	where TException : Exception
{
	try
	{
		await task;
	}
	// relies on onError() logging method
	// always returning false:
	catch (Exception ex) when (onError(ex))
	{
	}
	catch (TException ex2)
	{
		recovery(ex2);
	}
}

Summary

這個做法提出 async void 的寫法基本上沒有任何好處,甚至會改變錯誤的運行方式以及記錄方式還有無法確認何時完成等問題,關鍵就是會 影響我們的使用習慣,雖然有一些技巧可以讓 async void 更加實用但本質上並沒有任何好處,建議還是直接用 Task 或 Task 而不是寫 async void 方法。