← 문제로

21. AsyncLocal<T> 와 ExecutionContext 흐름 (await 를 가로지르는 컨텍스트)

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

해설 — AsyncLocal 와 ExecutionContext 흐름 (await 를 가로지르는 컨텍스트)

난이도: 최상

요약

AsyncLocal<T> 값은 ExecutionContext(EC) 안에 담기고, EC 는 각 await 지점에서 캡처되어 연속(continuation)에서 복원된다. 그래서 값이 물리 스레드가 아니라 논리적 비동기 흐름을 따라 전파된다. AsyncLocal.Value 를 바꾸면 현재 흐름과 그 아래(호출된 async)에는 보이지만, 호출자에게로 거슬러 올라가 반영되지는 않는다(EC 의 copy-on-write + 호출 후 복원). 예상 출력:

A: 1          // Main 이 설정
B: 1          // Child 로 아래로 흐름
C: 42         // Child 내부에서 바꾼 값이 자기 await 를 가로질러 유지
D: 1          // ★ Child 의 변경은 Main 으로 흐르지 않음 → 여전히 1
E: 10,20      // 두 브랜치가 각자 격리된 값을 봄
F: 1          // ★ 브랜치의 변경도 Main 으로 흐르지 않음 → 여전히 1

핵심 개념

  • ExecutionContext: 논리적 호출 흐름을 따라 "흘러야 하는" 앰비언트 상태의 컨테이너. AsyncLocal 값들의 불변 맵을 담는다.
  • 캡처/복원: await 로 미완료를 만나면 상태 머신이 continuation 을 예약하면서 현재 EC 를 캡처한다. 나중에 어느 스레드에서 재개되든 그 EC 를 복원한 뒤 이어서 실행한다. Task.Run/타이머/스레드풀 큐잉도 기본적으로 EC 를 흐른다.
  • copy-on-write: AsyncLocal.Value = x 는 현재 EC 의 async-local 맵을 새 맵으로 교체(불변 갱신)한다. 이 새 EC 는 현재 실행 지점 이후로만 쓰인다.

단계별 내부 동작

  1. A: MainCtx.Value=1 → 현재 EC 의 맵이 {Ctx:1} 로 교체. 출력 1.
  2. B: await Child() 호출로 진입. ChildMain 의 EC(=,{Ctx:1})를 이어받아 시작 → Ctx.Value 는 1. 아래로 흐름.
  3. C: ChildCtx.Value=42Child 의 EC 맵이 {Ctx:42} 로 교체(자기 지점 이후 유효). await Task.Yield() 가 이 EC(42)를 캡처하고 continuation 에서 복원 → 재개 후에도 42.
  4. D: Childawait 가 모두 끝나 Main 으로 제어가 돌아오면, Main 자신의 continuation 은 Main 의 EC(=,{Ctx:1})로 복원된다. Child 가 만든 {Ctx:42} EC 는 Child 논리 흐름에만 속하고 Main 으로 올라오지 않는다. 그래서 1. — 이게 "아래로만 흐르고 위로는 안 흐른다" 의 실체.
  5. E: Branch(10), Branch(20) 은 각자 자기 EC 사본에서 Ctx.Value 를 10/20 으로 두고, 각자의 await 를 가로질러 그 값을 유지 → 반환값 10, 20. 동시에 실행돼도 EC 사본이 분리돼 서로 격리.
  6. F: 브랜치들의 Ctx 변경도 Main 으로 흐르지 않으므로 Main 의 값은 여전히 1.

[ThreadStatic] 과의 대비

  • [ThreadStatic] 필드는 물리 스레드에 묶인다. await 이후 continuation 은 (SynchronizationContext 가 없는 서버/콘솔에선) 다른 스레드풀 스레드에서 재개될 수 있고, 그 스레드의 ThreadStatic 은 기본값(0)이다 → 값이 "사라진 것처럼" 보인다. 게다가 같은 물리 스레드가 무관한 다른 요청을 처리할 때 잔재가 남아 교차 오염될 수도 있다.
  • 따라서 "요청 스코프 컨텍스트(플레이어/트레이스 ID)" 는 AsyncLocal 로 구현해야 한다. ThreadStatic 은 동기 코드나 "이 물리 스레드 한정" 캐시에만.

흔한 오해·함정

  • "AsyncLocal 을 콜리에서 세팅하면 호출자도 본다" → 아니다(D, F). 흐름은 단방향(아래로). 값을 위로 전달하려면 반환값/명시적 out 을 써야 한다.
  • 성능: AsyncLocal.Value 쓰기는 EC 맵 copy-on-write 를 유발한다. 고빈도 세팅은 할당·복사 비용. 요청 시작 시 한 번 세팅하는 패턴이 이상적.
  • EC 흐름 억제: ExecutionContext.SuppressFlow()ConfigureAwait(false) 의미를 혼동. ConfigureAwait(false)SynchronizationContext 복귀를 끊을 뿐 EC(따라서 AsyncLocal) 흐름은 유지된다.
  • Thread.Start 로 넘어갈 때: 기본 EC 캡처가 되지만, 커스텀 스레드/네이티브 콜백 경계에서는 흐름이 끊길 수 있으니 확인 필요.
  • 가변 참조를 AsyncLocal 에 담기: 값이 흐름별로 격리돼도 그 안의 참조가 가리키는 객체는 공유된다. 격리되는 건 "슬롯" 이지 대상 객체가 아님.

면접 포인트

  1. AsyncLocal 이 스레드가 아니라 ExecutionContext(논리 흐름) 를 따라 전파되는 원리 — await 지점의 캡처/복원.
  2. 왜 콜리의 변경이 호출자로 흐르지 않는가(EC copy-on-write + 호출 후 복원) — D/F 의 근거.
  3. 요청 스코프 컨텍스트는 AsyncLocal, ThreadStatic 은 await 이후 신뢰 불가(다른 스레드 재개·교차 오염) — 서버에서의 선택 기준과 ConfigureAwait(false) 가 EC 흐름을 끊지 않는다는 점.