
async/await는 실행이 아니라 기술이다
- Async/Await
- State Machine
- SynchronizationContext
- C#
문법이지 실행 모델이 아니다
async 키워드를 메서드에 붙이면 그 메서드가 비동기로 실행된다고 생각하기 쉽다. 틀렸다. async는 컴파일러에게 보내는 신호이지 런타임에게 보내는 지시가 아니다. 이 메서드 안에 await가 있을 것이니 상태 머신으로 변환하라는 표식일 뿐이다.
1편에서 return이 값의 반환이 아니라 제어의 양도라는 이야기를 했다. 3편에서는 yield가 제어를 일시적으로 돌려주되 상태를 보존하는 구조라는 걸 다뤘다. async/await는 이 yield의 연장선에 있다. 상태를 보존하면서 제어를 돌려주는 패턴을, 콜백이 아닌 순차적 문법으로 기술하는 것이다.
핵심은 **"기술"**이라는 단어에 있다. 비동기 실행을 만들어내는 게 아니라 비동기 흐름이 어떤 순서로 이어지는지를 적어두는 것이다. 실제로 무엇이 비동기인지, 어디서 실행되는지는 전혀 다른 계층이 결정한다.
컴파일러가 만드는 상태 머신
개발자가 쓴 코드를 보자.
async Task<string> LoadAsync()
{
var raw = await ReadFileAsync();
var parsed = Parse(raw);
return parsed;
}컴파일러는 이걸 대략 이런 구조체로 변환한다.
struct LoadAsync_StateMachine
{
int state;
string raw;
string parsed;
TaskAwaiter<byte[]> awaiter;
void MoveNext()
{
switch (state)
{
case 0:
awaiter = ReadFileAsync().GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 1;
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // 호출자에게 제어를 돌려준다
}
goto case 1;
case 1:
raw = awaiter.GetResult();
parsed = Parse(raw);
builder.SetResult(parsed);
return;
}
}
}case 0에서 ReadFileAsync()를 호출하고, 아직 완료되지 않았으면 상태를 1로 저장한 뒤 return한다. 이 return은 1편에서 말한 바로 그 return이다. 제어를 호출자에게 돌려준다. 나중에 파일 읽기가 완료되면 MoveNext()가 다시 호출되고, case 1에서 이어서 실행된다.
결국 콜백이다. 다만 컴파일러가 콜백을 작성해주는 것이다. 개발자가 보는 코드는 위에서 아래로 흐르지만, 실행은 await 지점마다 잘리고 나중에 이어 붙여진다. 순차적으로 읽힌다는 것과 순차적으로 실행된다는 것은 다른 이야기다.
await 이후의 코드는 어디서 실행되는가
상태 머신이 재개될 때 어떤 스레드에서 MoveNext()가 호출되는지를 결정하는 것은 SynchronizationContext다. 같은 코드가 런타임에 따라 전혀 다르게 동작한다.
async Task DoWork()
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // 1
await Task.Delay(100);
Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // ?
}
// 콘솔 앱: 1 → 4 (스레드풀의 아무 스레드)
// WinForms: 1 → 1 (UI 스레드로 복귀)
// Unity: 1 → 1 (메인 스레드로 복귀)코드는 한 줄도 바뀌지 않았다. 바뀐 것은 실행 환경뿐이다. 콘솔 앱에서는 SynchronizationContext가 null이라 continuation이 스레드풀의 아무 스레드에서 실행된다. WinForms는 UI 스레드로, Unity는 메인 스레드로 보낸다. 문법만 보고 실행 모델을 추론할 수 없다.
콜백 지옥의 평탄화
async/await가 대체한 것을 보면 이 문법의 본질이 더 명확해진다.
// 콜백 방식
ReadFileAsync("data.bin", (raw) => {
var parsed = Parse(raw);
SaveAsync(parsed, (result) => {
Log(result);
// 중첩이 깊어진다
});
});
// async/await 방식
var raw = await ReadFileAsync("data.bin");
var parsed = Parse(raw);
var result = await SaveAsync(parsed);
Log(result);구조가 평탄해졌다. try/catch로 에러 처리도 가능해졌다. 그런데 실행 모델은 동일하다. 콜백 체인 위에 문법적 설탕을 올린 것이다. 성능이 달라지는 것도 아니고, 스레드가 추가되는 것도 아니다. 읽기 좋아졌을 뿐이다.
이건 async/await를 이해하려면 그것이 대체한 것을 이해해야 한다는 뜻이기도 하다. 모든 await는 제어가 떠났다가 돌아오는 지점이다. 그리고 돌아올 때 같은 맥락일 수도 있고 아닐 수도 있다. 콜백 시절에는 이 사실이 코드 구조에 그대로 드러났다. async/await는 그걸 감추되 없애지는 않았다.
이 기계가 특정 런타임에서 어떻게 작동하는지는 맥락에 따라 달라진다. SynchronizationContext가 모든 continuation을 단일 스레드로 보내고, 그 스레드가 60fps로 렌더링까지 담당하고 있다면 어떤 일이 벌어지는가. 다음 글에서 다룬다.