UniTask 는 기다릴 때만 풀에서 빌려 GC 를 만들지 않는다
Esc

    UniTask 는 기다릴 때만 풀에서 빌려 GC 를 만들지 않는다

    2026년 10월 4일 수정–Unity라이브러리동시성GC
    // 적 수만큼, 매 프레임 부른다
    async UniTask<Sprite> GetIconAsync(string key)
    {
        if (cache.TryGetValue(key, out var icon)) return icon;   // 대부분 여기서 끝난다
        icon = await LoadIconAsync(key);                          // 가끔만 기다린다
        cache[key] = icon;
        return icon;
    }

    같은 모양의 async Task<int> 는 await 없이 값만 돌려줘도 80 B, Task.Yield() 로 한 번 양보하면 376 B 를 만듭니다. UniTask 에서는 0.0 B 와 0.1 B 입니다.

    바로 끝나면 아무것도 만들지 않고, 정말 기다릴 때만 풀에서 러너를 빌렸다가 돌려주기 때문입니다. 기준 판본은 2.5.11 입니다.

    바로 끝나면 아무것도 만들지 않는다

    UniTask 에는 [AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))] 가 붙어 있습니다. 컴파일러는 async UniTask 메서드를 상태 머신 구조체로 바꾸고, 이 빌더를 그 안의 필드로 넣습니다. 빌더도 구조체이고 Create() 는 default 입니다. 러너를 만드는 자리는 한 군데뿐입니다.

    // AsyncUniTaskMethodBuilder<T> (소스를 줄여 옮김)
    public void Start<TStateMachine>(ref TStateMachine stateMachine)
    {
        stateMachine.MoveNext();
    }
    
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
    {
        // 러너를 만드는 유일한 자리. 첫 await 가 실제로 기다릴 때만 온다
        if (runnerPromise == null)
            AsyncUniTask<TStateMachine, T>.SetStateMachine(ref stateMachine, ref runnerPromise);
    
        awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
    }
    
    public void SetResult(T result)
    {
        if (runnerPromise == null)
            this.result = result;
        else
            runnerPromise.SetResult(result);
    }
    
    public UniTask<T> Task => runnerPromise != null ? runnerPromise.Task
                            : ex != null ? UniTask.FromException<T>(ex)
                            : UniTask.FromResult(result);

    두 경우에 값이 어떻게 달라지는지 견주면 이렇습니다.

    기다리지 않고 끝나면 러너 칸은 끝까지 null 이고, 결과는 빌더 필드에 있다가 값만 담은 UniTask<T> 로 나갑니다. 1회당 Mono·IL2CPP Development 플레이어에서 0.0 B 였고, async Task<int> 는 80 B, 88 B 였습니다.

    기다릴 때만 러너를 풀에서 빌린다

    기다림이 걸리면 스택의 상태 머신이 살아 있을 자리가 힙에 필요합니다. 빌더는 첫 await 에서 러너 AsyncUniTask<TStateMachine> 을 풀에서 꺼내 상태 머신을 옮깁니다.

    • 러너는 풀에서 꺼내고, 비었을 때만 new 합니다.
    • 상태 머신은 제네릭 필드 TStateMachine 에 값으로 담겨 박싱이 없습니다.
    • MoveNext 델리게이트는 러너 생성자에서 한 번 만든 것을 재사용합니다.

    풀은 상태 머신 타입마다, 곧 async 메서드마다 따로 있습니다. await UniTask.Yield() 한 번짜리 메서드가 워밍업 뒤 1회당 Mono 0.1 B, IL2CPP 0.2 B 였고, Task.Yield() 판은 376 B, 384 B 였습니다.

    IL2CPP 에서는 러너가 프레임 끝에 돌아온다

    러너는 호출한 쪽이 GetResult 로 결과를 꺼낼 때 버전을 올리고 상태 머신을 비워 풀로 돌아갑니다. IL2CPP 에서는 VM 버그를 피하려고 이 반환을 그 프레임의 LastPostLateUpdate 로 미룹니다. 같은 메서드를 세 번 이어 기다려 봅니다.

    // StateMachineRunner.cs (소스를 줄여 옮김)
    public static void SetStateMachine(ref TStateMachine stateMachine, ref IStateMachineRunnerPromise runnerPromiseFieldRef)
    {
        // 풀에 있으면 꺼내고, 비었을 때만 새로 만든다
        if (!pool.TryPop(out var result))
            result = new AsyncUniTask<TStateMachine>();
    
        runnerPromiseFieldRef = result;
        result.stateMachine = stateMachine;
    }
    
    public void GetResult(short token)
    {
        try
        {
            core.GetResult(token);
        }
        finally
        {
    #if ENABLE_IL2CPP
            // 반환을 이 프레임의 LastPostLateUpdate 로 미룬다
            PlayerLoopHelper.AddContinuation(PlayerLoopTiming.LastPostLateUpdate, returnDelegate);
    #else
            // 그 자리에서 비우고 풀에 넣는다
            TryReturn();
    #endif
        }
    }

    호출마다 풀에서 꺼낸 러너와 풀의 상태는 이렇습니다.

    순차 실행이어도 IL2CPP 는 반환이 한 박자 늦어 러너가 하나 더 필요합니다. 워밍업이 끝나면 둘 다 새로 만들지 않습니다.

    실측도 같았습니다. 200 회 순차로 돌린 뒤 러너 풀은 Mono 1, IL2CPP 2 였고, 1회당 할당은 둘 다 0 이었습니다. 한 번만 드는 고정비는 다릅니다. 같은 메서드를 동시에 N 개 돌릴 때 Mono 16.5 KB, IL2CPP 32.7 KB 였습니다. IL2CPP 에서만 미뤄 둔 반환이 LastPostLateUpdate 대기열의 두 버퍼를 16,384 칸까지 키웠기 때문입니다.

    이 0 은 최적화 컴파일에서만 성립한다

    최적화를 끄고 컴파일하면 상태 머신이 클래스가 되어, 부를 때마다 그 객체가 하나 생깁니다. 기준은 DEBUG 심볼이나 Development 여부가 아니라 최적화 컴파일이 켜졌는가 입니다.

    값은 N = 1,000 과 2,000 을 동시에 돌린 차이를 N 차이로 나눈 한계비용이고, ProfilerRecorder 의 “GC Allocated In Frame” 으로 쟀습니다. Debug 쪽 56 B, 48 B 는 상태 머신 몫이라 최적화를 켜면 0 이 됩니다. Task 는 어느 조건에서도 줄지 않습니다. 출하 빌드는 최적화 컴파일이므로, 플레이어가 받는 코드에서 0 이 성립합니다.

    주의할 점

    1. 할당은 최적화 컴파일에서 잰다

    안 지키면 생기는 문제. 에디터의 기본 Code Optimization 은 Debug 입니다. 여기서 프로파일러를 보면 async 메서드마다 48~64 B 가 찍혀 “UniTask 도 할당한다” 는 잘못된 결론에 닿습니다. Development 빌드에 Script Debugging 을 켜도 같습니다.

    이렇게 쓴다. 에디터 Code Optimization 을 Release 로 바꾸거나, Script Debugging 을 끈 Development 플레이어에서 잽니다.

    2. 끝나지 않는 await 는 지역 변수를 붙잡는다

    안 지키면 생기는 문제. 기다리는 동안 상태 머신은 러너 안에 있고, 러너는 대기 객체에 맡긴 MoveNext 로 붙잡혀 있습니다. WaitUntil(() => ready) 처럼 영영 끝나지 않고 토큰도 없으면, 그 메서드의 지역 변수가 가리키는 텍스처나 오브젝트가 풀려나지 못합니다.

    이렇게 쓴다. 오래 기다릴 수 있는 await 에는 destroyCancellationToken 같은 토큰을 넘깁니다. 취소로 끝나도 러너는 비워져 풀로 돌아갑니다.

    3. IL2CPP 에서는 두 번 await 가 예외 없이 러너를 망가뜨린다

    안 지키면 생기는 문제. IL2CPP 에서는 버전을 올리는 Reset 도 프레임 끝으로 밀립니다. await UniTask.Yield() 한 번짜리 async 메서드가 돌려준 t 를 await t; await t; 하자 두 번째가 예외 없이 통과했고, 프레임이 지난 뒤 러너 풀은 2 였습니다. 러너 하나가 두 번 들어간 것입니다. 이어서 같은 메서드를 두 번 부르자 두 호출이 같은 러너와 같은 토큰을 받았습니다. Mono 와 에디터에서는 두 번째 await 가 곧바로 InvalidOperationException 입니다.

    이렇게 쓴다. 에디터에서 예외가 안 났다고 안심하지 말고, 두 번 await 자체를 코드에서 없앱니다.

    정리

    UniTask 의 async 메서드는 바로 끝나면 아무것도 만들지 않고, 기다릴 때만 풀에서 러너를 빌렸다가 돌려주기 때문에 반복해서 불러도 GC 가 생기지 않습니다.

    • 바로 끝나면 결과를 빌더 필드에 두고 값만 돌려줍니다(0.0 B, Task 80~88 B).
    • 기다리면 첫 await 에서만 러너를 풀에서 꺼내고 박싱과 델리게이트 할당 없이 씁니다(0.10.2 B, Task 376384 B). IL2CPP 는 반환이 프레임 끝이라 러너가 하나 더 듭니다.
    • 이 0 은 최적화 컴파일에서만 성립합니다. 에디터 Debug 와 Script Debugging 빌드에서는 48~64 B 가 생깁니다.