[ Item 27 : 비동기 작업에는 비동기 메서드를 사용하라 ]
동기 메서드에서는 코드들이 작성한 순서대로 실행된다.
하지만 비동기 메서드에서는 꼭 그렇지 않을 수 있다.
비동기 메서드는 내부 코드를 모두 수행하기 전에 미리 반환될 수 있으며,
내부적으로 요청한 비동기 작업이 완료되는 시점에 맞추어 수행을 중단했던 지점부터 다시 수행을 이어간다.
private async Task SomeMethodAsync()
{
Console.WriteLine($"Entering {nameof(SomeMethodAsync)}");
var awaitable = SomeMethodReturningTask();
Console.WriteLine($"In {nameof(SomeMethodAsync)}, before the await");
var result = await awaitable;
Console.WriteLine($"In {nameof(SomeMethodAsync)}, after the await");
}
SomeMethodReturningTask()을 수행하는 부분을 만나면 해당 메서드가 실행되고, 결과 값을 받을 수 있는 Task 객체를 awaitable 변수에 할당한다.
1. await 명령어에 도달했을 때 비동기 메서드가 완료된 경우
- 자연스럽게 결과 값이 result에 할당되고 동기식으로 나머지가 진행된다.
2. await 명령어에 도달했을 때 비동기 메서드가 완료되지 않은 경우
- 우선 메서드에서 빠져나오고, 다른 작업을 이어간다.
이후 Task가 완료되면 await 쪽에 작업이 완료되었다는 신호를 보내고 중단된 지점부터 다시 코드가 진행된다.
비동기 메서드 호출 시 이전에 로직을 수행하던 환경을 되돌리는 작업이 중요한데, 이와 관련된 일련의 과정은 SynchronizationContext 클래스가 담당한다.
이 클래스는 대기 중이던 Task가 완료되어 비동기 메서드 내에서 코드 수행을 재개할 때, 이전 수행 환경과 컨텍스트를 복원하는 역할을 한다.
예를 들어, GUI 애플리케이션의 경우 Dispatcher를 이용하여 Task를 스케줄링한다.
웹 애플리케이션의 경우 QueueUserWorkItem을 사용해 스케줄링한다.
만약 대기 중인 비동기 태스크에서 예외가 발생하면 SynchronizationContext로 예외를 전달한다.
만약 비동기 메서드를 사용하는데 await 를 사용하지 않아서 되돌아갈 곳이 없다면 SynchronizationContext 의 예외를 받을 수 없게 된다.
public void ExceptionTest()
{
try
{
// _ = at.SomeMethodAsync(); // 이 경우엔 내부에서 발생한 예외를 catch 할 수 없다.
await at.SomeMethodAsync();
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
모든 Task는 대기(await)하는 것이 매우 중요하다.
[ Item 28 : async void 메서드는 절대 작성하지 말라 ]
async void 로 메서드를 작성하면 이 메서드가 던지는 예외를 호출 측에서 잡지 못한다.
Item 27에서 봤듯이 비동기 메서드는 Task 객체를 통해 예외를 보고한다.
비동기 메서드가 작업을 수행하다 예외를 던져서 Task 객체가 오류 상태가 되면,
awiat를 호출한 코드가 다시 스케줄링 될 때 예외가 발생하게 되는 것이다.
async void 의 문제점
- async void로 선언된 메서드인 경우 Task 객체를 전달하지 않기 때문에 호출자에게 예외를 전파할 수 있는 방법이 없다.
async void 메서드 → Fire And Forgot(실행하면 잊어라)의 성격을 띈다. - async void 메서드 실행 중 예외가 발생하면 해당 SynchronizationContext 에서 수행 중인 스레드를 중단해 버린다.
이때 사용자는 어떤 통지도 받지 못하고, 어떤 예외 처리기도 수행하지 못하고 스레드가 감쪽같이 사라지는 걸 볼 수 있다.
public async void SetSessionState()
{
var config = await ReadConfigFromNetwork();
CurrentUser = config.User;
}
public void Test()
{
var manager = new SessionManager();
manager.SetSessionState();
await Task.Delay(1000);
Assert.Equal(t.User, "User");
}
SetSessionState() 를 테스트 하기 위한 코드이다.
이 테스트 코드는 await Task.Delay(1000) 라인 때문에 안좋은 코드가 될 수 있다.
바로 delay 직전의 t.SetSessionState() 수행 시 비동기 메서드가 언제 끝날 지 모르기 때문에 1초가 충분할 수도, 그렇지 않을 수도 있다.
→ async void 메서드가 언제 끝날 지 모르는 크리티컬한 문제 때문에 테스트 결과가 항상 바뀔 수 있는 코드이고 비동기를 제대로 이해하지 못한 테스트 코드이다.
async void 를 사용하면 안되는 이유를 지금까지 알아보았다.
가능하면 async Task 메서드를 생성해야 한다고 알게 되었을 텐데, 그럼에도 async void 가 허용되는 이유는 뭘까?
바로 async, await 가 도입되기 전에 규칙이 확립된 ‘이벤트 핸들러’에서 사용해야하기 때문이다.
- 이벤트 핸들러에 비동기로 접근해야하는 상황이 있는건가?
- 이벤트 핸들러는 일반적으로 사용자가 호출하는 코드가 아닌데, 호출측으로 반환된 Task로 무엇을 해야하는가?
이처럼 이벤트가 async, await를 고려하지 못하고 만들어졌기 때문에 async void 이벤트 핸들러가 작성될 수 있다.
이때는 비동기 이벤트 핸들러를 가능한 안전하게 작성해야 한다.
private async void OnCommand(object sender, RoutedEventArgs e)
{
...
try
{
// 주요 로직 실행
}
catch (Exception e)
{
// 예외 기록, throw 하지 않음
}
}
이 코드는 단순히 모든 예외를 기록하고 이전과 같이 실행을 이어가도 문제가 없을 것이라 가정한다.
많은 경우에 이렇게 처리하더라도 안전하다.
그런데 만약 걷잡을 수 없는 수준의 문제가 발생하여 예외가 발생하여 시스템으로 전달해야 한다면? 그리고 특정 예외는 복구할 수 있다면?
아래는 FileNotFoundException은 복구할 수 있지만, 그 외의 예외에 대해서는 대처할 수 없는 상황에 사용할 수 있는 코드이다.
public static async void FireAndForgot<TException>(
this Task task,
Action<TException> recovery,
Func<Exception, bool> onError) where TException : Exception
{
try
{
await task;
}
catch (Exception ex) when (onError(ex))
{
}
catch (TException ex2)
{
recovery(ex2);
}
}
위와 같이 Task에 대한 확장 메서드를 작성해서 async void 에 대해 일반적이고 재사용 가능한 예외처리를 수행할 수 있다.
[ Item 29 : 동기, 비동기 메서드를 함께 사용해서는 안 된다 ]
비동기 메서드를 사용한다는 것은 작업이 오래 걸릴 수 있으니 호출자 스레드는 그동안 다른 유용한 일을 하라는 의미이다.
동기 메서드는 작업을 수행하는 시간에 상관없이 호출자와 같은 리소스를 사용해서 모든 작업을 완료한다.
그리고 호출자는 동기 작업이 끝날 때까지 멈춰 있다.
비동기 작업이 완료될 때까지 기다리는 동기 메서드를 만들지 말라.
비동기 코드를 감싼 동기 코드가 문제를 일으키는 원인 3가지
1. 서로 다른 예외 처리 방식
비동기 태스크에서 다양한 예외가 발생할 수 있기 때문에 Task 클래스 내에는 여러 예외를 담기 위한 리스트를 가지고 있다.
여러 예외가 발생한 비동기 작업이 종료되어 await 명령이 호출되는 시점에 리스트에 예외들이 담겨있다.
만약 실패한 태스크에 대해 Task.Wait() 을 호출하거나 Task.Result 를 읽으려 하면 모든 예외를 담은 AggregateException 이 던져진다.
이때는 AggregateException을 캐치하여 리스트의 예외들을 꺼내봐야 한다.
public async Task<int> ComputeUsageAsync()
{
try
{
var a = await GetAAsync();
var b = await GetBAsync();
return a + b;
}
catch (KeyNotFoundException e)
{
return 0;
}
}
public int ComputeUsage()
{
try
{
var a = GetAAsync().Result;
var b = GetBAsync().Result;
return a + b;
}
catch (AggregateException e) when (e.InnerExceptions.FirstOrDefault()?.GetType()
== typeof(KeyNotFoundException))
{
return 0;
}
}
위 두 메서드를 비교해서 보면 왜 비동기 메서드의 결과를 .Result로 가져오면 안되는 지 알 수 있다.
.Wait()를 사용하면 안되는 이유도 동일하다.
2. 잠재적 데드락 위험성
public async Task SimulateWorkAsync()
{
await Task.Delay(1000);
}
public void SyncOverAsyncDeadlock()
{
var delayTask = SimulateWorkAsync();
delayTask.Wait();
}
위 코드는 일반적인 콘솔 애플리케이션에서는 문제없이 동작한다.
콘솔의 SynchronizationContext는 스레드 풀에서 여러 스레드를 가져와 사용하기 때문이다.
하지만 GUI나 웹 프레임워크에서는 SynchronizationContext 스레드를 하나만 가지기 때문에 이런 상황에서 데드락에 걸리게 된다.
예를 들어 WPF 같은 GUI 프레임 워크는 SynchronizationContext로 메인 스레드(== UI 스레드)만을 사용하기 때문이다.
데드락이 걸리는 과정을 살펴보자.
1. 만약 SyncOverAsyncDeadlock 메서드가 UI 스레드에서 호출되면, UI 스레드는 SimulateWorkAsync의 완료를 동기적으로 기다린다.
2. 그리고 SimulateWorkAsync 내부에서 Task.Delay를 await 하고 있기 때문에 Task.Delay 이후의 작업을 UI 스레드에서 실행하려고 스레드를 기다리게 된다. (SimulateWorkAsync 를 UI 스레드가 실행했기 때문이다)
3. Wait() 를 사용해서 SimulateWorkAsync의 완료를 기다리고 있기 때문에 await의 완료 이후 연속된 작업을 UI 스레드에서 실행할 수 없다. UI 스레드가 이미 Wait 호출로 인해 차단되어 있기 때문이다.
4. 결과적으로, await는 UI 스레드로 돌아가려고 하지만 UI 스레드는 이미 대기 상태이므로 await는 계속 대기 상태에 머물게 되고, UI 스레드도 Task의 완료를 기다리며 차단된 상태로 남아 있게 된다.
해결 방법
Wait(), Result 를 사용하지 않고, 비동기 메서드를 await 하는 것이다.
SyncOverAsyncDeadlock를 async로 만들고 await delayTask 를 통해 호출하면 된다.
3. 리소스 낭비
비동기 작업을 동기적으로 대기하게 만들면, 스레드가 블로킹되어 리소스가 낭비된다.
특히 GUI 앱에서 UI 스레드가 블로킹되면 앱이 뚝뚝 끊기고 멈추는 현상이 자주 발생하고, 웹 애플리케이션에서는 동시에 많은 요청을 처리해야 하는데 성능이 저하된다.
수행 시간이 오래 걸리는 CPU 중심 작업을 비동기로 수행하지 말라.
비동기 프로그래밍의 핵심 목적은 I/O 작업(파일 접근, 네트워크 요청)처럼 대기 시간이 긴 작업을 최적화 하는 것이다.
이러한 작업은 실제로 CPU가 아무것도 하지 않고 대기하는 상황을 만들기 때문에 반드시 비동기 프로그래밍이 필요하다.
하지만 CPU Bound 작업(복잡한 계산, 데이터 처리)은 CPU 작업을 집중적으로 사용하기 때문에 굳이 비동기로 처리하면 오히려 여러 문제를 일으킬 수 있다.
1. 리소스 낭비
CPU 작업을 비동기로 처리하면, 해당 작업을 실행하는 동안에도 CPU는 계속해서 활성화 되어있다.
비동기 작업은 대기 상태에 들어간 동안 스레드를 반환하여 다른 작업에 사용할 수 있게 하지만, CPU 중심 작업에서는 CPU가 계속 일하고 있기 때문에 이러한 비동기의 이점을 활용할 수 없다.
2. 성능 저하
비동기 메서드는 작업을 백그라운드 스레드로 옮기고, 완료될 때까지 기다리는 오버헤드를 일으킨다.
CPU 중심 작업의 경우 이런 컨텍스트 스위칭같은 오버헤드가 오히려 성능을 저하시킬 수 있다.
- CPU 작업은 가능한 한 연속적으로 처리되어야 최적의 성능을 발휘할 수 있다.
비동기 메서드를 이용해 작업을 분리하다 보면 그 여파가 애플리케이션 전반으로 퍼지게 된다.
비동기 API를 하나 추가하면 그 메서드를 활용해야 하는 다른 메서드들도 비동기가 될 것이기 때문이다.
이건 올바른 현상이며 콜스택에 비동기 메서드의 비중이 점점 늘어나게 된다.
[ Item 30 : 비동기 메서드를 사용해서 스레드 생성과 콘텍스트 전환을 피하라 ]
모든 비동기 작업이 서로 다른 스레드에서 수행된다고 착각하기 쉽다.
물론 그럻게 수행될 수 있지만, 비동기 작업이라도 새로운 스레드를 생성하지 않는 경우도 많다.
- 파일 입출력은 비동기이지만 스레드가 아닌 I/O 완료 포트를 사용한다.
- 웹 요청도 비동기이지만 스레드가 아닌 네트워크 인터럽트를 사용한다.
이런 태스크를 비동기로 수행하면 스레드가 필요하지 않으므로, 스레드를 다른 작업에 쓸 수 있다.
워커 스레드에 작업을 위임한다는 것은, 작업을 요청한 스레드를 자유롭게 해주기 위해서 다른 스레드를 생성하는 것이다. (자유를 위해 스레드 생성 비용 지불)
비동기 작업과 스레드 생성에 관해서 2개의 관점에서 살펴보자
1. GUI 애플리케이션
비동기 작업 시 새 스레드를 생성하는 작업은 GUI 애플리케이션에서 UI 스레드를 위해 많이 사용된다.
GUI 애플리케이션의 사용자는 UI를 통해 수행한 작업이 완료되기 전이라도, 여전히 다른 UI와의 상호작용이 부드럽게 이어지길 원한다.
하지만 UI 스레드가 사용자의 요청 하나를 처리하기 위해 수 초 이상 걸린다면 사용자는 마치 고장난 것 같은 화면을 보게 될 것이다.
이 문제를 해결하기 위해 작업을 위임받아 대신 수행해줄 다른 리소스(워커 스레드)가 필요하다.
이를 통해 UI 스레드는 오래 걸리는 작업을 워커 스레드로 넘겨줄 수 있고, UI는 사용자의 다른 입력에 즉각적으로 반응할 수 있을 것이다.
2. 서버 애플리케이션
서버에 커다란 CPU Bound 작업이 들어왔다고 가정해보자.
태스크를 처리하기 위해 다른 스레드를 생성해 스레드 풀에 추가한다.
그리고 태스크를 호출한 스레드는 할 일이 없으므로 스레드 풀로 수거되어 다른 일을 할당받는다.
하지만 이 때문에 오버헤드가 더 커지게 된다.
원래 작업을 수행하던 스레드를 스레드 풀로 수거하려면 SynchronizationContext 에 웹 요청에 대한 상태를 저장해두어야 하고, 이후 태스크가 완료되면 저장된 상태를 복원해야 한다.
따라서 별다른 리소스 절약도 없고 2번의 컨텍스트 전환만 초래하게 된 것이다.
만약 CPU Bound 작업을 장시간 수행해야 한다면, 해당 작업을 다른 프로세스 잡이나 다른 머신에서 수행하도록 하자.
'Lang > C#' 카테고리의 다른 글
[More Effective C#] Chapter 4. 요약 (1) (1) | 2024.04.21 |
---|---|
[More Effective C#] Chapter 3. 요약 (2) (0) | 2024.04.07 |
[More Effective C#] Chapter 2. 요약 (2) (0) | 2024.03.24 |
[More Effective C#] Chapter 2. 요약 (1) (4) | 2024.03.17 |
[More Effective C#] Chapter 1. 요약 (0) | 2024.03.10 |