2024. 3. 30. 05:21ㆍC#
Task를 이용해 비동기 코드를 작성할 때, async void는 되도록이면 사용하지 않는 것이 좋다.
async void가 가진 문제점들은 다음과 같다.
1. Task를 반환하지 않으므로 호출자에서 await을 통해 완료 되었는지 알 수 없다.
누군가가 아래와 같이 스트림에 바이트 배열을 기록하는 클래스를 만들었다고 해보자.
public class DataWriter
{
public async void WriteDataAsync(byte[] data);
public int Length { get; private set; }
}
만약 WriteDataAsync 비동기 함수의 작업이 언제 끝나는지 알고싶다면 어떻게 해야할까?
방법이 없다. Polling을 통해 데이터가 모두 기록되었는지 확인하는 등 비효율적인 방법을 사용해야 할 것이다.
2. 1번으로 인해 테스트가 어려워진다
다음과 같이 유닛 테스트를 작성했다고 해보자.
// Arrange
var dataWriter = new DataWriter();
byte[] data = new byte[64];
// Act
dataWriter.WriteDataAsync(data);
// Assert
Assert.AreEqual(64, dataWriter.Length);
WriteDataAsync 함수의 알고리즘에 문제가 없다고 가정했을때, Assert가 참이라고 할 수 있을까?
1번의 문제로 인해 작업이 완료되기 전에 Assert가 먼저 실행되는 경우가 생길 수 있을 것이다.
3. async void 함수에서 던진 예외는 처리하기 힘들다.
일단 일반적인 Task를 반환하는 코드를 보자.
private async Task DoSomething()
{
try
{
await TestAsync();
}
catch (System.Exception e)
{
Console.WriteLine(e);
}
}
private async Task ThrowAsync()
{
throw new System.InvalidOperationException();
}
위 코드에서 ThrowAsync 함수에서 던진 예외는 DoSomething 함수의 catch에 걸리게 되는데,
다음과 비슷한 작업이 수행되기 때문에 그렇다.
private void DoSomething()
{
try
{
ThrowAsync().GetAwaiter().GetResult();
}
catch (System.Exception e)
{
Console.WriteLine(e);
}
}
private Task ThrowAsync()
{
Task task; // 반환될 Task
try
{
throw new System.InvalidOperationException();
}
catch (System.Exception e)
{
task.SetException(e);
}
return task;
}
ThrowAsync 함수에서 반환되는 Task에 예외가 저장되며, 호출자에서 await을 하는 순간 예외가 던져지게 되는 것이다. 따라서 호출자인 DoSomething 함수는 ThorwAsync 함수가 던진 예외를 처리할 수 있게된다.
그럼 이제 ThorwAsync 함수가 Task를 반환하는게 아니라 다음과 같이 void를 반환하게 해보자.
private void DoSomething()
{
try
{
TestAsync();
}
catch (System.Exception e)
{
Console.WriteLine(e);
}
}
private async void ThrowAsync()
{
throw new System.InvalidOperationException();
}
이제 ThrowAsync 함수는 Task를 반환하지 않으므로 예외를 호출자 함수로 전달할 수 없으며,
따라서 DoSomething 함수의 catch는 절대 실행되지 않는다.
이 경우, Task.SetException이 호출될 때 다음과 같이 SynchronizationContext에 예외를 던지는 콜백을 Post한다.
targetContext.Post(state => { throw PrepareExceptionForRethrow((Exception)state); }, exception);
SynchronizationContext에서는 이 콜백을 실행할 때 try/catch를 통해 (예외를 로깅하는 등) 예외를 처리할 수 있다. 예외를 어떻게 처리하는지는 각 프레임워크(WinForm, WPF, ASP.NET Core 등)가 제공하는 SynchronizationContext의 구현마다 다를 것이다. 또는 커스텀 SynchronizationContext를 작성할 수도 있다.
만약 SynchronizationContext가 null이라면, 다음과 같이 스레드풀에 콜백을 Post한다.
ThreadPool.QueueUserWorkItem(state => { throw PrepareExceptionForRethrow((Exception)state); }, exception);
이 경우, AppDomain.UnhandledException 이벤트를 등록하면 예외 처리를 할 수는 있지만 앱 크래시를 피할 수 없다.
결국 어떠한 경우에도 async void 함수에서 발생한 예외를 처리가 쉽지는 않다.
ㅡ
위와 같은 문제점들로 인해 async void 함수의 사용은 되도록이면 피해야 하며,
반환 타입이 void여야 하는 이벤트 핸들러의 경우로 사용을 제한하는 것이 좋다.
(async void 함수가 가능한 언어는 흔치 않은데, C#이 이것을 허용한 이유가 비동기 이벤트 핸들러를 위해서라고 한다.)