안녕하세요. 저는 유니티의 파트너 엔지니어 이제민입니다.
이전 글에서는 Unity에서 코루틴, async/await 및 Unity 6의 새로운 Awaitable 타입을 사용하여
비동기 프로그래밍이 어떻게 작동하는지 알아봤습니다.
하지만 우리가 다루지 않은 중요한 개념이 하나 있습니다.
비동기 코드가 보통 메인 스레드에서 재개되는 이유이자,
Unity의 비동기 동작이 표준 .NET과 다르게 느껴지는 이유인데요.
바로 SynchronizationContext입니다.
이번 글에서는 async/await와 SynchronizationContext의 내부 메카닉에 초점을 맞춥니다.
Unity에서 async/await를 안전하게 사용하기 위해 이러한 내용을 꼭 자세히 알아야 할 필요는 없습니다.
Unity에서 비동기 프로그래밍에 대한 실용적인 가이드를 알아보려면 이전 글을 참고하세요.
SynchronizationContext란?
비동기 프로그래밍에서 중요한 질문은 “내 비동기 작업이 언제 끝나는가?” 외에도
“작업이 어디에서 계속 실행되어야 하는가?”가 있습니다.
SynchronizationContext는 그 ‘어디’에 대한 .NET의 추상화입니다.
더 정확하게 말하자면, await 중인 작업이 완료된 후
비동기 작업이 어디에서 계속 실행될지 제어할 수 있는 메커니즘입니다.
· 참고: SynchronizationContext는 스레드를 나타내지 않습니다. 실행이 재개되는 위치를 나타냅니다.
구현에 따라 그 위치는 하나 또는 여러 스레드를 통해 지원될 수 있습니다.
SynchronizationContext가 없을 경우
참고: 이는 콘솔 애플리케이션 같은 환경에서 일반적인 .NET 비동기 동작을 설명하는 단순화된 예시입니다.
아래 예시는 await 중인 작업이 서로 다른 스레드에서 완료된다고 가정하여
SynchronizationContext가 작업이 계속 실행되는 위치에 미치는 영향을 보여 줍니다.
Unity에서는 메인 스레드(예: Start 또는 Update)에서 시작된 비동기 메서드가 기본적으로 메인 스레드에서 계속 실행됩니다.
간단한 비동기 메서드부터 시작해 보겠습니다.
private async Task DoSomethingAsync()
{
PrintCurrentThread(); // (1)
var otherTask = OtherAsync();
await otherTask;
RemainCodes(); // (2) 작업이 계속 실행되는 위치
}
언뜻 보기에는 메서드가 시작된 동일한 스레드에서 (2)가 재개되어야 할 것처럼 보입니다.하지만 그 가정은 SynchronizationContext가 존재하고 캡처된 경우에만 성립합니다.
질문을 구체적으로 만들기 위해 이 시나리오를 가정해 보겠습니다.
· DoSomethingAsync()는 스레드 1에서 호출됩니다.
· PrintCurrentThread()는 스레드 1에서 실행됩니다.
· OtherAsync()는 스레드 2(또는 다른 백그라운드 스레드)에서 작업을 수행합니다.
· 대기하는 동안 스레드 1은 다른 작업을 계속할 수 있습니다.
· OtherAsync()가 완료되었을 때 스레드 1은 여전히 작업 중일 수 있습니다.
그렇다면 (2)는 어떤 스레드에서 실행될까요?
· 정답: SynchronizationContext의 존재 여부에 따라 다릅니다.
사례 A: SynchronizationContext가 null인 경우
SynchronizationContext.Current가 null이면 작업을 계속할 위치가 지정되지 않은 것입니다.
이 경우, 사용 가능한 ThreadPool 스레드에서 자유롭게 작업이 계속될 수 있습니다.
이는 콘솔 애플리케이션, 백그라운드 스레드 및 Task.Run을 통해 시작된 코드에서 발생하는 기본 동작입니다.
실제로 (1)과 (2)는 서로 다른 스레드에서 실행될 수 있으며,
계속할 작업은 외견상 무작위 ThreadPool 스레드에서 재개될 수 있습니다.
다음과 같이 간주할 수 있습니다.
· 컨텍스트 없음 → 정해진 위치 없음 → 계속할 작업이 ThreadPool로 이동
사례 B: SynchronizationContext가 존재하는 경우
SynchronizationContext.Current가 null이 아니면 await는 다음과 같이 동작합니다.
1. 일시 중지하기 직전에 await는 현재 SynchronizationContext를 캡처합니다.
2. await 중인 작업이 완료되면 계속할 작업이 해당 컨텍스트로 포스트백됩니다.
3. 컨텍스트는 계속할 작업이 어디서 어떻게 실행될지 결정합니다.
이는 await 이후의 코드가 await 이전의 코드와 동일한 논리적 실행 위치에서 재개됨을 의미합니다.
예를 들어 많은 UI 프레임워크에서 이 논리적 위치는 단일 UI 스레드에 바인딩됩니다.
따라서 OtherAsync()가 백그라운드 스레드에서 실행되더라도,
SynchronizationContext가 캡처되는 한 (1)과 (2)는 UI 스레드에서 실행됩니다.
SynchronizationContext 및 스레드
다시 강조하자면, SynchronizationContext는 비동기 작업이 시작되는 위치를 제어하지 않고
await 이후에 실행이 재개되는 위치를 제어합니다.
일반적인 .NET 관점에서 보면 await는 여러 종류의 작업을 기다리는 데 사용할 수 있습니다.
중요한 점은 await 작업이 서로 다른 실행 컨텍스트에서 완료될 수 있다는 것입니다.
예를 들어 비동기 메서드 내에서 await를 사용하는 작업은 다음과 같을 수 있습니다.
· 여러 스케줄링 지점에 걸쳐 동일한 스레드에서 완료됩니다(Unity에서 일반적인 경우).
· 명시적으로 스케줄링되고 백그라운드 스레드에서 실행 및 완료됩니다(예: Task.Run).
· I/O 완료, 타이머 또는 엔진 콜백 같은 비스레드 기반 메커니즘에 의해 구동됩니다.
하지만 await가 완료되면 계속 진행되는 작업은 캡처된 SynchronizationContext로 라우팅됩니다.
이 컨텍스트는 다음을 통해 지원될 수 있습니다.
· 단일 스레드(예: UI 스레드)
· 여러 스레드(예: ThreadPool 기반 컨텍스트)
Unity의 경우
Unity의 경우 SynchronizationContext는 의도적으로 단일 스레드, 즉 메인 스레드에 바인딩됩니다.
이는 Unity 메인 스레드에서 시작된 비동기 메서드(예: Start, Update 또는 기타 Unity 콜백)가 다음과 같다는 의미입니다.
· 기본적으로 메인 스레드에서 계속 진행됩니다.
· 각 await 후에 Unity API와 안전하게 상호 작용할 수 있습니다.
추가 설명: 비동기 메서드가 실행되기 시작하는 위치
비동기 메서드는 항상 호출 스레드에서 동기적으로 실행되기 시작합니다.
다시 말해 비동기 메서드가 실행되기 시작하는 위치는 호출자에 의해 결정되며,
SynchronizationContext에 의해 결정되지 않습니다.
비동기 메서드를 시작한 후 발생하는 일은 해당 메서드 내부의 작업이 어떻게 구현되었는지에 따라 달라집니다.
특히 비동기 메서드 내부의 작업이 동일한 스레드에서 계속 실행되는지,
백그라운드 스레드를 사용하는지, 아니면 다른 실행 메커니즘에 의존하는지는 await 작업 자체에 의해 결정됩니다.
명시적으로 백그라운드 스레드로 실행을 전환하지 않는 한(예: Task.Run 또는 Awaitable.BackgroundThreadAsync를 통해), Unity의 메인 스레드에서 시작된 비동기 메서드는 기본적으로 계속 메인 스레드에서 실행됩니다.
SynchronizationContext는 await 작업이 완료된 후 실행이 재개되는 위치에만 영향을 미칩니다.
Unity에 자체 SynchronizationContext가 필요한 이유
Unity의 API는 근본적으로 메인 스레드 전용입니다. 게임 오브젝트를 생성하고,
트랜스폼을 수정하고, 씬 데이터에 액세스하는 등의 모든 작업은 Unity 메인 스레드에서 실행된다고 가정합니다.
따라서 Unity에서는 기본적으로 비동기 지속 작업이 메인 스레드에서 재개되어야 합니다.
이 규칙을 적용하기 위해 Unity는 UnitySynchronizationContext라는 커스텀 구현을 제공합니다.
UnitySynchronizationContext가 PlayerLoop와 통합되는 방법
UnitySynchronizationContext는 await 작업이 완료되더라도 곧바로 계속해서 작업을 실행하지 않습니다.
대신 작업을 대기열에 추가합니다. 내부적으로 비동기 지속 작업이 UnitySynchronizationContext에 포스트백되면
내부 작업 대기열에 추가됩니다.
그런 다음 Unity는 Unity PlayerLoop의 특정 지점에서 이 대기열을 처리합니다.
다시 말해 이 작업은 Unity 일반 프레임 업데이트 사이클의 일환으로 실행되는 것입니다.
이러한 설계 덕분에 다른 Unity 시스템과의 실행 순서가 결정적으로 유지되며,
비동기 코드가 Unity의 프레임 기반 모델과 깔끔하게 통합됩니다.
단일 프레임 지연
비동기 지속 작업은 대기열에 추가되고 나중에 PlayerLoop에서 플러시되기 때문에 일반적으로 다음 프레임에서 실행됩니다.
이와 같은 근본적인 이유로 인해 Unity 비동기 코드에서 일반적으로 관찰되는 ‘단일 프레임 지연’이 발생합니다.
다만 한 가지 예외가 있습니다. await 작업이 동기적으로 완료되면(실제로 일시 중지되지 않음),
작업이 동일한 프레임 내에서 계속 실행될 수 있습니다.
참고: Task.Run 및 컨텍스트 손실
메인 스레드에서만 안전한 Unity API를 Task.Run 내에서 호출하는 것은 일반적인 오류의 원인이 됩니다.
Task.Run(async () =>
{
await Task.Delay(100);
Debug.Log(Time.time); // 안전하지 않음
});
Task.Run은 ThreadPool 스레드에서 실행을 시작하며, 여기에는 UnitySynchronizationContext가 없습니다.
그 결과 await는 SynchronizationContext를 캡처하지 않으므로 지속 작업은 ThreadPool에서 재개됩니다.
실제 작동 방식
지금까지는 SynchronizationContext가 하는 일, 즉 대기 후 실행이 재개되는 위치를 결정하는 것에 집중했습니다.
이제 Unity가 실제로 그 규칙을 어떻게 적용하는지 살펴보겠습니다.
UnitySynchronizationContext는 본질적으로 PlayerLoop에서 플러시되는 메인 스레드 작업 대기열 역할을 합니다.
또한 비동기 실행에 중요한 두 가지 기본 요소를 제공합니다.
· Post(게시): 메인 스레드에서 나중에 실행할 작업을 예약합니다(비동기).
· Send(전송): 메인 스레드에서 작업을 동기적으로 실행하고 완료될 때까지 차단합니다.
이는 SynchronizationContext의 주요 스케줄링 API입니다.
Unity의 비동기 모델에서 await 지속 작업은 거의 항상 ‘Post’ 스타일 동작을 사용합니다.
· UnitySynchronizationContext의 C# 구현은 여기에서 찾을 수 있습니다.
개념적으로 실행 플로는 다음과 같습니다.
1. await 작업이 완료됩니다(대부분 백그라운드 스레드에서).
2. 지속 작업은 Unity 메인 스레드에서 재개되어야 합니다.
3. UnitySynchronizationContext는 Post를 통해 지속 작업을 수신합니다.
4. 지속 작업은 내부 작업 대기열에 추가됩니다.
5. PlayerLoop 동안 Unity는 Exec()를 호출하여 대기열을 플러시하고
메인 스레드에서 지속 작업을 실행합니다.
지속 작업 게시
코드에서 Post는 단순히 메인 스레드에서 나중에 실행할 작업을 대기열에 추가합니다.
// Post는 나중에 메인 스레드에서 실행될 작업 목록에 호출을 추가합니다.
// 작업은 비동기적으로 계속 진행됩니다.
public override void Post(SendOrPostCallback callback, object state)
{
lock (m_AsyncWorkQueue)
{
m_AsyncWorkQueue.Add(new WorkRequest(callback, state));
}
}
Post는 콜백을 즉시 실행하지 않으며, 메인 스레드에서 나중에 실행될 작업을 예약합니다.
Exec: 대기열 플러시
대기열에 추가된 지속 작업은 Exec() 메서드에 의해 실행됩니다.
// Exec는 작업 목록에서 작업을 실행합니다.
public void Exec()
{
lock (m_AsyncWorkQueue)
{
m_CurrentFrameWork.AddRange(m_AsyncWorkQueue);
m_AsyncWorkQueue.Clear();
}
while (m_CurrentFrameWork.Count > 0)
{
WorkRequest work = m_CurrentFrameWork[0];
m_CurrentFrameWork.RemoveAt(0);
work.Invoke();
}
}
여기에는 두 개의 중요한 대기열이 관련되어 있습니다.
· m_AsyncWorkQueue
모든 스레드에서 게시된 지속 작업을 유지합니다.
· m_CurrentFrameWork
현재 PlayerLoop 틱에서 실행될
지속 작업 세트를 유지합니다.
이 두 대기열 설계는 의도적으로 이루어진 것입니다.
Exec()가 실행되는 동안에도 다른 스레드가 Post()를 호출할 수 있습니다.
Unity는 사용자 코드를 실행하는 동안 잠금이 유지되지 않고
새로 게시된 지속 작업이 같은 플러시 사이클에서 실행되지 않도록,
먼저 작업을 m_CurrentFrameWork에 복사하고 m_AsyncWorkQueue를 비웁니다.
Exec() 호출 위치
그러면 누가 Exec()를 호출하는지 궁금해질 것입니다.
이 작업은 네이티브에서 관리형 코드로 변환되는 진입점을 통해 발생합니다.
[RequiredByNativeCode]
private static void ExecuteTasks()
{
var context = SynchronizationContext.Current as UnitySynchronizationContext;
if (context != null)
context.Exec();
}
ExecuteTasks()는 PlayerLoop에서 프레임당 한 번 호출됩니다. 이때가 비동기 지속 작업이 실제로 메인 스레드로 ‘반환’되는 순간입니다.
Send: 메인 스레드에서 동기 실행
Post 외에도 UnitySynchronizationContext는 메인 스레드에서 동기 실행에 사용되는 Send도 제공합니다.
public override void Send(SendOrPostCallback callback, object state)
{
if (m_MainThreadID == Thread.CurrentThread.ManagedThreadId)
{
callback(state);
}
else
{
using (var waitHandle = new ManualResetEvent(false))
{
lock (m_AsyncWorkQueue)
{
m_AsyncWorkQueue.Add(new WorkRequest(callback, state, waitHandle));
}
waitHandle.WaitOne();
}
}
}
· Send가 메인 스레드에서 호출되면 콜백이 즉시 실행됩니다.
· Send가 백그라운드 스레드에서 호출되면
콜백이 대기열에 추가되고 호출 스레드는
메인 스레드가 작업을 실행할 때까지 차단됩니다.
Send 예시
드물게 백그라운드 스레드가 Unity 메인 스레드에서 짧은 코드를 동기적으로 실행하고 결과를 기다려야 할 수 있습니다.
아래는 메인 스레드에서만 안전하게 액세스할 수 있는 데이터를 쿼리하는 경우입니다.
void BackgroundWorker()
{
var context = SynchronizationContext.Current;
int instanceCount = 0;
context.Send(_ =>
{
// 이 코드는 Unity 메인 스레드에서 실행됩니다.
instanceCount = GameObject.FindObjectsOfType().Length;
}, null);
// 메인 스레드 코드가 완료된 후에만 여기서 실행이 재개됩니다.
Debug.Log($"Total GameObjects: {instanceCount}");
}
이 예시의 경우:· BackgroundWorker는 백그라운드 스레드에서 실행되고 있습니다.
· Send는 콜백이 Unity 메인 스레드에서 실행되도록 강제합니다.
· 백그라운드 스레드는 콜백이 완료될 때까지 차단됩니다.
· 결과는 백그라운드 스레드에서 안전하게 사용됩니다.
중요:
Send가 호출 스레드를 차단하기 때문에,
메인 스레드가 동시에 해당 백그라운드 스레드를 기다리고 있다면 교착 상태에 빠지기 쉽습니다.
이러한 이유로 Send는 사용자 수준의 Unity 코드에서 거의 사용되지 않습니다.
대부분의 비동기 워크플로는 Post(await를 통해)에 의존해야 합니다.
요약
· SynchronizationContext는 비동기 실행이 재개되는 위치를 정의합니다.
· Unity는 소속 SynchronizationContext를 메인 스레드에 바인딩합니다.
· Unity에서 메인 스레드를 통해 시작한 비동기 메서드는 명시적으로 백그라운드 스레드로 전환하지 않는 한
Unity API와 함께 안전하게 사용할 수 있습니다.
· 비동기 지속 작업은 대기열에 추가되고 PlayerLoop에서 실행됩니다.
· ‘단일 프레임 지연’은 설계상 의도적인 선택입니다.
· Task.Run은 Unity 컨텍스트를 손실시키고 메인 스레드 안전성을 저해합니다.
· Send는 일반적인 비동기 패턴이 아니라 차단용 탈출구로 존재합니다.