Runtime Async와 관련해서는 많은 변화가 발생할 수 있는 주제입니다. 그동안의 맥락을 정리해두면 좋을 것 같아 추가 정보를 Claude Opus를 통해 정리한 내용을 포스팅합니다. 
Runtime Async는 .NET의 async/await 구현 방식 자체를 근본적으로 바꾸는 변화이므로, 그 배경부터 현재 상태까지 정리해 드리겠습니다.
배경: Green Threads에서 Runtime Async로
원래 .NET 8 시기에 Green Threads 실험이 먼저 진행되었습니다. OS가 아닌 런타임이 관리하는 경량 스레드를 도입하려는 시도였는데, 기존 async 모델과의 상호작용 복잡성, 네이티브 코드 인터롭 문제, Shadow Stack 등 보안 완화 기법과의 호환성 문제로 중단되었습니다.
이후 팀은 방향을 전환하여, async/await를 컴파일러 기능이 아닌 런타임 기능으로 이동시키는 “async2” 실험을 시작했습니다. JIT 기반 구현이 성능과 유지보수 양쪽에서 더 유리하다는 결론이 나왔고, 기존 컴파일러 기반 async와 최소 동등하거나 그 이상의 성능을 달성했습니다.
핵심 변화: 컴파일러 State Machine → 런타임 레벨 처리
기존 async/await의 핵심 문제는 이것입니다. 현재 async 메서드를 작성하면 C# 컴파일러가 상태 머신(state machine)을 생성하는데, JIT 컴파일러는 async/await 자체에 대해 아무것도 모릅니다. 런타임 입장에서는 그냥 일반 코드일 뿐이라 최적화 여지가 제한적입니다.
Runtime Async는 이를 뒤집습니다. C# 컴파일러가 전체 상태 머신을 구축하는 대신 AsyncHelpers.Await(...) 호출을 생성하고, 런타임이 이를 가로채어 Task가 아직 완료되지 않은 경우 필요한 상태만 저장한 후 메서드를 일시 중단했다가 결과가 준비되면 재개합니다.
구체적으로 어떤 차이가 생기는지 Steven Giesel의 벤치마크 결과가 잘 보여줍니다. 동일한 .NET 11 Preview 1에서 runtime-async를 켠 경우와 끈 경우를 비교하면, 동기적으로 완료되는 async 호출 체인에서 약 33% 속도 향상(192ns → 128ns)과 약 42% 할당 감소(1.61KB → 928B)가 관측되었습니다. 물론 합성 벤치마크이므로 실제 워크로드에서의 차이는 다를 수 있습니다.
성능 개선의 원리
핵심은 로컬 변수 처리 방식입니다. .NET 10까지는 상태 머신이 모든 로컬 변수를 생성된 상태 머신 클래스의 필드로 호이스팅 (글 하단을 참고해주세요) 합니다. .NET 11의 Runtime Async에서는 런타임이 변수를 스택에 유지하다가, 실제로 await 경계를 넘어 필요한 경우에만 힙으로 spill합니다. 즉, 동기적으로 완료되는 hot path에서는 불필요한 힙 할당이 대폭 줄어듭니다.
Lowered 코드를 보면 이 차이가 극명합니다. 기존 방식은 AsyncTaskMethodBuilder, IAsyncStateMachine 구현체, 100줄 이상의 상태 머신 코드가 생성되지만, 새 방식은 원본 코드와 거의 동일한 형태에 AsyncHelpers.Await 호출만 추가된 형태입니다.
디버깅 경험 개선
성능 외에 실질적으로 큰 의미가 있는 부분이 디버깅입니다. 개발자 커뮤니티에서는 현재 비동기 앱의 콜 스택이 첫 번째 await 이후로 사실상 무의미해지는 문제가 런타임이 async를 직접 인식하게 되면 개선될 것이라는 기대가 표명되고 있습니다. 런타임이 async 메서드의 실행 흐름을 직접 알게 되므로, 프로파일링과 진단 도구에서도 더 정확한 정보를 제공할 수 있습니다.
현재 상태와 제약
.NET 11 Preview 1에서 CoreCLR의 Runtime Async 지원은 기본 활성화되어 환경 변수 설정이 필요 없습니다. NativeAOT에 대한 기초 지원도 포함되었습니다. 다만, 핵심 런타임 라이브러리들은 아직 runtime-async로 컴파일되지 않았으며, 이후 프리뷰에서 변경될 예정입니다.
직접 실험하려면 프로젝트 파일에 다음 설정이 필요합니다:
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>$(Features);runtime-async=on</Features>
즉, 직접 컴파일하는 코드에서는 성능 이점을 볼 수 있지만, 코어 프레임워크와 라이브러리가 아직 runtime-async로 재컴파일되지 않았으므로 개선 효과는 제한적입니다.
SynchronizationContext 관련 변화 가능성
한 가지 라이브러리 개발자로서 주의할 점이 있습니다. 기존에는 상태 머신이 SynchronizationContext를 캡처하여 continuation을 원래 컨텍스트로 돌려보냈는데, 새 버전에서는 런타임이 SynchronizationContext를 드롭할 가능성이 있습니다. 아직 코어 라이브러리들이 기존 방식을 사용하고 있어 확정적이지는 않지만, AsyncLocal, ExecutionContext 등과의 상호작용에서 변화가 생길 수 있으므로 라이브러리 메인테이너라면 후속 프리뷰를 면밀히 추적할 필요가 있습니다.
정리하면, Runtime Async는 “기존 async/await의 프로그래밍 모델은 그대로 유지하면서, 내부 구현을 런타임 레벨로 끌어내려 성능·할당·디버깅을 개선하는” 방향입니다. Drop-in replacement를 지향하므로 기존 코드 수정 없이 혜택을 받을 수 있도록 설계되고 있으며, .NET 11 GA(2026년 11월 예정)까지 코어 라이브러리 재컴파일이 진행되면 실질적인 성능 차이가 더 뚜렷해질 것으로 예상됩니다.
호이스팅 (Hoisting)
"호이스팅(hoisting)"이라는 용어는 소프트웨어 엔지니어링의 여러 맥락에서 등장하지만, 각각이 가리키는 동작은 상당히 다릅니다.
JavaScript의 호이스팅은 변수나 함수 선언이 해당 스코프의 최상단으로 끌어올려지는 것처럼 동작하는 언어 명세상의 특성입니다. 실제로 코드가 물리적으로 이동하는 것이 아니라, 실행 컨텍스트 생성 단계에서 선언이 먼저 처리되는 것을 비유적으로 표현한 것입니다.
컴파일러 최적화에서의 호이스팅(Loop-Invariant Code Motion)은 루프 내부에서 반복마다 동일한 결과를 산출하는 연산을 루프 바깥으로 끌어올려 불필요한 재계산을 제거하는 최적화 기법입니다. 이쪽은 성능 향상을 위해 코드를 상위 스코프로 이동시킨다는 점에서 "끌어올림"의 의미가 가장 직관적입니다.
async 상태 머신에서의 호이스팅은 이 둘과 성격이 다릅니다. 일반적인 동기 메서드에서 로컬 변수는 스택 프레임에 할당되며, 메서드가 반환되면 스택 프레임과 함께 사라집니다. 그런데 async 메서드는 await 지점에서 호출자에게 제어를 돌려주고 나중에 재개되므로, 스택 프레임이 유지되지 않습니다. 따라서 컴파일러는 await 경계를 넘어 살아남아야 하는 로컬 변수를 힙에 할당되는 상태 머신 구조체(또는 클래스)의 필드로 끌어올립니다. 이 과정을 호이스팅이라 부릅니다. 여기서의 "끌어올림"은 스코프나 실행 순서의 이동이 아니라, **저장 위치의 승격(promotion)**입니다. 스택이라는 수명이 짧은 저장소에서 힙이라는 수명이 긴 저장소로 변수의 거처를 옮기는 것입니다.
문제는 기존 컴파일러 기반 구현에서는 어떤 변수가 실제로 await 경계를 넘는지 정밀하게 판별하지 않고, 메서드 내 모든 로컬 변수를 일괄적으로 호이스팅한다는 점입니다. Runtime Async에서는 런타임이 이 판단을 직접 수행하여, 실제로 await를 넘어 필요한 변수만 선택적으로 힙에 spill하고 나머지는 스택에 유지합니다. 결과적으로 동기적 완료 경로(hot path)에서 불필요한 힙 할당이 대폭 줄어드는 것이 Runtime Async의 핵심 성능 이점 중 하나입니다.