Split View: .NET 11 runtime-async — async 상태 머신을 컴파일러에서 런타임으로 옮기는 중간 보고
.NET 11 runtime-async — async 상태 머신을 컴파일러에서 런타임으로 옮기는 중간 보고
- 들어가며 — async는 원래 런타임이 모르는 기능이었다
- runtime-async가 실제로 바꾸는 것 — 플래그 하나
- 눈에 보이는 첫 번째 성과 — 살아 있는 스택 트레이스
- 프리뷰 1에서 6까지 — 실제로 벌어진 일
- 숫자를 정직하게 읽기
- 켜지 않아도 이미 당신 밑에서 돌고 있다
- 아직 안 되는 것, 그리고 오히려 느려지는 것
- 스펙이 못 박은 제약 — 이건 안 풀릴 수도 있습니다
- 그래서 지금 뭘 해야 하나
- 마치며
- 참고 자료
들어가며 — 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 릴리스 노트가 든 예제는 OuterAsync → MiddleAsync → InnerAsync 세 단계를 거쳐 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.StackTrace와 System.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가 항상 false인 NullAwaiter를 만들어 놓고, 1천만 번 도는 워밍업 루프로 메서드를 OSR 대상으로 만든 다음, 그 안에서 await na를 1천만 번 실행합니다. 바깥 루프는 계속 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 #126925는 System.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.Sockets의 ExecutionContextFlowTest가 기대값 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일입니다. 그때 이 목록이 얼마나 짧아져 있는지가, 이 기능을 켤지 말지의 진짜 답입니다. 지금 할 수 있는 가장 생산적인 일은 프리뷰에서 자기 워크로드로 재 보고 결과를 — 특히 나쁜 결과를 — 보내는 것입니다. 문서가 직접 부탁하고 있는 일이기도 하고요.
참고 자료
- runtime-async 스펙 초안 — ECMA-335 개정안 (dotnet/runtime)
- Roslyn Runtime Async Design — 컴파일러 측 변환 설계
- .NET Runtime-Async Feature — 에픽 이슈 #109632
- .NET 11 릴리스 노트 인덱스 (프리뷰 1~6 날짜, STS 지원 기간)
- 프리뷰 2 런타임 노트 — 스택 트레이스 13프레임 vs 5프레임
- 프리뷰 4 런타임 노트 — 런타임 라이브러리가 runtime-async로 빌드됨
- 프리뷰 5 런타임 노트 — 중단 성능 수치
- 프리뷰 6 런타임 노트 — JIT async 지원, ExecutionContext 생략
- What's new in the .NET 11 runtime — 공식 문서 (프리뷰 5 기준)
- dotnet/runtime #127074 — OSR 재개 최적화와 벤치마크 재현 코드
- dotnet/runtime #128384 — 동기 태스크 반환 메서드의 runtime-async 버전 컴파일 (Fix #115771)
- dotnet/runtime #115771 — 동기 Task 반환 래퍼의 역최적화
- dotnet/runtime #129004 — 풀링 빌더 회귀
- dotnet/runtime #122052 — ExecutionContext.SuppressFlow 미준수
- dotnet/runtime #126925 — IIS in-process HTTP 500
- dotnet/runtime #120855 — runtime-async에서 성능이 나쁜 패턴들
.NET 11 Runtime-Async: A Progress Report on Moving the Async State Machine from Compiler to Runtime
- Introduction — Async Used to Be a Feature the Runtime Didn't Know About
- What Runtime-Async Actually Changes — One Flag
- The First Visible Payoff — Live Stack Traces
- Preview 1 Through 6 — What Actually Happened
- Reading the Numbers Honestly
- It's Already Running Under You Even If You Never Turn It On
- What Doesn't Work Yet, and What's Actually Slower
- Constraints the Spec Nails Down — These May Never Change
- So What Should You Actually Do Right Now
- Closing
- References
Introduction — Async Used to Be a Feature the Runtime Didn't Know About
You've probably decompiled async Task M() in C# at least once just to see what actually happens. Roslyn erases the method entirely, generates a struct implementing IAsyncStateMachine plus a MoveNext(), and leaves only a stub in the original method's place that kicks off that state machine. Local variables become fields, every await point gets its own state integer, and exceptions are caught by an AsyncTaskMethodBuilder.
Here's the core of it — the runtime has no idea this method is async. All the JIT sees is an ordinary class with an ordinary MoveNext method. Async was purely a compiler-side source rewrite, a concept that simply didn't exist as far as the CLR was concerned. That's been true for 14 years, ever since C# 5 introduced this model in 2012.
That design has a real cost. State-machine plumbing leaks straight into stack traces, debuggers have to pick through compiler-generated code, and the JIT has no way to know "this is an async suspension point," so it has no room to optimize around it. Runtime-async flips that premise. In the spec document's own words — implementing this via compiler rewriting works too, but implementing it directly in the .NET runtime is expected to yield improvements, especially in performance (runtime-async spec draft).
This post is a status check as of .NET 11 Preview 6, released on July 14, 2026. .NET 11 is an STS release supported from November 10, 2026 through November 9, 2028 (release notes README), so we're now four months out from GA.
What Runtime-Async Actually Changes — One Flag
The mechanism starts from a surprisingly simple place. The ECMA-335 amendment draft splits methods into two kinds, 'sync' and 'async,' and defines an async method as one carrying [MethodImpl(MethodImplOptions.Async)]. That flag is added to MethodImplAttributes as value 0x2000, is expressed in IL with the async keyword, and is recognized by ilasm/ildasm.
So what Roslyn does changes shape:
// what the developer writes
async Task M()
{
// ...
}
// what gets compiled with runtime-async (conceptual)
[MethodImpl(MethodImplOptions.Async)]
Task M()
{
// no state-machine class. the body stays exactly where it was.
}
The state-machine class disappears, and the method body stays right where it was. Suspension points are marked by calls into the helper methods on System.Runtime.CompilerServices.AsyncHelpers.
// suspension helpers as defined by the spec (partial)
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;
// more variants exist for ValueTask, ConfiguredTaskAwaitable, etc.
}
}
So await C.M() lowers to this 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)
The spec explicitly says it favors this "two calls in a row" shape — an IL sequence that calls an async method and immediately calls Await gets maximum performance, because the JIT can recognize the pattern and chain them directly without an intermediate task object.
Local variables that cross a suspension point are "hoisted" so their state is preserved. That's what the state-machine struct's fields used to do; now a runtime-managed continuation object does it instead. Phrases like "continuation reuse" and "avoiding storing unchanged locals," which keep showing up in the preview notes, are all about this object.
One principle the Roslyn design doc nails down is worth remembering — this feature is meant to be invisible at the user level, initial binding is barely affected, and the compiler doesn't even know whether a method in a referenced assembly was compiled with runtime-async (Roslyn Runtime Async Design). Manually attaching MethodImplOptions.Async yourself is also disallowed — try it, and the compiler throws an error.
The First Visible Payoff — Live Stack Traces
Before we get to performance, this is the part that lands first. The Preview 2 release notes include an example that walks through OuterAsync → MiddleAsync → InnerAsync and dumps a new StackTrace().
Without runtime-async, you get 13 frames.
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)
Turn it on and you get 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)
(Both outputs are taken from the Preview 2 runtime notes. The $>g__ in the method names is there because they're local functions, not because they're async.)
It's worth catching a detail the release notes don't spell out for you. What improves is the "live" stack trace — what a profiler, a debugger, or a new StackTrace() called at runtime sees. The exception stack trace you get from catch (Exception ex), on the other hand, already looks the same with or without runtime-async, thanks to the existing ExceptionDispatchInfo cleanup in compiler-generated code.
So "async stack traces are finally clean" is only half true. The exception traces you look at in your logs every day were already fine; what's improving here is the profiler and debugger call-stack views. And including runtime-async frames in Environment.StackTrace and System.Diagnostics.StackFrame is still open as an API proposal. The diagnostics side is also still an open item on the epic issue.
On the debugging side, Preview 2 got breakpoints binding correctly inside runtime-async methods, and stepping across await boundaries without bouncing through compiler-generated infrastructure (dotnet/runtime #123644).
Preview 1 Through 6 — What Actually Happened
Pinning dates and commits to this makes it much clearer where the feature actually stands.
Preview 1 (2026-02-10). CoreCLR's runtime-side support became enabled by default — no more environment variable needed. NativeAOT could also compile runtime-async code. But the notes are explicit — at this point, none of the core runtime libraries are compiled with runtime-async.
Preview 2 (2026-03-10). The stack trace and debugger improvements covered above.
Preview 3 (2026-04-14). The barrier to entry dropped a notch. With the [RequiresPreviewFeatures] gate removed from the API, net11.0 projects no longer need EnablePreviewFeatures turned on just to flip runtime-async=on (dotnet/runtime #124488). NativeAOT and ReadyToRun support also landed in this preview.
<PropertyGroup>
<Features>runtime-async=on</Features>
- <EnablePreviewFeatures>true</EnablePreviewFeatures>
</PropertyGroup>
Preview 4 (2026-05-12). The biggest event of the cycle. The entire runtime library started being built with runtime-async=on. In the release notes' own phrasing, the runtime library no longer contains compiler-generated state machines and instead relies on the runtime-provided async feature. On top of that, the constraint blocking crossgen2 from inlining runtime-async methods was lifted, and 69 async tests passed on both crossgen2 and composite R2R (dotnet/runtime #125472).
Preview 5 (2026-06-09). Suspend/resume cost optimizations. Covered in detail in the next section.
Preview 6 (2026-07-14). The JIT changed so that a synchronous task-returning method now gets a dedicated runtime-async version compiled separately, instead of being routed through a thunk (dotnet/runtime #128384). And continuations can now skip ExecutionContext capture/restore (dotnet/runtime #128323) — the runtime now detects when a context snapshot would end up doing nothing anyway (no AsyncLocal values in play) and skips it entirely, instead of taking one on every resume regardless.
There's a backstory behind that first Preview 6 change. More on that later.
Reading the Numbers Honestly
Preview 5's headline number is this — a benchmark heavy on suspensions improved from Took 6357.1 ms to Took 457.1 ms (dotnet/runtime #127074). Close to 14x. How should you read that?
First, what actually changed. OSR (On-Stack Replacement) is a JIT feature that lets a long-running method switch from tier-0 code to optimized code mid-execution. The problem was that when async resumed inside an OSR method, it took the general OSR transition path (the patchpoint helper), and the PR description notes that helper isn't cheap — overhead runs roughly 10 to 20 times higher. This change jumps directly from tier-0 code to OSR code, bypassing the helper entirely.
Now, what the benchmark actually is. Looking at the repro code attached to the PR: it builds a NullAwaiter whose IsCompleted is always false, uses a 10-million-iteration warmup loop to make the method an OSR candidate, and then runs await na 10 million times inside it. An outer loop keeps calling na.Continue() to wake it back up.
In other words, this is a synthetic microbenchmark where every single await suspends, 100% of the time. In real code, a good share of awaits hit an already-completed task and pass through without suspending at all (the spec itself notes that "if all Task-like objects have already completed, no suspension is necessary"). This benchmark deliberately drives that fast path to 0% and measures the suspension path alone. So the number should be read not as "your app gets 14x faster" but as "this specific bottleneck on the suspension path shrank by this much." It's a vendor-measured figure, under the conditions described above.
The rest of Preview 5's numbers are the same kind of figure. The suspension code-size reduction is about an 8 percent improvement on suspension-heavy microbenchmarks, with the PR's sample generated code going from 766 bytes to 751 bytes (#126041). The TLS-operation and write-barrier reduction takes the PR's sample from Took 350.3 ms to Took 291.3 ms (#127336). All of these are figures the PR authors measured on their own samples.
So what's the answer to "how much faster does a real app get"? Microsoft hasn't given one yet. The Preview 4 release notes sum up the situation most accurately — they expect runtime-async to bring throughput and library-size improvements, note that the magnitude will scale with how much async you use, and say they welcome reports either way, positive or negative. For a feature four months from GA, having official docs explicitly ask for negative results too is unusual, and honest. It's not that there's no headline number — it's that there isn't one to give yet, and that's the accurate way to read it.
It's Already Running Under You Even If You Never Turn It On
This may be the single most important section in this post.
Runtime-async is an opt-in preview feature as far as your own code is concerned. To turn it on, you need this in your project file:
<PropertyGroup>
<Features>runtime-async=on</Features>
</PropertyGroup>
But starting with Preview 4, the runtime library already ships built this way. Which means the moment you call Task.WhenAll, Stream.ReadAsync, or any socket code on .NET 11, the async inside the BCL is already running on runtime-async — whether or not you turned anything on yourself. That's the shift from Preview 1's "none of the core libraries are compiled this way" to Preview 4's "all of them are."
There's concrete evidence this isn't just an abstract claim. dotnet/runtime #126925 documents an issue where, after runtime-async was unconditionally enabled in System.Private.CoreLib, an IIS in-process-hosted ASP.NET Core app started returning HTTP 500 on every request. The app starts up fine, ANCM logs show success, and then every subsequent HTTP request fails with a 500. It was opened on April 14, 2026, and was still open as of the July 1 update — discovered via a codeflow PR carrying runtime changes into dotnet/aspnetcore.
What this one issue tells you is that this isn't "a flag only people who want to experiment touch" — it's a structural change that's already carrying load at the bottom of the platform. So if you run into an unexplained async-related anomaly on a .NET 11 preview, the fact that you never turned runtime-async=on on in your own code is not an alibi.
For reference, the old DOTNET_RuntimeAsync and UNSUPPORTED_RuntimeAsync environment variables have been removed. The official docs say to use <UseRuntimeAsync>false</UseRuntimeAsync> to turn it off on a per-project basis (What's new in .NET 11 runtime, current as of Preview 5). That said, this property is confirmed from the build files of the dotnet/runtime repo itself — I'd recommend verifying its behavior yourself in an ordinary user project.
What Doesn't Work Yet, and What's Actually Slower
This is the section this post exists for. The open issues pin down exactly where this feature currently stands.
Pooling builders are a regression. Methods annotated with [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] are showing a regression due to allocations compared to before runtime-async (dotnet/runtime #129004, June 4, 2026). The response was avoidance rather than a fix — runtime-async is disabled for any method carrying an AsyncMethodBuilder attribute for the time being, which is PR #128943. The Preview 6 release notes describe this softly as "methods that are already pooled opt out of runtime-async to avoid duplicate work," but the issue's own language is more candid. If you were using a pooling builder because you're allocation-sensitive, those methods aren't beneficiaries of this feature — they're being routed around it.
Large structs can round-trip through the heap. dotnet/runtime #120855 collects poorly-performing patterns under runtime-async, and one of them is that large structs can get copied to the heap repeatedly when suspend/resume happens frequently. It was opened in October 2025 and was still being updated as of July 9, 2026.
Synchronous task-returning wrappers used to be a de-optimization for a long time. Wrapping an async method with a synchronous Task-returning wrapper to save a state machine is a very common pattern. dotnet/runtime #115771 states it plainly — under runtime-async, this is a significant de-optimization. The issue was opened in May 2025, and was closed when PR #128384, covered above under Preview 6, merged on June 22, 2026. The last line of the PR description reads Fix #115771. A de-optimization that lived for over a year got fixed four months before GA — good news, but also a signal about how mature this feature actually is.
ExecutionContext.SuppressFlow() may not be honored. dotnet/runtime #122052's title says it plainly. The issue body points out that a suppressed execution context flowing anyway is observable behavior, and that one reason for suppressing it in the first place can be to keep secret values from leaking. There's an attached log showing System.Net.Sockets's ExecutionContextFlowTest failing with an expected value of 0 but an actual value of 42. Opened in November 2025, still being updated as of July 3, 2026. This is different in kind from the others — it's not a performance problem, it's a semantics problem.
Custom awaiters get boxed on suspension. dotnet/runtime #119842 is open on avoiding this boxing. Worth checking if you're using a hand-rolled awaiter.
Multicore JIT excludes async variants. Per dotnet/runtime #115097, there's no fundamental reason for this — it's just not implemented/tested yet (NYI).
Profiler ReJIT is a question mark. dotnet/runtime #128944 is literally an open question: "is profiler ReJIT of runtime-async method bodies meant to be supported in .NET 11?" Worth watching if you're running with an APM agent attached.
Async iterators aren't here yet. The Roslyn design doc still lists async iterators returning IAsyncEnumerable as a TODO, and in Roslyn's feature-status table, "Runtime Async Streams" is tracked as in-progress work on a separate branch. "Runtime Async" itself is listed in that table as merged to main as preview.
The epic issue (#109632) was opened in November 2024 and remained open as of July 13, 2026, with diagnostics (StackTrace formatting/output) and "what to do about function pointers to async methods" still listed as unresolved items.
Constraints the Spec Nails Down — These May Never Change
If the issues above are "not yet" problems, there are also constraints the spec draft explicitly says are likely to be permanent.
- Exactly four return types. Only
System.Threading.Tasks.Task,ValueTask,Task<T>, andValueTask<T>can be the return type of a runtime-async method. - Byref locals cannot be hoisted across a suspension point. More specifically, the spec states that reading a byref variable after a suspension point yields null, and that byref-like structs also aren't hoisted, defaulting after suspension. The same applies to pinned locals.
- A suspension point cannot appear inside an exception-handling block.
That last point raises an obvious question — what happens to await inside a try? The answer is in the Roslyn design doc. Compiler-generated state machines and runtime-generated async share some of the same building blocks, and this is one of them — await inside a catch/finally block gets rewritten to pend the exception, perform the await outside the catch/finally region, and restore the exception afterward if needed. This rewrite has always been there and stays exactly as it was. So even though the source-level await sits inside an EH block, the IL-level suspension point ends up outside it. From the developer's point of view, nothing changes.
The temporary constraints include a ban on the tail prefix and on the localloc instruction — the spec notes these may be lifted later.
And then there's the quietest but most important constraint. In the Roslyn design doc's own words — a method returning some other Task-like type is not converted to the runtime-async form and continues to use the C#-generated state machine. If your code leans on a type with a custom builder attached, that code keeps running the old way. The two models coexist within the same process, and that's by design, not a bug. The Mono runtime isn't a supported target either.
So What Should You Actually Do Right Now
When there's a reason to turn it on
- If you're running an async-heavy, suspension-heavy service and plan to flip this on once .NET 11 hits GA anyway, benchmarking it on preview now is worth it. Microsoft is explicitly asking for negative results, and GA is four months out. A regression found now has a chance of getting fixed; one you find after GA is yours to live with.
- If you frequently read async call stacks through a profiler or debugger, 13 frames down to 5 is a genuinely noticeable improvement.
When you shouldn't turn it on
- Production. It's a preview feature, the epic issue is still open, issues like the IIS in-process 500 remain open, and
ExecutionContext.SuppressFlowsemantics aren't finalized. - If you're using pooling builders to squeeze out allocations. The regression is confirmed, and the runtime's own answer was to exclude those methods entirely.
- If custom
Task-like types or custom builders are core to your codebase. They don't get converted regardless. - If you're targeting Mono. It's not a supported target.
- If your profiler or APM agent depends on ReJIT. Whether it's even meant to be supported is still an open question.
What people who never turn anything on should know
- Upgrading to .NET 11 alone changes the BCL's async paths to runtime-async. This isn't opt-in. If you hit an unexplained difference in async behavior during migration testing, put this on your list of suspects.
Closing
Runtime-async isn't "an optimization that makes async faster" — it's a change in whose feature async is. It moves something that was a compiler-side source rewrite for 14 years into a first-class runtime concept, and that's exactly why the gains show up differently — once the state machine as an intermediate representation disappears, the JIT finally knows "this is a suspension point," and only then do things like OSR-resume-path optimization or tail-merging suspension points become possible. Everything done in Preview 5 and 6 is that category of work — optimizations that simply couldn't have been attempted under the state-machine model.
At the same time, the honest state of things as of July 2026 is this. The runtime library has already moved over, stack traces really are shorter, and a de-optimization that lived over a year got fixed last month. And pooling builders are regressing, the IIS in-process issue is open, SuppressFlow semantics are undecided, and async iterators haven't arrived yet. The epic issue has been open since November 2024.
GA is November 10, 2026. How much shorter that list gets by then is the real answer to whether you should turn this on. The most productive thing you can do right now is benchmark it against your own workload on preview and send back results — especially bad ones. That's literally what the documentation is asking for.
References
- runtime-async spec draft — ECMA-335 amendment (dotnet/runtime)
- Roslyn Runtime Async Design — compiler-side transformation design
- .NET Runtime-Async Feature — epic issue #109632
- .NET 11 release notes index (Preview 1-6 dates, STS support window)
- Preview 2 runtime notes — 13 frames vs 5 frames stack trace
- Preview 4 runtime notes — runtime library built with runtime-async
- Preview 5 runtime notes — suspension performance numbers
- Preview 6 runtime notes — JIT async support, ExecutionContext elision
- What's new in the .NET 11 runtime — official docs (current as of Preview 5)
- dotnet/runtime #127074 — OSR resume optimization and benchmark repro
- dotnet/runtime #128384 — compiling a runtime-async version of synchronous task-returning methods (Fix #115771)
- dotnet/runtime #115771 — de-optimization of synchronous Task-returning wrappers
- dotnet/runtime #129004 — pooling builder regression
- dotnet/runtime #122052 — ExecutionContext.SuppressFlow not honored
- dotnet/runtime #126925 — IIS in-process HTTP 500
- dotnet/runtime #120855 — poorly-performing patterns under runtime-async