UniTask 는 풀에서 빌린 객체로 await 를 처리한다
UniTask<Config> loading; // Awake 에서 한 번 시작해 둔다
async UniTask InitHudAsync() { var c = await loading; /* ... */ }
async UniTask InitShopAsync() { var c = await loading; /* ... */ } // 둘 중 하나가 예외
Task 였다면 문제없는 코드입니다. UniTask 에서는 한쪽이 InvalidOperationException 을 받습니다.
UniTask 는 결과를 담은 객체가 아니라, 풀에서 빌린 대기 객체를 가리키는 참조 하나와 번호 하나입니다. await 가 끝나면 그 객체는 풀로 돌아가므로 한 번만 기다릴 수 있습니다. 기준 판본은 2.5.11 입니다.
UniTask 값은 참조 하나와 번호 하나다
// Runtime/UniTask.cs (줄임)
public readonly partial struct UniTask
{
readonly IUniTaskSource source; // 기다림을 들고 있는 대기 객체
readonly short token; // 그 객체의 몇 번째 사용분인가
public UniTaskStatus Status
{
get
{
if (source == null) return UniTaskStatus.Succeeded; // 객체가 없으면 끝난 것
return source.GetStatus(token);
}
}
}
source 는 DelayPromise 나 async 메서드의 러너 같은 대기 객체입니다. source 가 null 이면 그 자체로 성공 완료입니다. 그래서 UniTask.CompletedTask 와 UniTask.FromResult(42) 는 객체를 하나도 만들지 않습니다(UniTask<T> 는 결과 칸이 하나 더 있습니다). Task.FromResult(42) 는 매번 Task 를 만듭니다.
두 필드뿐이라 복사하고 넘기는 데 비용이 없습니다. 문제는 source 가 있을 때입니다.
await 가 끝나면 대기 객체는 풀로 돌아간다
await t 는 대기 객체에 GetStatus, OnCompleted, GetResult 를 차례로 부르고, 매번 토큰을 함께 넘깁니다. t = UniTask.Delay(200) 이 (P, 5) 일 때입니다.
마지막 단계가 핵심입니다. GetResult 가 P 의 버전을 6 으로 올리고 풀로 돌려보내면, 다음 Delay 가 P 를 꺼내 씁니다. 그런데 t 는 구조체라 사본들이 여전히 (P, 5) 를 들고 있습니다.
번호가 낡은 사본을 막는다
대기 객체는 들어온 토큰을 자기 버전과 맞춰 보고, 다르면 예외를 던집니다.
var t = UniTask.Delay(200); // 풀에서 P 를 꺼냈다. P 의 버전이 5 라 t = (P, 5)
await t; // 5 와 5 가 같아 통과. 끝나면 P 는 버전 6 으로 풀에 돌아간다
var u = UniTask.Delay(500); // 풀 맨 위의 P 를 그대로 꺼낸다. u = (P, 6)
await t; // t 는 아직 (P, 5). 5 와 6 이 달라 예외
// P 안에서 매번
void ValidateToken(short token)
{
if (token != version)
throw new InvalidOperationException("... can not await twice ...");
}
t 는 구조체라 첫 await 가 끝난 뒤에도 (P, 5) 를 그대로 들고 있습니다. 그사이 P 는 버전이 6 으로 올랐고, 세 번째 줄에서는 u 가 P 를 빌려 500 ms 를 세고 있습니다. 두 번째 await t 는 그 P 에 토큰 5 를 내밀고, 버전과 달라 InvalidOperationException 을 받습니다. u 의 상태는 건드려지지 않습니다.
번호가 없었다면 낡은 t 는 남의 500 ms 대기를 자기 것으로 읽었을 것입니다. 번호 덕에 낡은 쪽만 예외를 받습니다. 막히는 길은 둘입니다.
| 무엇을 했나 | 메시지 앞부분 |
|---|---|
끝난 뒤에 다시 await 하거나 Status 를 읽었다 |
Token version is not matched |
| 끝나기 전에 두 곳에서 동시에 await 했다 | Already continuation registered |
두 번째는 버전이 같아 토큰 검사를 통과하지만, 대기 객체가 이어 부를 곳을 하나만 받아서 걸립니다. 맨 위 HUD 와 상점은 타이밍에 따라 둘 중 어느 것이든 받습니다.
번호는 short 라 65,536 번 재사용되면 같은 번호가 돌아옵니다. 풀 크기를 1 로 줄이고 첫 번호를 들고 있었더니 65,535 번째까지는 예외가 났고, 65,536 번째에는 옛 번호로 새 작업의 결과를 꺼내 갔습니다. 옛 값을 그만큼 오래 들고 있을 때만 생기는 일입니다.
다시 읽으려면 Preserve 로 결과를 기억시킨다
Preserve() 는 대기 객체를 MemoizeSource 로 감쌉니다. 첫 GetResult 에서 결과(또는 예외)를 자기 필드에 적고 원래 객체는 풀로 보낸 뒤, 그다음부터는 자기 필드에서 답합니다. 이미 끝난 값이면 그대로 돌려줍니다.
Preserve() 한 번의 값은 MemoizeSource 하나, 40 B 였습니다. 결과를 기억하기 전에는 등록을 원래 객체에 넘기므로, 끝나기 전에 두 곳에서 기다리면 세 번째 탭처럼 예외입니다.
주의할 점
1. UniTask 를 필드에 담아 여러 곳에서 await 하지 않는다
안 지키면 생기는 문제. 맨 위 loading 처럼 여러 컴포넌트가 같은 값을 기다리면, 먼저 끝난 쪽이 대기 객체를 풀로 돌려보내고 나머지는 예외를 받습니다. 어느 쪽이 터질지는 타이밍에 따라 달라집니다.
이렇게 쓴다. 여러 곳에서 기다릴 결과는 UniTaskCompletionSource<T> 의 Task 를 나눠 주거나 AsyncLazy<T> 로 감쌉니다.
2. await 한 뒤에 Status 를 읽지 않는다
안 지키면 생기는 문제. await t; 뒤에 로그를 남기려고 t.Status 를 읽으면 버전이 이미 올라가 있어 예외가 납니다.
이렇게 쓴다. 성공, 실패, 취소는 await 결과로 받습니다. 취소를 예외 없이 받으려면 await t.SuppressCancellationThrow() 로 bool 을 받습니다.
3. Preserve 를 동시 대기용으로 쓰지 않는다
안 지키면 생기는 문제. Preserve() 는 끝난 결과를 되풀이해 읽는 것만 허락합니다. 끝나기 전에 두 곳에서 await 하면 Already continuation registered 가 납니다.
이렇게 쓴다. 차례로 다시 읽을 때만 Preserve() 를 쓰고, 동시에 기다릴 곳이 여럿이면 1번의 방법을 씁니다.
정리
UniTask 는 풀에서 빌린 대기 객체를 가리키는 참조와 번호라서, 복사는 공짜지만 한 번만 await 할 수 있습니다.
source == null이 곧 성공 완료라 끝난 값에는 객체가 필요 없습니다.- await 가 끝나면 대기 객체는 버전을 올리고 풀로 돌아가고, 토큰이 낡은 사본의 접근을 예외로 막습니다.
- 다시 읽으려면
Preserve()(40 B), 동시에 기다리려면UniTaskCompletionSource나AsyncLazy를 씁니다.