← 문제로

1. async/await 의 내부 동작(상태 머신)과 동기 블로킹 데드락

난이도 상
내 리뷰 · C#
해설 · C#

해설 — async/await 의 내부 동작(상태 머신)과 동기 블로킹 데드락

난이도: 상

답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트

요약

async/await 는 컴파일러가 상태 머신(state machine) 으로 변환한다. await 지점에서 메서드는 일시 중단되고, 기다리던 작업이 끝나면 continuation(이어지는 코드) 이 다시 스케줄된다. 이때 기본적으로 캡처된 SynchronizationContext(또는 TaskScheduler) 위에서 이어지려 한다. Deadlock()SynchronizationContext 가 있는 환경(UI 스레드, 구 ASP.NET 요청 컨텍스트)에서 그 컨텍스트 스레드를 .Result 로 블록한다. LoadAsyncawait Task.Delay 이후 continuation 은 그 같은(블록된) 컨텍스트 로 돌아오려 하지만, 컨텍스트는 이미 .Result 로 점유돼 있어 서로를 기다리는 데드락이 된다. 게임서버 워커(컨텍스트 없는 스레드풀)에선 데드락은 잘 안 나지만, 스레드풀 고갈·응답성 저하의 원인이 된다.

핵심 개념

  • await ≠ 블로킹: await 는 스레드를 멈추는 게 아니라, 완료 시 실행할 continuation 을 등록하고 호출 스택을 반환한다(메서드는 Task 를 즉시 돌려줌).
  • 컨텍스트 캡처: await 시점에 SynchronizationContext.Current(없으면 현재 TaskScheduler)를 캡처해, continuation 을 거기로 다시 게시(post)한다.
  • .Result/.Wait(): Task 가 끝날 때까지 현재 스레드를 동기 블록. 컨텍스트 스레드를 블록하면 그 컨텍스트로 돌아와야 하는 continuation 이 영원히 못 돌아온다.

단계별 내부 동작

  1. 컴파일러는 LoadAsyncIAsyncStateMachine 을 구현하는 구조체로 변환한다. 본문은 MoveNext() 안의 상태별 switch 로 쪼개진다(상태 -1/0/1 …).
  2. MoveNext: Task.Delay(100) 의 awaiter 를 얻고 IsCompleted 를 검사. 아직 미완료이므로 awaiter.OnCompleted(MoveNext)continuation 등록반환(메서드는 미완료 Task 를 호출자에게 돌려줌). 이때 AsyncTaskMethodBuilder 가 컨텍스트를 캡처.
  3. 100ms 후 Delay 완료 → 등록된 continuation 이 캡처된 컨텍스트에 게시되어 MoveNext 가 다시 호출됨 → return 42 로 결과 Task 를 완료.
  4. Deadlock(): 2번에서 반환된 미완료 Task.Result 로 블록. 컨텍스트 스레드가 블록됨 → 3번의 continuation 이 그 컨텍스트로 post 되지만 처리될 수 없음 → 상호 대기(데드락).

흔한 오해·함정

  • "async 면 새 스레드에서 돈다" ✗ — await 전까지는 호출 스레드에서 동기 실행되고, Task.Delay 같은 I/O 성 대기는 스레드를 점유하지 않는다(타이머/완료포트). 새 스레드가 생기는 게 아니다.
  • .Result/.Wait() 로 동기 대기 ✗ — 컨텍스트 있는 환경에선 데드락, 없는 환경에서도 스레드풀 스레드를 블록해 고갈을 유발. 해법: 끝까지 async(호출 체인을 await 로 전파).
  • ConfigureAwait(false): continuation 을 캡처된 컨텍스트로 되돌리지 않고 스레드풀에서 잇는다 → LoadAsync 내부를 await Task.Delay(100).ConfigureAwait(false) 로 바꾸면 이 데드락은 사라진다(라이브러리 코드 권장). 단 근본 해법은 동기 블로킹을 없애는 것.
  • async void(C) ✗ — 반환 Task 가 없어 호출자가 완료/예외를 관측 못 함. throw 는 잡히지 않고 SynchronizationContext 로 올라가 프로세스 크래시가 될 수 있다. 이벤트 핸들러 외에는 async Task 를 쓴다.

수정 방향(설명용)

// 끝까지 async: 동기 블로킹 제거
public async Task<int> NoDeadlockAsync() => await LoadAsync();

// 라이브러리 내부라면 컨텍스트 복귀를 끊는다
public async Task<int> LoadAsync()
{
    await Task.Delay(100).ConfigureAwait(false);
    return 42;
}

면접 포인트 (예상 질문)

  1. await 한 줄이 컴파일 후 무엇이 되나(상태 머신/MoveNext/awaiter.OnCompleted)? await 가 스레드를 블록하지 않는다는 게 무슨 뜻인가?
  2. .Result 데드락이 일어나는 메커니즘을 컨텍스트 캡처 관점에서 단계별로 설명하라. ConfigureAwait(false) 가 왜 이를 푸는가, 그리고 왜 그게 근본 해법은 아닌가?
  3. async voidasync Task 의 차이(예외 전파·관측 가능성)는? 언제 async void 가 정당한가?