.NET 11 미리 보기 1에 포함된 Runtime Async 기능을 살펴보고, 기존 컴파일러 기반 async/await 상태 머신 방식의 한계와 런타임이 비동기를 1급 개념으로 이해할 때 얻는 이점, Preview 1에서 달라진 점, 직접 실험하는 방법을 정리한다.
.NET 11 미리 보기 1에는 획기적인 기능인 Runtime Async가 포함되어 있습니다. 이제 async/await 메서드를 상태 머신으로 재작성하는 역할을 C# 컴파일러에만 의존하지 않고, .NET 런타임 자체가 async 메서드를 1급 개념으로 이해합니다. 이 글에서는 Runtime Async가 무엇인지, 왜 중요한지, 미리 보기 1에서 무엇이 바뀌었는지, 그리고 오늘 바로 어떻게 실험해 볼 수 있는지 살펴봅니다.
C# 5에서 async/await가 도입된 이후, 비동기가 동작하도록 만드는 책임은 전적으로 컴파일러에 있었습니다. async 메서드를 작성하면 C# 컴파일러는 이를 상태 머신으로 재작성합니다. 즉, IAsyncStateMachine을 구현하는 생성된 struct를 만들고, 일시 중단 지점(suspension point) 사이에서 메서드의 진행 상태를 추적합니다.
이 접근은 잘 동작하지만, 다음과 같은 트레이드오프가 있습니다.
async 메서드는 상태 머신 struct를 생성하며, await를 가로질러 살아남아야 하는 모든 지역 변수에 대한 필드를 포함합니다. 메서드가 동기적으로 완료되지 않으면 이 struct는 힙에 박싱됩니다.MoveNext 메서드가 나타납니다.await는 완전한 일시 중단/재개 사이클을 강제합니다.Runtime Async는 async 메서드에 대한 이해를 컴파일러에서 .NET 런타임으로 옮깁니다. 컴파일러가 복잡한 상태 머신을 생성하는 대신, [MethodImpl(MethodImplOptions.Async)]로 주석 처리된 더 단순한 IL을 생성합니다. 그런 다음 런타임이 다음을 책임집니다.
Task 할당을 잠재적으로 완전히 피할 수도 있습니다.Runtime Async 모델에서 async 메서드는 일시 중단을 표현하기 위해 AsyncHelpers를 사용합니다.
namespace System.Runtime.CompilerServices
{
public static class AsyncHelpers
{
[MethodImpl(MethodImplOptions.Async)]
public static void AwaitAwaiter<TAwaiter>(TAwaiter awaiter) where TAwaiter : INotifyCompletion;
[MethodImpl(MethodImplOptions.Async)]
public static void UnsafeAwaitAwaiter<TAwaiter>(TAwaiter awaiter) where TAwaiter : ICriticalNotifyCompletion;
[MethodImpl(MethodImplOptions.Async)]
public static void Await(Task task);
[MethodImpl(MethodImplOptions.Async)]
public static void Await(ValueTask task);
[MethodImpl(MethodImplOptions.Async)]
public static T Await<T>(Task<T> task);
[MethodImpl(MethodImplOptions.Async)]
public static T Await<T>(ValueTask<T> task);
[MethodImpl(MethodImplOptions.Async)]
public static void Await(ConfiguredTaskAwaitable configuredAwaitable);
[MethodImpl(MethodImplOptions.Async)]
public static void Await(ConfiguredValueTaskAwaitable configuredAwaitable);
[MethodImpl(MethodImplOptions.Async)]
public static T Await<T>(ConfiguredTaskAwaitable<T> configuredAwaitable);
[MethodImpl(MethodImplOptions.Async)]
public static T Await<T>(ConfiguredValueTaskAwaitable<T> configuredAwaitable);
}
}
C# 컴파일러가 런타임-async IL을 생성할 때는 전체 상태 머신을 구성하는 대신 AsyncHelpers.Await(...) 호출을 생성합니다. 런타임은 이를 가로채고, 작업이 아직 완료되지 않았다면 메서드를 일시 중단합니다. 이때 필요한 상태만 저장한 뒤, 결과를 사용할 수 있게 되면 나중에 메서드를 재개합니다.
Runtime Async는 .NET 10에서 이미 실험적으로 사용할 수 있었지만, 런타임 지원을 활성화하기 위해 환경 변수를 설정해야 했습니다. .NET 11 미리 보기 1에서는 큰 진전이 있습니다.
CoreCLR의 runtime-async 지원이 이제 기본으로 활성화되었습니다. 더 이상 DOTNET_RuntimeAsync=1을 설정할 필요가 없습니다. 런타임은 기본 설정 그대로 런타임-async 메서드를 실행할 준비가 되어 있습니다.
미리 보기 1에는 런타임-async 메서드를 위한 기초적인 Native AOT 지원이 포함됩니다. 즉, runtime-async=on으로 컴파일된 코드를 이제 사전 컴파일(AOT)할 수 있으며, 여기에는 continuation 지원과 진단을 위한 도구 체인 배선(plumbing)도 포함됩니다.

.NET 11 미리 보기 1에서 runtime-async를 직접 코드에 적용해 보려면 다음을 수행하세요.
1. .NET 11을 대상으로 지정합니다:
<TargetFramework>net11.0</TargetFramework>
2. .csproj에서 미리 보기 기능과 runtime-async 컴파일을 활성화합니다:
<PropertyGroup>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
3. 평소처럼 async 코드를 작성합니다:
async Task<string> FetchDataAsync(HttpClient client, string url)
{
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
runtime-async가 활성화되면 C# 컴파일러는 완전한 상태 머신 없이 더 단순한 IL을 생성합니다. 런타임이 일시 중단과 재개를 네이티브로 처리합니다. 소스 코드는 전혀 바뀌지 않으며, 개선점은 전적으로 코드가 컴파일되고 실행되는 방식에 있습니다.
다음은 JetBrains 디컴파일러로 디컴파일한 .NET 11의 저수준 C# 코드입니다.
[NullableContext(1)]
[CompilerGenerated]
[MethodImpl(MethodImplOptions.Async)]
internal static Task<string> <<Main>$>g__FetchDataAsync|0_0(HttpClient client, string url)
{
HttpResponseMessage httpResponseMessage = AsyncHelpers.Await<HttpResponseMessage>(client.GetAsync(url));
httpResponseMessage.EnsureSuccessStatusCode();
return (Task<string>) AsyncHelpers.Await<string>(httpResponseMessage.Content.ReadAsStringAsync());
}
생성된 IL은 훨씬 단순합니다. 상태 머신 struct도 없고 MoveNext 메서드도 없습니다. 런타임이 async 오케스트레이션을 맡게 되며, 이는 컴파일러만 사용하는 접근으로는 불가능한 메서드 간(cross-method) 최적화의 가능성을 열어 줍니다.
전통적인 .NET 10 컴파일러-async 모델이 생성하는 복잡한 상태 머신과 비교해 보면:
[NullableContext(1)]
[AsyncStateMachine(typeof (Program.<<<Main>$>g__FetchDataAsync|0_0>d))]
[DebuggerStepThrough]
[CompilerGenerated]
internal static Task<string> <<Main>$>g__FetchDataAsync|0_0(HttpClient client, string url)
{
Program.<<<Main>$>g__FetchDataAsync|0_0>d stateMachine = new Program.<<<Main>$>g__FetchDataAsync|0_0>d();
stateMachine.<>t__builder = AsyncTaskMethodBuilder<string>.Create();
stateMachine.client = client;
stateMachine.url = url;
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<Program.<<<Main>$>g__FetchDataAsync|0_0>d>(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
[CompilerGenerated]
private sealed class <<<Main>$>g__FetchDataAsync|0_0>d : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<string> <>t__builder;
public HttpClient client;
public string url;
private HttpResponseMessage <response>5__1;
private HttpResponseMessage <>s__2;
private string <>s__3;
[Nullable(new byte[] {0, 1})]
private TaskAwaiter<HttpResponseMessage> <>u__1;
[Nullable(new byte[] {0, 1})]
private TaskAwaiter<string> <>u__2;
public <<<Main>$>g__FetchDataAsync|0_0>d()
{
base..ctor();
}
void IAsyncStateMachine.MoveNext()
{
int num1 = this.<>1__state;
string s3;
try
{
TaskAwaiter<HttpResponseMessage> awaiter1;
int num2;
TaskAwaiter<string> awaiter2;
if (num1 != 0)
{
if (num1 != 1)
{
awaiter1 = this.client.GetAsync(this.url).GetAwaiter();
if (!awaiter1.IsCompleted)
{
this.<>1__state = num2 = 0;
this.<>u__1 = awaiter1;
Program.<<<Main>$>g__FetchDataAsync|0_0>d stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<HttpResponseMessage>, Program.<<<Main>$>g__FetchDataAsync|0_0>d>(ref awaiter1, ref stateMachine);
return;
}
}
else
{
awaiter2 = this.<>u__2;
this.<>u__2 = new TaskAwaiter<string>();
this.<>1__state = num2 = -1;
goto label_9;
}
}
else
{
awaiter1 = this.<>u__1;
this.<>u__1 = new TaskAwaiter<HttpResponseMessage>();
this.<>1__state = num2 = -1;
}
this.<>s__2 = awaiter1.GetResult();
this.<response>5__1 = this.<>s__2;
this.<>s__2 = (HttpResponseMessage) null;
this.<response>5__1.EnsureSuccessStatusCode();
awaiter2 = this.<response>5__1.Content.ReadAsStringAsync().GetAwaiter();
if (!awaiter2.IsCompleted)
{
this.<>1__state = num2 = 1;
this.<>u__2 = awaiter2;
Program.<<<Main>$>g__FetchDataAsync|0_0>d stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Program.<<<Main>$>g__FetchDataAsync|0_0>d>(ref awaiter2, ref stateMachine);
return;
}
label_9:
this.<>s__3 = awaiter2.GetResult();
s3 = this.<>s__3;
}
catch (Exception ex)
{
this.<>1__state = -2;
this.<response>5__1 = (HttpResponseMessage) null;
this.<>t__builder.SetException(ex);
return;
}
this.<>1__state = -2;
this.<response>5__1 = (HttpResponseMessage) null;
this.<>t__builder.SetResult(s3);
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine)
{
}
}
}
Runtime Async 에픽 이슈에서는 .NET 11을 위해 남은 작업을 추적합니다.
System.* 및 ASP.NET Core 라이브러리를 runtime-async 지원으로 컴파일하면 프레임워크 전반의 성능 향상이 가능해집니다.핵심 라이브러리들이 runtime-async 활성화 상태로 재컴파일되어 배포되면, 전체적인 성능 이점(할당 감소, 더 작은 상태, 최적화된 async 호출 체인)이 ASP.NET Core 같은 실제 애플리케이션에서 측정 가능해질 것입니다.
Runtime Async는 .NET 역사에서 가장 중요한 런타임 수준 변화 중 하나입니다. async에 대한 이해를 컴파일러 재작성에서 런타임 자체로 옮김으로써, .NET 11은 async 생태계 전반에 걸친 의미 있는 성능 개선, 더 나은 디버깅 및 프로파일링 경험, 그리고 컴파일러만 사용하는 모델로는 불가능한 미래 최적화를 위한 기반을 마련합니다.
미리 보기 1은 CoreCLR 및 Native AOT 지원으로 토대를 마련하지만, 진정한 성능 향상은 이후 미리 보기에서 핵심 라이브러리들이 runtime-async로 재컴파일되면서 본격적으로 나타날 것입니다. 지금이야말로 여러분의 코드에서 이 기능을 실험해 보고, .NET 11에 찾아올 async 혁명에 대비하기 좋은 시점입니다.
Runtime Async에 대해 어떻게 생각하시나요? 잠재적인 성능 개선에 기대가 되시나요? 댓글로 알려 주세요!