UniTask 는 다 쓴 대기 객체를 풀에서 다시 쓴다
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입니다.