UniTask 는 다 쓴 대기 객체를 풀에서 다시 쓴다
Esc

    UniTask 는 다 쓴 대기 객체를 풀에서 다시 쓴다

    2026년 10월 4일 수정–Unity라이브러리동시성GC
    for (int i = 0; i < 1000; i++)
        await UniTask.Delay(1);

    코루틴의 WaitForSeconds 는 기다릴 때마다 76 B 를 만듭니다. 위 루프의 UniTask.Delay(1) 은 워밍업 뒤 개당 0.1~0.2 B, 곧 계측 잡음 수준이었습니다.

    UniTask 는 다 쓴 대기 객체를 타입마다 있는 풀 TaskPool<T> 에 돌려놓고, 다음 호출이 그것을 다시 꺼내 씁니다. 위 루프 동안 DelayPromise 는 한 번만 만들어집니다. 기준 판본은 2.5.11 입니다.

    await 가 끝나면 풀로 돌아간다

    // 꺼낼 때 (YieldPromise.Create 안)
    if (!pool.TryPop(out var result))
        result = new YieldPromise();   // 풀이 비었을 때만 만든다
    
    // 돌려줄 때 (GetResult 의 finally 안, 줄임)
    core.Reset();                      // 버전을 올려 옛 UniTask 값을 무효로
    pool.TryPush(this);                // 반환값은 보지 않는다

    대기 객체는 await 한 쪽이 결과를 꺼내는 GetResult 의 finally 에서 돌아갑니다. 성공이든 예외든 돌아가므로, 차례로 기다리는 천 번의 Delay 는 첫 번째가 만든 객체 하나를 계속 씁니다.

    풀은 객체 안의 칸으로 쌓아 할당이 없다

    풀은 풀링되는 클래스의 정적 필드 static TaskPool<T> pool; 입니다. 타입마다 하나라 찾을 때 조회나 캐스팅이 없습니다. TaskPool<T> 는 gate, size, root 세 필드짜리 가변 구조체이고, 객체를 담는 배열이 없습니다. 풀링되는 객체가 자기 필드 nextNode 에 다음 객체를 적어 두고, 풀은 맨 위 root 만 압니다. 이런 모양을 침입형 연결 리스트라고 부릅니다.

    풀에 A, B, C 가 있고 맨 위가 C 일 때입니다.

    빌릴 때는 맨 위 C 를 떼어 내고 root 를 B 로 내립니다.

    // TryPop
    if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)   // 게이트를 잠근다
    {
        var v = root;                                       // 맨 위 C 를 집는다
    
        if (!(v is null))
        {
            ref var nextNode = ref v.NextNode;              // C 의 nextNode 칸을 잡는다
            root = nextNode;                                // 맨 위가 B 로 내려간다
            nextNode = null;                                // C 가 B 를 더는 붙잡지 않게 비운다
            size--;
            result = v;
            Volatile.Write(ref gate, 0);                    // 게이트를 연다
            return true;
        }
    
        Volatile.Write(ref gate, 0);
    }
    
    result = default;
    return false;

    돌려줄 때는 C 의 nextNode 에 지금 맨 위 B 를 적고 root 를 C 로 올립니다.

    // TryPush
    if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)   // 게이트를 잠근다
    {
        if (size < TaskPool.MaxPoolSize)                    // 상한 기본값은 int.MaxValue
        {
            item.NextNode = root;                           // C 의 nextNode 에 B 를 적는다
            root = item;                                    // C 가 맨 위가 된다
            size++;
            Volatile.Write(ref gate, 0);                    // 게이트를 연다
            return true;
        }
    
        Volatile.Write(ref gate, 0);
    }
    
    return false;

    값은 이렇게 바뀝니다.

    시점 스택 root size C 의 nextNode
    처음 C → B → A C 3 B
    C 를 빌린 뒤 B → A B 2 null
    C 를 돌려준 뒤 C → B → A C 3 B

    바뀌는 것은 root 와 객체의 nextNode 한 칸뿐이라, 빌리고 돌려주는 일에 할당이 없습니다. ITaskPoolNode<T> 가 ref T NextNode 를 요구하는 것도 이 칸을 직접 읽고 쓰기 위해서입니다.

    경합하면 기다리지 않고 새로 만든다

    동기화는 lock 이 아니라 게이트 하나입니다.

    if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)   // 0 이면 1 로 바꾸고 들어간다
    {
        // 스택을 만진다
        Volatile.Write(ref gate, 0);
    }
    // 못 들어왔으면 다시 시도하지 않는다

    이미 1 이면 그 자리에서 실패합니다. 빌리기에 실패하면 new, 돌려주기에 실패하면 그 객체를 버립니다. 동작은 틀어지지 않고, 할당이나 GC 대상이 하나 늘 뿐입니다. 스레드 여럿이 같은 풀을 쉬지 않고 두드린 결과입니다.

    스레드 수 TryPop 실패 TryPush 실패 끝난 뒤 풀 크기
    1 0 % 0 % 1,024
    2 45.7 % 44.9 % 4,224
    4 35.3 % 34.6 % 10,298
    8 28.6 % 28.1 % 17,201

    개발 빌드 플레이어에서도 Mono 3045 %, IL2CPP 3249 % 였습니다. 빌리기에 실패한 만큼 새 객체가 생겨 풀이 부풉니다. 메인 스레드 하나만 풀을 만지는 보통의 게임 코드에서는 실패가 없습니다.

    풀은 가장 바빴던 만큼 자라고 줄지 않는다

    풀 크기는 동시에 살아 있던 최대 개수까지 자라고, TaskPool 에는 비우는 메서드가 없습니다. UniTask.Delay(1) 300 개를 동시에 끝내고 300 프레임을 더 기다려도 DelayPromise 풀은 300 이었습니다. 한 번 Yield 하는 async 메서드 셋을 WhenAll 로 묶어 2,000 벌 동시에 돌린 뒤 그 메서드의 러너 풀은 6,000 이었습니다. 러너 하나는 await 를 넘어 사는 참조 지역 변수가 없을 때 Mono 241 B, IL2CPP 377 B 이고, 참조 하나마다 8 B 씩 커집니다.

    막을 수 있는 것은 앞으로의 반납뿐입니다.

    // Runtime/TaskPool.cs (줄임)
    static TaskPool()
    {
        var value = Environment.GetEnvironmentVariable("UNITASK_MAX_POOLSIZE");
        if (value != null && int.TryParse(value, out var size)) { MaxPoolSize = size; return; }
        MaxPoolSize = int.MaxValue;           // 기본은 사실상 상한이 없다
    }
    
    public static void SetMaxPoolSize(int maxPoolSize) => MaxPoolSize = maxPoolSize;
    • 환경 변수는 정적 생성자에서 한 번만 읽습니다. 실행 중에는 TaskPool.SetMaxPoolSize(n) 을 부릅니다.
    • 상한은 타입 하나당 개수입니다.
    • 이미 들어 있는 것은 상한을 낮춰도 남습니다.

    UNITASK_MAX_POOLSIZE=16 을 준 개발 빌드 플레이어에서 같은 300 개 실험의 DelayPromise 풀은 16 이었습니다. 실행 중에 SetMaxPoolSize(16) 을 부른 뒤 처음 쓰는 풀도 16 에서 멈췄습니다. 지금 크기는 TaskPool.GetCacheSizeInfo() 로 봅니다.

    주의할 점

    1. TaskPool 필드를 readonly 로 선언하지 않는다

    안 지키면 생기는 문제. TaskPool<T> 는 공개 타입이라 직접 만든 대기 객체도 쓸 수 있지만 가변 구조체입니다. static readonly TaskPool<T> pool; 로 두면 메서드를 부를 때마다 복사본에서 불려, root 와 size 변경이 사라집니다. 풀이 늘 빈 것처럼 동작해 매번 새로 만듭니다.

    이렇게 쓴다. UniTask 소스처럼 static TaskPool<T> pool; 로 두고, T nextNode; 와 public ref T NextNode => ref nextNode; 를 함께 둡니다.

    2. 워커 스레드에서 대기 객체를 쏟아내지 않는다

    안 지키면 생기는 문제. 게이트는 경합하면 실패합니다. 여러 스레드가 같은 종류의 대기 객체를 쉬지 않고 빌리고 돌려주면 그만큼 새로 만들어 풀이 커집니다. 8 스레드가 두드린 풀은 한 스레드일 때의 17 배였습니다.

    이렇게 쓴다. 워커의 계산은 한 덩어리로 끝내고 결과만 메인으로 넘깁니다. 워커 안에서 Yield 를 촘촘히 반복하지 않습니다.

    3. 로딩 뒤 풀이 줄어들 것이라 기대하지 않는다

    안 지키면 생기는 문제. 로딩에서 Delay 나 WhenAll 을 수천 개 동시에 돌리면 그 수만큼의 대기 객체가 게임 끝까지 남습니다. 메모리 프로파일러에서 UniTask 타입이 줄지 않는 것을 누수로 오해하기 쉽습니다.

    이렇게 쓴다. TaskPool.GetCacheSizeInfo() 로 타입별 크기를 봅니다. 동시 실행 최대치와 같다면 정상입니다. 메모리가 빠듯하면 쌓이기 전에, 곧 시작할 때 TaskPool.SetMaxPoolSize(n) 을 부릅니다.

    정리

    UniTask 대기는 다 쓴 대기 객체를 그릇도 락도 없는 타입별 풀에 돌려 다시 쓰기 때문에 GC 가 생기지 않습니다.

    • 풀은 객체의 nextNode 칸으로 쌓는 스택이라 빌리고 돌려주는 데 할당이 없습니다.
    • 게이트가 막히면 기다리지 않고 포기합니다. 빌리기 실패는 new, 돌려주기 실패는 버림입니다.
    • 풀은 동시 실행 최대치까지 자라고 줄지 않습니다. 상한은 UNITASK_MAX_POOLSIZE 나 SetMaxPoolSize 로 정하고, 기본은 int.MaxValue 입니다.