UniTask 의 예외는 꺼내 가야 보고된다
try
{
await UniTask.WhenAll(LoadMapAsync(), LoadUnitsAsync(), LoadSoundAsync());
}
catch (Exception ex)
{
Debug.LogException(ex); // 둘이 실패해도 여기에는 하나만 온다
}
로딩 둘이 실패했는데 catch 에는 하나만 오고, 나머지 하나는 어디에도 찍히지 않습니다. 반대로 await 를 깜빡하고 버린 태스크의 예외는 몇 초 뒤 엉뚱한 때 찍힙니다.
규칙 둘 때문입니다. 완료는 처음 한 번만 이기고, 예외는 누군가 결과를 꺼내 가야(GetResult) 보고됩니다.
WhenAll 은 먼저 실패한 하나만 전한다
WhenAll 은 자식이 끝날 때마다 자식의 결과를 직접 꺼냅니다. 성공이면 자식 번호 칸에 넣고, 실패면 자기를 그 예외로 끝냅니다. 끝내기는 한 번만 성공하니 두 번째 실패는 갈 곳이 없습니다.
static void TryInvokeContinuation(WhenAllPromise<T> self, in UniTask<T>.Awaiter awaiter, int i)
{
try
{
self.result[i] = awaiter.GetResult(); // 자식의 결과를 자식 번호 칸에 넣는다. 실패면 여기서 던진다
}
catch (Exception ex)
{
self.core.TrySetException(ex); // WhenAll 을 이 예외로 끝낸다. 먼저 온 하나만 성공한다
return; // 카운터를 올리지 않고 나간다
}
// 마지막으로 끝난 자식이 WhenAll 을 완료한다
if (Interlocked.Increment(ref self.completeCount) == self.result.Length)
self.core.TrySetResult(self.result);
}
셋 다 성공하면 끝나는 순서와 상관없이 결과가 자식 번호 칸에 들어갑니다. B, A, C 순서로 끝나도 await 결과는 [10, 20, 30] 입니다.
A 가 성공하고 B, C 가 차례로 실패하면 이렇게 됩니다.
- A 가 끝납니다. 0 번 칸에 10 을 넣고 카운터가 1 이 됩니다.
- B 의
GetResult()가 예외를 던집니다.TrySetException이 성공해WhenAll이 B 의 예외로 끝나고, 그 예외가await한 쪽으로 갑니다. - C 의
GetResult()도 예외를 던집니다.WhenAll은 이미 끝났으므로TrySetException이false를 돌려줍니다. C 의 예외는 여기서 사라집니다.
C 의 예외는 파이널라이저 보고로도 이어지지 않습니다. WhenAll 이 C 의 GetResult() 를 불렀으니 C 쪽에서는 관측된 예외이기 때문입니다. UnobservedTaskException 에 카운터를 걸고 WhenAll(ok, throwA, throwB) 를 받은 뒤 GC 를 두 번 돌렸더니, 보고는 0 번이었습니다.
Task.WhenAll 처럼 AggregateException 에 모아 주지 않습니다. catch 에 온 예외가 실패의 전부가 아닐 수 있습니다.
이렇게 쓴다
실패를 전부 알아야 하면 자식이 던지지 않게 감싸고, 예외를 결과로 받습니다. 아무도 던지지 않으니 WhenAll 은 셋이 다 끝난 뒤에 끝나고, 결과 배열에 각자의 예외가 들어 있습니다.
// 예외를 던지는 대신 결과로 돌려준다
static async UniTask<Exception> Capture(UniTask task)
{
try
{
await task;
return null;
}
catch (Exception ex)
{
return ex;
}
}
Exception[] errors = await UniTask.WhenAll(
Capture(LoadMapAsync()),
Capture(LoadUnitsAsync()),
Capture(LoadSoundAsync()));
foreach (var ex in errors)
{
if (ex != null)
Debug.LogException(ex); // 실패한 것이 모두 찍힌다
}
첫 실패만 알면 되는 곳은 처음 코드 그대로 둡니다.
WhenAny 도 처음 끝난 하나가 이긴다
WhenAny 는 완료 조건이 “처음 끝난 하나” 일 뿐 같은 규칙입니다.
- 먼저 끝난 자식이 실패면
WhenAny도 그 예외로 끝납니다. 실패도 “끝남” 입니다. - 먼저 끝난 자식이 성공이면, 나중에 실패한 자식의 예외는
WhenAll의 두 번째처럼 사라집니다.
그리고 어느 조합기든 나머지 자식을 멈추지 않습니다. 콜백을 걸어 둘 뿐 멈출 방법이 없어서, 실패 뒤에도, WhenAny 에서 진 뒤에도 끝까지 돕니다.
이렇게 쓴다
자식들에게 같은 CancellationTokenSource 의 토큰을 넘기고, 조합기가 끝난 자리에서 취소합니다.
var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
try
{
await UniTask.WhenAll(
LoadMapAsync(cts.Token),
LoadUnitsAsync(cts.Token),
LoadSoundAsync(cts.Token));
}
catch
{
cts.Cancel(); // 하나가 실패하면 아직 도는 나머지를 멈춘다
throw;
}
finally
{
cts.Dispose();
}
WhenAny 는 성공으로 끝나도 진 쪽이 계속 돌므로, await 다음 줄에서 취소합니다.
await UniTask.WhenAny(WaitClickAsync(cts.Token), UniTask.Delay(5000, cancellationToken: cts.Token));
cts.Cancel(); // 진 쪽을 멈춘다
아무도 꺼내 가지 않은 예외는 GC 때에야 보고된다
예외를 보고하는 쪽은 결과를 꺼내 가는 쪽입니다. await 가 꺼내 가면 catch 로 옵니다. Forget() 과 async UniTaskVoid 는 끝나는 순간 꺼내서 곧바로 UniTaskScheduler 에 넘깁니다. 아무도 꺼내 가지 않으면 예외를 감싼 ExceptionHolder 가 GC 에 수거될 때 파이널라이저에서 보고합니다.
예외를 던지는 작업 10개로 쟀습니다.
| 경로 | 10 프레임 안에 보고 | GC.Collect() + WaitForPendingFinalizers() 뒤 |
|---|---|---|
| 반환값을 버림 | 0 / 10 | 10 / 10 |
.Forget() |
10 / 10 | |
async UniTaskVoid |
10 / 10 |
버린 쪽은 GC 가 돌 때까지 하나도 보고되지 않았습니다. 실제 게임에서는 몇 초 뒤나 씬을 옮길 때라, 찍힌 로그가 예외가 난 자리와 동떨어집니다.
이렇게 쓴다
반환된 UniTask 는 셋 중 하나로 받습니다. 어느 쪽이든 예외가 끝나는 순간 보고되고, 대기 객체도 풀로 돌아갑니다.
await SaveAsync(); // 기다릴 것. 예외가 catch 로 온다
SaveAsync().Forget(); // 기다리지 않을 것. 예외가 끝나는 순간 보고된다
async UniTaskVoid Start() // 진입점. 예외가 끝나는 순간 보고된다
{
await SaveAsync();
}
Forget() 과 UniTaskVoid 의 예외는 catch 로 오지 않고 UniTaskScheduler 로 갑니다. 출시 빌드에서도 남기려면 핸들러를 겁니다.
UniTaskScheduler.UnobservedTaskException += ex =>
{
Debug.LogException(ex); // 크래시 리포트로 보내는 코드를 여기에 둔다
};
정리
UniTask 의 완료는 처음 한 번만 이기고, 예외는 누군가 GetResult() 로 꺼내 가야 보고됩니다.
WhenAll은 먼저 실패한 하나만 전하고, 두 번째 예외는 보고 없이 사라집니다.WhenAny는 실패도 “끝남” 으로 치고, 어느 조합기든 나머지 자식을 멈추지 않습니다.- 버린 태스크의 예외는 GC 가
ExceptionHolder를 치울 때에야 보고되고,Forget()과async UniTaskVoid는 곧바로 보고합니다.
그래서 실패를 전부 알아야 하면 예외를 결과로 받고, 남은 자식은 토큰으로 직접 멈추고, 반환된 UniTask 는 await 나 Forget() 으로 받습니다.