.NET에서 async, await, Task가 등장한 이후 비동기 프로그래밍 방식은 크게 변화하였다.
과거에는 스레드 동기화 객체를 사용하여 작업 완료를 기다렸지만, 현재는 Task 기반 비동기 모델을 통해 보다 효율적인 구현이 가능하다.
이번 글에서는 비동기 환경에서 자주 사용되는 시그널링(Signaling) 패턴과 TaskCompletionSource를 활용한 현대적인 구현 방법을 소개한다.
문제 상황
다음과 같은 구조를 생각해보자.
- F1()은 int 값을 반환한다.
- 호출자는 await F1()으로 결과를 기다린다.
- 실제 결과는 F1()이 아니라 F2()에서 생성된다.
- F2()는 결과 생성 시점에 F1()의 대기를 해제해야 한다.
즉, 결과를 기다리는 주체와 결과를 생성하는 주체가 서로 다른 상황이다.
기존 방식 : ManualResetEvent
private int _result;
private readonly ManualResetEvent _event = new(false);
private async Task<int> F1()
{
_event.Reset();
await Task.Run(() => _event.WaitOne());
return _result;
}
private void F2(int result)
{
_result = result;
_event.Set();
}
동작 과정
문제점
- 대기 중인 스레드를 실제로 점유한다.
- ThreadPool 자원을 낭비한다.
- async/await 기반 설계와 잘 맞지 않는다.
현대적인 방식 : TaskCompletionSource
private TaskCompletionSource<int>? _resultTcs;
private Task<int> F1()
{
_resultTcs = new TaskCompletionSource<int>();
return _resultTcs.Task;
}
private void F2(int result)
{
_resultTcs?.TrySetResult(result);
}
호출부
int result = await F1();
동작 과정
실무 권장 패턴
실제 서비스 코드에서는 RunContinuationsAsynchronously 옵션을 함께 사용하는 것을 권장한다.
private TaskCompletionSource<int>? _resultTcs;
public Task<int> F1()
{
_resultTcs =
new TaskCompletionSource<int>(
TaskCreationOptions.RunContinuationsAsynchronously);
return _resultTcs.Task;
}
왜 필요한가?
기본 TaskCompletionSource는 TrySetResult() 호출 시 await 이후 코드가 같은 스레드에서 즉시 실행될 수 있다.
int result = await F1(); DoSomething();
즉, 결과를 전달하려던 코드가 소비자 측 후속 로직까지 실행하게 될 수 있다.
RunContinuationsAsynchronously 적용 후
- 예측 가능한 실행 흐름
- 데드락 위험 감소
- UI 응답성 향상
- 생산자와 소비자 역할 분리
결론
ManualResetEvent는 스레드를 점유하는 블로킹 방식이다.
TaskCompletionSource는 스레드 점유 없이 결과 완료 시점을 외부에서 제어할 수 있으며, 현대적인 .NET 비동기 프로그래밍에 가장 적합한 시그널링 기법이다.