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 는 현재 실행 지점 이후로만 쓰인다.
단계별 내부 동작
- A:
Main이Ctx.Value=1→ 현재 EC 의 맵이 {Ctx:1} 로 교체. 출력 1. - B:
await Child()호출로 진입.Child는Main의 EC(=,{Ctx:1})를 이어받아 시작 →Ctx.Value는 1. 아래로 흐름. - C:
Child가Ctx.Value=42→ Child 의 EC 맵이 {Ctx:42} 로 교체(자기 지점 이후 유효).await Task.Yield()가 이 EC(42)를 캡처하고 continuation 에서 복원 → 재개 후에도 42. - D:
Child의await가 모두 끝나Main으로 제어가 돌아오면,Main자신의 continuation 은Main의 EC(=,{Ctx:1})로 복원된다.Child가 만든 {Ctx:42} EC 는Child논리 흐름에만 속하고Main으로 올라오지 않는다. 그래서 1. — 이게 "아래로만 흐르고 위로는 안 흐른다" 의 실체. - E:
Branch(10),Branch(20)은 각자 자기 EC 사본에서Ctx.Value를 10/20 으로 두고, 각자의await를 가로질러 그 값을 유지 → 반환값 10, 20. 동시에 실행돼도 EC 사본이 분리돼 서로 격리. - 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 에 담기: 값이 흐름별로 격리돼도 그 안의 참조가 가리키는 객체는 공유된다. 격리되는 건 "슬롯" 이지 대상 객체가 아님.
면접 포인트
- AsyncLocal 이 스레드가 아니라 ExecutionContext(논리 흐름) 를 따라 전파되는 원리 — await 지점의 캡처/복원.
- 왜 콜리의 변경이 호출자로 흐르지 않는가(EC copy-on-write + 호출 후 복원) — D/F 의 근거.
- 요청 스코프 컨텍스트는 AsyncLocal, ThreadStatic 은 await 이후 신뢰 불가(다른 스레드 재개·교차 오염) — 서버에서의 선택 기준과
ConfigureAwait(false)가 EC 흐름을 끊지 않는다는 점.