---
title: "UniTask 는 다 쓴 대기 객체를 풀에서 다시 쓴다"
description: "UniTask 대기를 반복해도 GC 가 없는 것은 다 쓴 대기 객체를 타입별 풀에 돌려 다시 쓰기 때문입니다. 풀은 할당도 락도 없고, 경합하면 기다리지 않고 포기하며, 가장 바빴던 순간만큼 자라서 줄지 않습니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성", "GC"]
canonical: https://blog.unialgames.com/posts/unity-unitask-pool/
---
```csharp
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 가 끝나면 풀로 돌아간다

```csharp
// 꺼낼 때 (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 로 내립니다.

```csharp
// 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 로 올립니다.

```csharp
// 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` 이 아니라 게이트 하나입니다.

```csharp
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 30~45 %, IL2CPP 32~49 % 였습니다. 빌리기에 실패한 만큼 새 객체가 생겨 풀이 부풉니다. 메인 스레드 하나만 풀을 만지는 보통의 게임 코드에서는 실패가 없습니다.

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

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

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

```csharp
// 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<T> 필드를 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` 입니다.