UniTask 의 취소는 예외로 끝난다
Esc

    UniTask 의 취소는 예외로 끝난다

    2026년 10월 4일 수정–Unity라이브러리동시성
    var popup = Instantiate(popupPrefab);
    try
    {
        await UniTask.Delay(3000, cancellationToken: ct);   // 여기서 취소되면
    }
    finally
    {
        Destroy(popup);                                      // 이 줄은 돈다
    }

    StopCoroutine 으로 멈춘 코루틴은 finally 가 돌지 않습니다. UniTask 로 옮기면 반대로 finally 는 도는데, 취소가 오류 로그로 찍히고, 할당 0 이라더니 취소마다 GC 가 생깁니다.

    UniTask 의 취소는 토큰을 받은 대기가 스스로 OperationCanceledException 을 던지며 끝나는 일입니다. 끝난 길에 따라 대기 객체가 풀로 돌아가기도 하고 버려지기도 합니다.

    취소는 대기가 예외를 던지며 끝나는 것이다

    취소로 끝날 때 대기 객체는 새 OperationCanceledException 을 담아 두고, await 한 쪽의 GetResult() 에서 던집니다. 그래서 받는 쪽도 예외로 받습니다. OperationCanceledException 도 Exception 이라 순서가 바뀌면 취소가 오류 로그로 찍힙니다.

    try
    {
        await ShowRewardAsync(ct);
    }
    catch (OperationCanceledException) { }        // 취소. 정상 흐름
    catch (Exception ex) { Debug.LogException(ex); }

    Delay 하나를 기본값으로 취소한 1회당 할당입니다.

    구성 Mono IL2CPP
    CancellationTokenSource 만들고 취소하고 해제 64 B 64 B
    예외를 만들고 던지고 받기 264 B 336 B
    전체 352 B 464 B

    할당 0 은 정상으로 끝나는 대기의 이야기입니다. 취소마다 생기는 GC 의 정체는 예외 객체입니다.

    기본 취소는 다음 MoveNext 가 알아챈다

    토큰을 넘겨도 누가 대기를 깨워 주지는 않습니다. 기본값에서는 대기 객체가 자기 차례에 토큰을 보고 스스로 끝납니다. 대기 객체의 MoveNext() 는 모두 이렇게 시작합니다.

    if (cancellationToken.IsCancellationRequested)
    {
        core.TrySetCanceled(cancellationToken);
        return false;
    }

    Cancel() 을 부른 순간에는 아무 일도 없고, 러너가 다음에 MoveNext() 를 부를 때 끝납니다. 토큰에 등록하는 것이 없어 등록 비용이 없고, 러너가 알아채니 이어지는 코드는 늘 메인 스레드에서 돕니다. 토큰을 받지 않은 대기는 볼 토큰이 없으니 끝까지 돕니다.

    즉시 취소는 Cancel 을 부른 자리에서 끝난다

    cancelImmediately: true 를 넘기면 Create() 가 토큰에 콜백을 겁니다. Cancel() 을 부른 그 호출 안에서, 그 스레드에서 대기가 끝납니다.

    반응은 빠르지만 대가가 둘입니다. 워커 스레드에서 취소하면 이어지는 코드가 워커에서 돕니다. 그리고 할당이 늘어납니다.

    Delay 1회를 취소 (개발 빌드 플레이어) Mono IL2CPP
    cancelImmediately: false 352 B 464 B
    cancelImmediately: true, 풀이 차 있을 때 704 B 816 B
    cancelImmediately: true, 풀이 비었을 때 816 B 936 B

    352 B 가 콜백 등록 몫이고, 풀이 비었을 때 더 드는 112 B(IL2CPP 120 B)는 대기 객체를 새로 만드는 값입니다. 왜 새로 만드는지가 다음 절입니다.

    풀로 돌아가는 문은 GetResult 하나다

    대기 객체는 GetResult() 의 finally 에서만 풀로 돌아갑니다. 즉시 취소로 끝났으면 그 문 앞에서 돌려보내지 않습니다.

    public void GetResult(short token)
    {
        try
        {
            core.GetResult(token);             // 취소로 끝났으면 여기서 OperationCanceledException 을 던진다
        }
        finally
        {
            // 즉시 취소 옵션이 켜져 있고 실제로 취소됐을 때만 풀로 돌려보내지 않는다
            if (!(cancelImmediately && cancellationToken.IsCancellationRequested))
                TryReturn();
            else
                TaskTracker.RemoveTracking(this);   // 추적만 끈다. 버전도 등록도 그대로다
        }
    }
    
    bool TryReturn()
    {
        TaskTracker.RemoveTracking(this);
        core.Reset();                          // 버전을 올려 이전 UniTask 값으로는 다시 못 쓰게 한다
        cancellationToken = default;
        cancellationTokenRegistration.Dispose();   // 걸어 둔 취소 콜백을 푼다
        return pool.TryPush(this);             // 풀로 돌아간다
    }

    끝나는 방식마다 finally 의 조건이 이렇게 갈립니다.

    • 정상 완료. 취소 요청이 없으니 TryReturn() 을 부릅니다. 풀로 돌아갑니다.
    • 기본 취소. MoveNext() 가 취소를 보고 끝낸 경우입니다. cancelImmediately 가 거짓이라 TryReturn() 을 부릅니다. 예외는 던지지만 풀로 돌아갑니다.
    • 즉시 취소. 취소 콜백이 그 자리에서 끝낸 경우입니다. 두 조건이 모두 참이라 TryReturn() 을 건너뜁니다. 풀로 돌아가지 않습니다.

    즉시 취소가 반복되면 풀이 줄고 결국 매번 새로 만듭니다. 위 표의 112 B 가 그 DelayPromise 하나입니다.

    취소를 예외 대신 bool 로 받는 SuppressCancellationThrow() 는 문을 아예 거치지 않습니다. 취소로 끝나면 원본의 GetResult() 를 부르지 않기 때문입니다. Delay(1000, ct) 를 N 개 걸고 곧바로 취소해 받았습니다.

    받는 방식 풀 상태 Mono IL2CPP 풀 (전 → 후, N=2,000)
    SuppressCancellationThrow() 채움 232 B 232 B 2,500 → 500
    try/catch 채움 288 B 368 B 2,500 → 2,500
    SuppressCancellationThrow() 비움 410 B 418 B 0 → 0
    try/catch 비움 400 B 488 B 0 → 2,000

    한 번만 보면 SuppressCancellationThrow() 가 싸지만 풀이 N 만큼 줄었습니다. 풀이 빈 뒤에는 Mono 에서 try/catch 와 비슷해집니다.

    주의할 점

    1. 모든 await 에 토큰을 넘긴다

    안 지키면 생기는 문제. 바깥 메서드가 토큰을 받아도 안쪽 Delay 나 WaitUntil 에 넘기지 않으면 그 대기는 취소를 모릅니다. 오브젝트가 파괴된 뒤에 이어지는 코드가 파괴된 오브젝트를 건드립니다.

    이렇게 쓴다. 진입점에서 this.destroyCancellationToken 을 받아 모든 await 에 끝까지 넘깁니다.

    2. cancelImmediately 는 필요한 곳에만 켠다

    안 지키면 생기는 문제. 켜면 등록 비용이 붙고, 취소된 대기 객체가 풀로 돌아가지 않습니다. 워커 스레드에서 취소하면 이어지는 코드가 그 스레드에서 돌아 유니티 API 호출이 실패합니다.

    이렇게 쓴다. 기본값 false 로 둡니다. 다른 스레드에서 긴 대기를 그 자리에서 끊어야 할 때만 켜고, 이어지는 코드 앞에 await UniTask.SwitchToMainThread() 를 둡니다.

    3. 자주 취소되는 대기에 SuppressCancellationThrow 를 쓸 때 풀을 본다

    안 지키면 생기는 문제. 취소될 때마다 원본 대기 객체가 풀로 돌아가지 않아, 예외를 피하려다 할당이 다른 곳에서 생깁니다.

    이렇게 쓴다. 드물게 취소되는 곳은 편한 쪽을 씁니다. 취소가 반복되는 곳은 try/catch 로 받거나 TaskPool.GetCacheSizeInfo() 로 풀 크기를 지켜봅니다.

    정리

    UniTask 의 취소는 토큰을 받은 대기가 OperationCanceledException 을 던지며 끝나는 일입니다.

    • 취소 한 번은 예외 값을 냅니다(Delay 기준 Mono 352 B, IL2CPP 464 B). catch (OperationCanceledException) 을 먼저 둡니다.
    • 기본값은 다음 MoveNext() 가 알아채 메인 스레드에서 끝나고, cancelImmediately: true 는 Cancel() 을 부른 스레드에서 그 자리에 끝납니다.
    • 풀로 돌아가는 문은 GetResult() 하나라서, 즉시 취소와 SuppressCancellationThrow() 로 끝난 대기 객체는 돌아가지 않습니다.