Skip to content

필사 모드: .NET 11 runtime-async — async 상태 머신을 컴파일러에서 런타임으로 옮기는 중간 보고

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — async는 원래 런타임이 모르는 기능이었다

C#에서 async Task M()를 쓸 때 실제로 벌어지는 일을 한 번쯤 디컴파일해 본 적이 있을 겁니다. Roslyn은 그 메서드를 통째로 지워 버리고, IAsyncStateMachine을 구현하는 구조체 하나와 MoveNext() 하나를 만들고, 원래 메서드 자리에는 그 상태 머신을 킥오프하는 스텁만 남깁니다. 지역 변수는 필드가 되고, await 지점마다 state 정수가 하나씩 붙고, 예외는 AsyncTaskMethodBuilder가 받아냅니다.

핵심은 이겁니다 — 런타임은 이 메서드가 async라는 사실을 모릅니다. JIT이 보는 건 그냥 평범한 클래스와 평범한 MoveNext 메서드입니다. async는 순전히 컴파일러의 소스 재작성(rewrite)이었고, CLR 입장에서는 존재하지 않는 개념이었습니다. C# 5가 2012년에 이 모델을 내놓은 이래로 14년째 그랬습니다.

이 설계에는 실질적인 대가가 있습니다. 스택 트레이스에 상태 머신 인프라가 그대로 노출되고, 디버거는 컴파일러가 만든 코드를 헤집고 다녀야 하고, JIT은 "이건 async 중단 지점"이라는 정보가 없으니 최적화할 여지도 없습니다. runtime-async는 이 전제를 뒤집는 작업입니다. 스펙 문서의 표현을 그대로 옮기면 이렇습니다 — 컴파일러 재작성으로 구현하는 것도 효과적이지만, .NET 런타임에 직접 구현하면 특히 성능 면에서 개선을 얻을 수 있다고 본다는 것입니다(runtime-async 스펙 초안).

이 글은 2026년 7월 14일 나온 .NET 11 프리뷰 6까지의 상황 정리입니다. .NET 11은 STS 릴리스로 2026년 11월 10일부터 2028년 11월 9일까지 지원 예정이니(릴리스 노트 README), 지금은 GA를 4개월 앞둔 시점입니다.

runtime-async가 실제로 바꾸는 것 — 플래그 하나

메커니즘은 놀랄 만큼 단순한 데서 시작합니다. ECMA-335 개정 초안은 메서드를 'sync'와 'async' 두 종류로 나누고, async 메서드를 이렇게 정의합니다 — [MethodImpl(MethodImplOptions.Async)]가 붙은 메서드 정의. 이 플래그는 MethodImplAttributes에 값 0x2000으로 추가되고, IL에서는 async 키워드로 표현되며 ilasm/ildasm이 인식합니다.

그래서 Roslyn이 하는 일은 이렇게 바뀝니다.

// 개발자가 쓰는 코드
async Task M()
{
    // ...
}
// runtime-async로 컴파일했을 때 나오는 형태 (개념적 표현)
[MethodImpl(MethodImplOptions.Async)]
Task M()
{
    // 상태 머신 클래스 없음. 본문이 그대로 남는다.
}

상태 머신 클래스가 사라지고, 메서드 본문이 그 자리에 남습니다. 중단 지점은 System.Runtime.CompilerServices.AsyncHelpers의 헬퍼 호출로 표시합니다.

// 스펙이 정의하는 중단 헬퍼 (일부)
namespace System.Runtime.CompilerServices
{
    public static class AsyncHelpers
    {
        [MethodImpl(MethodImplOptions.Async)]
        public static void Await(Task task);
        [MethodImpl(MethodImplOptions.Async)]
        public static T Await<T>(Task<T> task);
        [MethodImpl(MethodImplOptions.Async)]
        public static void AwaitAwaiter<TAwaiter>(TAwaiter awaiter)
            where TAwaiter : INotifyCompletion;
        // ValueTask, ConfiguredTaskAwaitable 등의 변형이 더 있다
    }
}

그래서 await C.M()은 이런 IL로 내려갑니다.

call     [System.Runtime]System.Threading.Tasks.Task C::M()
call     void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)

스펙은 이 "call 두 개 연속" 형태를 명시적으로 선호한다고 적어 뒀습니다 — async 메서드를 호출하고 곧바로 Await를 호출하는 IL 시퀀스가 최대 성능을 낸다는 것입니다. JIT이 이 패턴을 알아보고 중간 태스크 객체 없이 직접 이어붙일 수 있기 때문입니다.

중단 지점을 가로지르는 지역 변수는 "호이스팅"되어 상태가 보존됩니다. 이게 예전에 상태 머신 구조체의 필드가 하던 일이고, 이제는 런타임이 관리하는 컨티뉴에이션(continuation) 객체가 그 역할을 합니다. 프리뷰 노트에 계속 등장하는 "continuation 재사용", "unchanged local 저장 회피" 같은 표현이 전부 이 객체 이야기입니다.

Roslyn 쪽 설계 문서가 못 박아 둔 원칙 하나는 기억해 둘 만합니다 — 이 기능은 사용자 수준에 노출하지 않는 것이 목표이고, 초기 바인딩은 거의 영향을 받지 않으며, 심지어 컴파일러는 참조된 어셈블리의 메서드가 runtime-async로 컴파일됐는지 알지 못합니다(Roslyn Runtime Async Design). MethodImplOptions.Async를 손으로 붙이는 것도 금지돼 있어서, 직접 시도하면 컴파일러가 오류를 냅니다.

눈에 보이는 첫 번째 성과 — 살아 있는 스택 트레이스

성능 얘기보다 먼저 와닿는 건 이쪽입니다. 프리뷰 2 릴리스 노트가 든 예제는 OuterAsyncMiddleAsyncInnerAsync 세 단계를 거쳐 new StackTrace()를 찍는 코드입니다.

runtime-async 없이는 13프레임이 나옵니다.

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__InnerAsync|0_2()
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__MiddleAsync|0_1()
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__OuterAsync|0_0()
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)

켜면 5프레임입니다.

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at Program.<Main>(String[] args)

(두 출력 모두 프리뷰 2 릴리스 노트에서 가져왔습니다. 메서드 이름에 $>g__가 붙은 건 async라서가 아니라 로컬 함수라서입니다.)

여기서 릴리스 노트가 스스로 붙인 단서를 흘리지 않는 게 중요합니다. 개선되는 건 "살아 있는" 스택 트레이스입니다 — 프로파일러, 디버거, 실행 중 호출한 new StackTrace()가 보는 것. 반면 catch (Exception ex)에서 찍는 예외 스택 트레이스는 runtime-async 유무와 관계없이 이미 똑같이 보입니다. 컴파일러가 생성한 코드의 기존 ExceptionDispatchInfo 정리 덕분입니다.

즉 "async 스택 트레이스가 드디어 깨끗해진다"는 말은 절반만 맞습니다. 당신이 로그에서 매일 보는 그 예외 트레이스는 이미 괜찮았고, 이번에 좋아지는 건 프로파일러와 디버거 호출 스택 창 쪽입니다. 그리고 Environment.StackTraceSystem.Diagnostics.StackFrame에 runtime-async 프레임을 포함시키는 건 아직 API 제안 단계로 열려 있습니다. 진단 쪽은 에픽 이슈에서도 여전히 미완료 항목입니다.

디버깅 쪽은 프리뷰 2에서 runtime-async 메서드 안에 중단점이 제대로 바인딩되고, await 경계를 컴파일러 생성 인프라로 튀지 않고 스텝할 수 있게 됐습니다(dotnet/runtime #123644).

프리뷰 1에서 6까지 — 실제로 벌어진 일

날짜와 커밋을 붙여서 보면 이 기능이 어디쯤 와 있는지가 훨씬 선명해집니다.

프리뷰 1 (2026-02-10). CoreCLR의 런타임 측 지원이 기본 활성화됐습니다. 환경 변수를 설정할 필요가 없어졌다는 뜻입니다. NativeAOT도 runtime-async 코드를 컴파일할 수 있게 됐습니다. 다만 노트가 분명히 적어 뒀습니다 — 이 시점에 코어 런타임 라이브러리 중 runtime-async로 컴파일된 건 하나도 없습니다.

프리뷰 2 (2026-03-10). 앞서 본 스택 트레이스와 디버거 개선.

프리뷰 3 (2026-04-14). 진입 장벽이 한 칸 내려갔습니다. API에 붙어 있던 [RequiresPreviewFeatures] 게이트가 제거되면서, net11.0 프로젝트는 이제 runtime-async=on을 켜려고 EnablePreviewFeatures까지 켤 필요가 없습니다(dotnet/runtime #124488). NativeAOT와 ReadyToRun 지원도 이 프리뷰에서 들어왔습니다.

 <PropertyGroup>
   <Features>runtime-async=on</Features>
-  <EnablePreviewFeatures>true</EnablePreviewFeatures>
 </PropertyGroup>

프리뷰 4 (2026-05-12). 이번 사이클에서 가장 큰 사건입니다. 런타임 라이브러리 전체가 runtime-async=on으로 빌드되기 시작했습니다. 릴리스 노트 표현으로는, 런타임 라이브러리가 더 이상 컴파일러 생성 상태 머신을 담고 있지 않고 런타임이 제공하는 async 기능에 의존합니다. 여기에 더해 crossgen2에서 runtime-async 메서드 인라이닝을 막던 제약이 제거됐고, 69개 async 테스트가 crossgen2와 composite R2R 양쪽에서 통과했습니다(dotnet/runtime #125472).

프리뷰 5 (2026-06-09). 중단/재개 비용 최적화. 자세한 건 다음 절에서 봅니다.

프리뷰 6 (2026-07-14). JIT이 동기 태스크 반환 메서드를 썽크로 우회하지 않고 전용 runtime-async 버전을 따로 컴파일하도록 바뀌었습니다(dotnet/runtime #128384). 그리고 컨티뉴에이션이 ExecutionContext 캡처/복원을 건너뛸 수 있게 됐습니다(dotnet/runtime #128323) — AsyncLocal 값을 쓰지 않아 복원이 어차피 아무 일도 안 하는 경우에도 매번 컨텍스트 스냅숏을 뜨던 걸 이제 런타임이 감지해 통째로 건너뜁니다.

이 프리뷰 6의 첫 번째 변경에는 사연이 있습니다. 뒤에서 다시 봅니다.

숫자를 정직하게 읽기

프리뷰 5의 헤드라인 수치는 이겁니다 — 중단이 잦은 벤치마크가 Took 6357.1 ms에서 Took 457.1 ms로 개선됐다(dotnet/runtime #127074). 14배 가까운 수치입니다. 이걸 어떻게 읽어야 할까요.

먼저 무엇이 바뀐 건지. OSR(On-Stack Replacement)은 오래 도는 메서드가 실행 중에 tier-0 코드에서 최적화 코드로 갈아타게 해 주는 JIT 기능입니다. 문제는 OSR 메서드 안에서 async가 재개될 때 일반적인 OSR 전환 경로(patchpoint 헬퍼)를 탔다는 것이고, PR 본문은 그 헬퍼가 싸지 않아서 오버헤드가 대략 10~20배쯤 된다고 적고 있습니다. 이번 변경은 tier-0 코드에서 OSR 코드로 직접 점프해 헬퍼를 통째로 우회합니다.

이제 벤치마크의 정체. PR에 붙은 재현 코드를 보면 이렇습니다 — IsCompleted항상 falseNullAwaiter를 만들어 놓고, 1천만 번 도는 워밍업 루프로 메서드를 OSR 대상으로 만든 다음, 그 안에서 await na1천만 번 실행합니다. 바깥 루프는 계속 na.Continue()를 불러 깨웁니다.

다시 말해 이건 모든 await가 100% 중단되는 합성 마이크로벤치마크입니다. 실제 코드에서 await의 상당수는 이미 완료된 태스크를 만나 중단 없이 지나갑니다(스펙도 "모든 Task-like 객체가 완료됐다면 중단은 필요하지 않다"고 적고 있습니다). 이 벤치마크는 그 빠른 경로를 의도적으로 0%로 만들어 놓고 중단 경로만 측정합니다. 그러니 이 숫자는 "당신 앱이 14배 빨라진다"가 아니라 "중단 경로의 특정 병목이 이만큼 줄었다"로 읽어야 합니다. 벤더 자체 측정이고, 조건은 위와 같습니다.

프리뷰 5의 나머지 수치도 같은 성격입니다. 중단 코드 크기 축소는 중단이 잦은 마이크로벤치마크에서 약 8퍼센트 개선, PR 샘플의 생성 코드는 766바이트에서 751바이트(#126041). TLS 작업과 쓰기 배리어 감소는 PR 샘플이 Took 350.3 ms에서 Took 291.3 ms(#127336). 전부 PR 저자가 자기 샘플로 잰 수치입니다.

그럼 "실제 앱은 얼마나 빨라지나"의 답은? Microsoft가 아직 안 내놨습니다. 프리뷰 4 릴리스 노트의 문장이 이 상황을 가장 정확하게 요약합니다 — 런타임 async가 처리량과 라이브러리 크기 개선을 가져올 것으로 기대하며(expect), 변화 폭은 async 사용 정도에 비례할 것이고, 긍정적이든 부정적이든 보고를 환영한다는 것입니다. GA를 넉 달 앞둔 기능의 공식 문서가 "부정적인 결과도 알려 달라"고 적어 두는 건 드문 일이고, 정직한 일입니다. 헤드라인 숫자가 없는 게 아니라, 아직 낼 수 있는 숫자가 없는 상태로 읽는 게 맞습니다.

켜지 않아도 이미 당신 밑에서 돌고 있다

여기가 이 글에서 가장 중요한 부분일 수 있습니다.

runtime-async는 당신 코드에 대해서는 옵트인 프리뷰 기능입니다. 켜려면 프로젝트 파일에 이걸 넣어야 합니다.

<PropertyGroup>
  <Features>runtime-async=on</Features>
</PropertyGroup>

하지만 프리뷰 4부터 런타임 라이브러리는 이미 이 방식으로 빌드돼 출하됩니다. 즉 .NET 11에서 Task.WhenAll이나 Stream.ReadAsync나 소켓 코드를 부르는 순간, 당신이 아무것도 켜지 않았어도 BCL 안쪽의 async는 runtime-async로 돕니다. 프리뷰 1의 "코어 라이브러리 중 컴파일된 건 하나도 없다"에서 프리뷰 4의 "전부 컴파일됐다"로 넘어간 게 이 변화입니다.

이게 추상적인 얘기가 아니라는 증거가 있습니다. dotnet/runtime #126925System.Private.CoreLib에 runtime-async를 무조건 활성화한 뒤로 IIS in-process 호스팅 ASP.NET Core 앱이 모든 요청에 HTTP 500을 반환한 이슈입니다. 앱은 정상 기동하고 ANCM 로그도 성공을 찍는데, 이후 모든 HTTP 요청이 500으로 실패했습니다. 2026년 4월 14일에 열렸고 7월 1일 갱신 시점까지 열려 있습니다. dotnet/aspnetcore로 런타임 변경이 흘러가는 코드플로 PR에서 발견됐습니다.

이 이슈 하나가 말해 주는 건, 이 기능이 "실험해 보고 싶은 사람만 건드리는 플래그"가 아니라 이미 플랫폼 밑바닥에서 하중을 받고 있는 구조 변경이라는 것입니다. 그래서 .NET 11 프리뷰에서 설명 안 되는 async 관련 이상 동작을 만나면, 자기 코드에 runtime-async=on을 안 켰다는 사실이 알리바이가 되지 못합니다.

참고로 예전에 쓰던 DOTNET_RuntimeAsync, UNSUPPORTED_RuntimeAsync 환경 변수는 제거됐습니다. 공식 문서는 프로젝트 단위로 끄려면 <UseRuntimeAsync>false</UseRuntimeAsync>를 쓰라고 안내합니다(What's new in .NET 11 runtime, 이 페이지는 프리뷰 5 기준으로 갱신돼 있습니다). 다만 이 속성은 dotnet/runtime 저장소 자체의 빌드 파일에서 확인되는 것이고, 일반 사용자 프로젝트에서의 동작은 직접 검증해 보시길 권합니다.

아직 안 되는 것, 그리고 오히려 느려지는 것

이 절이 이 글을 쓴 이유입니다. 열려 있는 이슈들을 보면 이 기능의 현재 좌표가 정확히 나옵니다.

풀링 빌더는 회귀입니다. [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]를 붙인 메서드는 runtime-async 이전 대비 할당 때문에 회귀를 보이고 있습니다(dotnet/runtime #129004, 2026년 6월 4일). 대응은 지원이 아니라 회피였습니다 — 당분간 AsyncMethodBuilder가 붙은 메서드에 대해서는 runtime-async를 끈다는 것이고, 이게 PR #128943입니다. 프리뷰 6 릴리스 노트는 이 변경을 "이미 풀링된 메서드는 runtime-async를 옵트아웃해 중복 작업을 피한다"고 부드럽게 적어 놨는데, 이슈 쪽 표현이 더 솔직합니다. 할당에 민감해서 풀링 빌더를 쓰고 있었다면, 그 메서드들은 이 기능의 수혜 대상이 아니라 회피 대상입니다.

큰 구조체는 힙을 왕복할 수 있습니다. dotnet/runtime #120855는 runtime-async에서 성능이 나쁜 패턴들을 모아 둔 이슈인데, 그중 하나가 중단/재개가 잦으면 큰 구조체가 힙으로 반복 복사될 수 있다는 것입니다. 2025년 10월에 열려 2026년 7월 9일까지 갱신되고 있는 열린 이슈입니다.

동기 태스크 반환 래퍼는 오랫동안 역최적화였습니다. 상태 머신을 하나 아끼려고 async 메서드를 감싸는 동기 Task 반환 래퍼를 만드는 건 아주 흔한 패턴입니다. dotnet/runtime #115771의 문장이 명확합니다 — runtime-async에서 이건 상당한 역최적화다. 이 이슈는 2025년 5월에 열렸고, 앞서 프리뷰 6 항목에서 본 PR #128384가 2026년 6월 22일에 머지되면서 닫혔습니다. PR 본문 마지막 줄이 Fix #115771입니다. 1년 넘게 살아 있던 역최적화가 GA 4개월 전에 고쳐진 것입니다. 좋은 소식이지만 동시에 이 기능의 성숙도를 알려 주는 신호이기도 합니다.

ExecutionContext.SuppressFlow()를 지키지 않을 수 있습니다. dotnet/runtime #122052의 제목 그대로입니다. 이슈 본문은 억제된 실행 컨텍스트가 흘러가는 건 관찰 가능한 동작이며, 억제하는 이유 중 하나가 비밀 값을 숨기기 위해서일 수 있다고 지적합니다. 실제로 System.Net.SocketsExecutionContextFlowTest가 기대값 0에 실제값 42로 실패한 로그가 붙어 있습니다. 2025년 11월에 열려 2026년 7월 3일까지 갱신 중인 열린 이슈입니다. 이건 성능이 아니라 의미론(semantics) 문제라서 성격이 다릅니다.

커스텀 awaiter는 중단 시 박싱됩니다. dotnet/runtime #119842가 이 박싱을 피해야 한다고 열려 있습니다. 직접 만든 awaiter를 쓰고 있다면 확인할 지점입니다.

Multicore JIT은 async 변형을 제외합니다. dotnet/runtime #115097에 따르면 근본적인 이유가 있어서가 아니라 그냥 아직 구현/테스트가 안 된 상태(NYI)입니다.

프로파일러 ReJIT은 물음표입니다. dotnet/runtime #128944는 "runtime-async 메서드 본문에 대한 프로파일러 ReJIT이 .NET 11에서 지원되는 것이 의도인가?"라는 질문 자체가 열려 있는 상태입니다. APM 에이전트를 붙여 쓰는 환경이라면 눈여겨볼 항목입니다.

async 이터레이터는 아직입니다. Roslyn 설계 문서에는 IAsyncEnumerable을 반환하는 async 이터레이터가 여전히 TODO로 남아 있고, Roslyn의 기능 상태 표에서 "Runtime Async Streams"는 별도 브랜치의 진행 중 작업으로 잡혀 있습니다. 그리고 "Runtime Async" 자체도 그 표에서 프리뷰로 main에 머지됨 상태입니다.

에픽 이슈(#109632)는 2024년 11월에 열려 2026년 7월 13일 기준으로 여전히 열려 있고, 진단(StackTrace 포맷팅·출력)과 "async 메서드에 대한 함수 포인터는 어떻게 할 것인가" 같은 항목이 미해결로 남아 있습니다.

스펙이 못 박은 제약 — 이건 안 풀릴 수도 있습니다

위 이슈들이 "아직"의 문제라면, 스펙 초안이 영구적일 가능성이 크다(likely to be permanent)고 명시한 제약도 있습니다.

  • 반환 타입은 딱 네 가지. System.Threading.Tasks.Task, ValueTask, Task<T>, ValueTask<T>만 runtime-async 메서드의 반환 타입이 될 수 있습니다.
  • byref 지역 변수는 중단 지점을 건너 호이스팅될 수 없습니다. 스펙은 더 구체적으로, 중단 지점 이후 byref 변수를 읽으면 null이 나오고, byref-like 구조체도 호이스팅되지 않아 중단 이후 기본값이 된다고 적고 있습니다. 고정(pinning) 지역 변수도 마찬가지입니다.
  • 중단 지점은 예외 처리 블록 안에 나타날 수 없습니다.

마지막 항목을 보고 "그럼 try 안의 await는 어떻게 되나" 싶을 텐데, 답은 Roslyn 설계 문서에 있습니다. 컴파일러 생성 상태 머신과 런타임 생성 async는 같은 빌딩 블록을 일부 공유하는데, 그중 하나가 바로 이겁니다 — catch/finally 블록 안의 await는 예외를 보류(pend)시키고, catch/finally 영역 바깥에서 await를 수행한 뒤, 필요에 따라 예외를 복원하도록 재작성합니다. 이 재작성은 예전부터 하던 것이고 그대로 남습니다. 즉 소스의 await가 EH 블록 안에 있어도, IL 수준의 중단 지점은 밖으로 빠져나옵니다. 개발자 눈에는 아무것도 안 바뀝니다.

임시 제약으로는 tail 접두사 금지와 localloc 명령 금지가 있습니다. 이건 나중에 풀릴 수 있다고 적혀 있습니다.

그리고 가장 조용하지만 중요한 제약. Roslyn 설계 문서의 문장입니다 — 다른 Task-like 타입을 반환하는 메서드는 runtime-async 형태로 변환되지 않고 C# 생성 상태 머신을 사용합니다. 커스텀 빌더를 물린 타입을 쓰고 있다면 그 코드는 계속 옛날 방식으로 돕니다. 두 모델이 한 프로세스 안에 공존한다는 뜻이고, 이건 버그가 아니라 설계입니다. Mono 런타임도 지원 대상이 아닙니다.

그래서 지금 뭘 해야 하나

켤 이유가 있는 경우

  • async가 무겁고 중단이 잦은 서비스를 운영 중이고, .NET 11 GA를 기다렸다가 켤 생각이라면 지금 프리뷰에서 재 보는 게 이득입니다. Microsoft가 명시적으로 부정적 결과 보고를 요청하고 있고, GA까지 넉 달 남았습니다. 지금 나온 회귀는 고쳐질 여지가 있고, GA 후에 나온 회귀는 당신이 안고 갑니다.
  • 프로파일러나 디버거로 async 호출 스택을 읽는 일이 잦다면, 13프레임 → 5프레임은 체감이 확실한 개선입니다.

켜지 말아야 할 경우

  • 프로덕션. 프리뷰 기능이고, 에픽이 열려 있고, IIS in-process 500 같은 이슈가 열린 채로 있고, ExecutionContext.SuppressFlow 의미론이 확정되지 않았습니다.
  • 할당을 짜내려고 풀링 빌더를 쓰고 있다면. 회귀가 확인됐고 런타임은 그 메서드들을 아예 제외하는 쪽으로 대응했습니다.
  • 커스텀 Task-like 타입이나 커스텀 빌더가 코드베이스의 핵심이라면. 어차피 변환되지 않습니다.
  • Mono 대상이라면. 지원 대상이 아닙니다.
  • 프로파일러/APM 에이전트가 ReJIT에 의존한다면. 지원 의도 자체가 질문으로 열려 있습니다.

아무것도 안 켤 사람이 알아야 할 것

  • .NET 11로 올리는 것만으로 BCL의 async 경로는 runtime-async로 바뀝니다. 이건 옵트인이 아닙니다. 마이그레이션 테스트에서 설명 안 되는 async 동작 차이를 만나면 이 가능성을 후보에 넣으십시오.

마치며

runtime-async는 "async를 더 빠르게 만드는 최적화"가 아니라 async가 누구의 기능인지를 바꾸는 작업입니다. 14년 동안 컴파일러의 소스 재작성이었던 것을 런타임의 일급 개념으로 옮기는 일이고, 그래서 이득이 나오는 방식도 다릅니다 — 상태 머신이라는 중간 표현이 사라지면 JIT이 비로소 "여기가 중단 지점"이라는 걸 알게 되고, 그때부터 OSR 재개 경로 최적화나 중단 지점 tail-merge 같은 게 가능해집니다. 프리뷰 5와 6의 작업이 전부 그 종류입니다. 상태 머신 모델에서는 애초에 시도할 수 없던 최적화들입니다.

동시에, 2026년 7월 현재의 정직한 좌표는 이렇습니다. 런타임 라이브러리는 이미 넘어갔고, 스택 트레이스는 실제로 짧아졌고, 1년 넘게 살아 있던 역최적화 하나가 지난달에 고쳐졌습니다. 그리고 풀링 빌더는 회귀 중이고, IIS in-process 이슈는 열려 있고, SuppressFlow 의미론은 미정이고, async 이터레이터는 아직 오지 않았습니다. 에픽은 2024년 11월부터 열려 있습니다.

GA는 2026년 11월 10일입니다. 그때 이 목록이 얼마나 짧아져 있는지가, 이 기능을 켤지 말지의 진짜 답입니다. 지금 할 수 있는 가장 생산적인 일은 프리뷰에서 자기 워크로드로 재 보고 결과를 — 특히 나쁜 결과를 — 보내는 것입니다. 문서가 직접 부탁하고 있는 일이기도 하고요.

참고 자료

현재 단락 (1/132)

C#에서 `async Task M()`를 쓸 때 실제로 벌어지는 일을 한 번쯤 디컴파일해 본 적이 있을 겁니다. Roslyn은 그 메서드를 통째로 지워 버리고, `IAsyncSta...

작성 글자: 0원문 글자: 14,395작성 단락: 0/132