.LAB
RETURN 에서 AWAIT 까지·RETURN 에서 AWAIT 까지 #5

Unity에서 비동기가 동기가 되는 순간

·5min read
  • Unity
  • Async/Await
  • SynchronizationContext
  • CancellationToken
  • Threading

UnitySynchronizationContext가 하는 일

#04에서 SynchronizationContext가 continuation의 실행 위치를 결정한다고 했다. Unity는 여기에 자체 구현을 넣는다. UnitySynchronizationContext다. 하는 일은 하나다. 모든 await continuation을 큐에 넣고, 메인 스레드의 PlayerLoop 안에서 실행한다.

C#
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을 메인 스레드로 보냈기 때문
}
프로파일러의 5520ms

실제로 겪은 상황이다. 이미지 파일 수십 장을 File.ReadAllBytesAsync로 읽는 코드가 있었다. 파일 I/O 자체는 스레드풀에서 일어났다. 문제는 await 이후였다. 바이트 배열 인코딩, 텍스처 생성, 머티리얼 할당이 전부 메인 스레드에서 실행됐다. 프로파일러를 열었을 때 ExecuteTasks()에 5520ms가 찍혀 있었다.

파일 읽기는 비동기였다. 그런데 실질적인 처리 시간 대부분이 메인 스레드에서 동기적으로 소비되고 있었다. I/O 대기 시간만 메인 스레드에서 빠졌을 뿐, 연산 비용은 한 바이트도 줄지 않았다. "비동기로 바꾸면 빨라진다"가 Unity에서 거짓이 되는 구조가 이것이다.

ConfigureAwait(false)의 존재 이유

continuation을 메인 스레드로 보내지 않는 방법이 있다.

C#
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다. 그 사이에 무슨 일이든 일어날 수 있다.

사진 편집 화면에서 있었던 일이다.

C#
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의 생명주기

CancellationTokenSource1회용이다.

C#
var cts = new CancellationTokenSource();

// 첫 번째 취소: 정상 동작
cts.Cancel();
cts.Dispose();

// 재사용 시도: 영구 고장
// cts.Token -> ObjectDisposedException
// 이후 모든 요청 실패

// 올바른 패턴
cts = new CancellationTokenSource();  // 재생성

클래스 필드에 CancellationTokenSource를 두고 여러 비동기 작업에 재사용하는 코드를 작성한 적이 있다. 첫 번째 취소는 정상 동작했다. 두 번째 요청부터 ObjectDisposedException이 발생했고, 이후 모든 요청이 실패했다.

LinkedTokenSource 패턴

1회용이라는 제약의 연장선에서 LinkedTokenSource 패턴이 필요해지는 상황이 있다. 상위 토큰과 대기 전용 토큰을 분리하면, 대기만 취소하고 루프는 유지할 수 있다.

C#
// 레코드 루프: 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 전후를 별개의 실행 단위로 보게 됐고, 제어가 지금 어디에 있고 어디로 돌아가는지를 먼저 확인하게 됐다.