async void는 되도록이면 사용하지 말자.

2024. 3. 30. 05:21C#

 

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#이 이것을 허용한 이유가 비동기 이벤트 핸들러를 위해서라고 한다.)