
Unity에서 비동기가 동기가 되는 순간
- Unity
- Async/Await
- SynchronizationContext
- CancellationToken
- Threading
UnitySynchronizationContext가 하는 일
#04에서 SynchronizationContext가 continuation의 실행 위치를 결정한다고 했다. Unity는 여기에 자체 구현을 넣는다. UnitySynchronizationContext다. 하는 일은 하나다. 모든 await continuation을 큐에 넣고, 메인 스레드의 PlayerLoop 안에서 실행한다.
async Task LoadTexture(string path)
{
byte[] bytes = await File.ReadAllBytesAsync(path);
// I/O는 스레드풀에서 실행된다
var tex = new Texture2D(width, height);
tex.LoadRawTextureData(bytes);
tex.Apply();
// 이 코드는 전부 메인 스레드에서 실행된다
// UnitySynchronizationContext가 continuation을 메인 스레드로 보냈기 때문
}실제로 겪은 상황이다. 이미지 파일 수십 장을 File.ReadAllBytesAsync로 읽는 코드가 있었다. 파일 I/O 자체는 스레드풀에서 일어났다. 문제는 await 이후였다. 바이트 배열 인코딩, 텍스처 생성, 머티리얼 할당이 전부 메인 스레드에서 실행됐다. 프로파일러를 열었을 때 ExecuteTasks()에 5520ms가 찍혀 있었다.
파일 읽기는 비동기였다. 그런데 실질적인 처리 시간 대부분이 메인 스레드에서 동기적으로 소비되고 있었다. I/O 대기 시간만 메인 스레드에서 빠졌을 뿐, 연산 비용은 한 바이트도 줄지 않았다. "비동기로 바꾸면 빨라진다"가 Unity에서 거짓이 되는 구조가 이것이다.
ConfigureAwait(false)의 존재 이유
continuation을 메인 스레드로 보내지 않는 방법이 있다.
async Task ProcessHeavyData(string path)
{
byte[] bytes = await File.ReadAllBytesAsync(path)
.ConfigureAwait(false);
// 이제 continuation이 스레드풀에서 실행된다
var processed = HeavyComputation(bytes);
// 메인 스레드를 차지하지 않는다
// 하지만 여기서 이건 불가능하다:
// var tex = new Texture2D(...); // Unity API는 메인 스레드에서만 호출 가능
}ConfigureAwait(false)는 SynchronizationContext를 무시하라는 지시다. continuation이 스레드풀에서 실행되니 메인 스레드의 부하는 줄어든다. 대신 그 스레드에서는 Unity API를 호출할 수 없다. Texture2D, GameObject, Transform 같은 엔진 객체는 메인 스레드에서만 접근할 수 있다.
순수 연산은 ConfigureAwait(false)로 스레드풀에 넘기고, Unity API 호출은 메인 스레드에 남긴다. 이건 제약을 회피하는 게 아니라, 메인 스레드에서 해야 할 일과 분리할 수 있는 일을 구분하는 설계 판단이다. 5520ms 중 얼마가 순수 연산이고 얼마가 Unity API 호출인지를 측정한 뒤에야 분리 지점을 정할 수 있었다.
await 사이에 세상이 바뀐다
#03에서 싱글 스레드 위의 동시성을 다뤘다. yield 사이에 다른 코드가 실행된다는 것. await도 yield다. 그 사이에 무슨 일이든 일어날 수 있다.
사진 편집 화면에서 있었던 일이다.
async Task Done()
{
var image = currentImage; // await 전: image가 존재한다
var result = await ProcessAsync(image);
// 이 사이에 사용자가 화면을 닫는다
// Close()가 호출되어 image 리소스가 해제된다
Output(result); // await 후: image는 이미 파괴된 상태
}Done()이 이미지 처리를 await하는 동안 사용자가 화면을 닫았다. Close()가 호출되면서 이미지 리소스가 해제됐다. continuation이 실행됐을 때 참조하던 이미지는 이미 파괴된 상태였다. 결과물은 검은 이미지. 크래시도 예외도 없는, 조용한 실패였다.
멀티스레드 레이스 컨디션이 아니다. 스레드는 하나다. 그런데 async 흐름이 싱글 스레드 위에 동시성을 만들어냈고, 그 동시성이 리소스 생존 범위와 충돌한 것이다. #03에서 말한 구조가 실제 버그로 나타난 사례였다.
해결 방향은 세 가지다. await 전에 필요한 데이터의 소유권을 이전하거나, 참조 대신 복사본을 만들어 두거나, 작업이 완료될 때까지 리소스 해제를 막는 구조를 만든다. 딜레이를 넣어서 타이밍을 피하는 건 해결이 아니다.
CancellationToken의 생명주기
CancellationTokenSource는 1회용이다.
var cts = new CancellationTokenSource();
// 첫 번째 취소: 정상 동작
cts.Cancel();
cts.Dispose();
// 재사용 시도: 영구 고장
// cts.Token -> ObjectDisposedException
// 이후 모든 요청 실패
// 올바른 패턴
cts = new CancellationTokenSource(); // 재생성클래스 필드에 CancellationTokenSource를 두고 여러 비동기 작업에 재사용하는 코드를 작성한 적이 있다. 첫 번째 취소는 정상 동작했다. 두 번째 요청부터 ObjectDisposedException이 발생했고, 이후 모든 요청이 실패했다.
1회용이라는 제약의 연장선에서 LinkedTokenSource 패턴이 필요해지는 상황이 있다. 상위 토큰과 대기 전용 토큰을 분리하면, 대기만 취소하고 루프는 유지할 수 있다.
// 레코드 루프: 5분 간격 sleep, 새 요청 시 즉시 깨움
while (!parentToken.IsCancellationRequested)
{
await PrintAsync(parentToken);
using var delayCts = new CancellationTokenSource();
using var linked = CancellationTokenSource
.CreateLinkedTokenSource(parentToken, delayCts.Token);
try
{
await Task.Delay(TimeSpan.FromMinutes(5), linked.Token);
}
catch (OperationCanceledException)
{
// delayCts.Cancel()이면 대기만 취소, 루프는 계속
// parentToken이면 루프 전체 종료
}
}delayCts를 별도로 만들고 상위 토큰과 링크한다. 요청이 오면 delayCts.Cancel()로 대기만 취소하고, 상위 토큰은 건드리지 않는다. 루프 자체는 계속 돌아간다. 대기용 토큰만 매 반복마다 재생성하면 된다.
1회용이라는 제약은 불편하지만, 취소 신호의 의미론과 일치한다. 한번 발생한 취소는 되돌릴 수 없다. 재사용하고 싶다는 욕구 자체가, 취소와 재시작이 별개의 관심사라는 신호다. LinkedTokenSource는 그 분리를 구조로 표현한 것이다.
시선의 이동
이 시리즈는 #01의 return에서 시작했다. return이 값이 아니라 제어의 양도라는 것. #02에서 블로킹이라는 기본값을 봤고, #03에서 yield가 싱글 스레드 위에 동시성을 만드는 구조를 봤다. #04에서 SynchronizationContext가 continuation의 실행 위치를 결정한다는 것까지 왔다.
async/await를 처음 썼을 때는 async를 붙이면 비동기가 되는 줄 알았다. 프로파일러의 5520ms가 그 생각을 교정했고, 원인을 거슬러 올라가다 보니 return까지 갔다. 그 과정에서 바뀐 건 지식이 아니라 시선이었다. 코드를 읽을 때 await 전후를 별개의 실행 단위로 보게 됐고, 제어가 지금 어디에 있고 어디로 돌아가는지를 먼저 확인하게 됐다.