UniTask 는 완료와 이어 부르기를 딱 한 번씩만 한다
await UniTask.Delay(1000, cancellationToken: ct); // 1초가 차는 그 프레임에 ct 도 취소되면?
완료가 두 번 들어올 수 있습니다. 완료가 워커 스레드에서 오는 순간 메인 스레드가 막 await 를 등록하고 있을 수도 있습니다. 그러면 await 뒤 코드가 두 번 돌거나, 한 번도 안 돌 수 있습니다.
UniTask 는 이 둘을 완료 상태를 담은 구조체 하나로 막습니다. 완료는 먼저 온 하나만 받고, 이어질 코드는 누가 먼저 오든 정확히 한 번 부릅니다. lock 없이 원자적 연산만 쓰고, 할당도 없습니다. 기준 판본은 2.5.11 입니다.
완료는 먼저 온 하나만 받는다
TrySetResult, TrySetException, TrySetCanceled 는 모두 같은 줄로 시작합니다.
// Runtime/UniTaskCompletionSource.cs (줄임)
public bool TrySetResult(TResult result)
{
if (Interlocked.Increment(ref completedCount) == 1) // 동시에 불러도 1 은 한 쪽만 받는다
{
this.result = result;
// 이어질 코드 처리 (다음 절)
return true;
}
return false; // 늦게 온 완료는 버린다
}
Interlocked.Increment 는 여러 스레드가 동시에 불러도 각자 다른 값을 돌려줍니다. 1 을 받은 쪽만 결과를 적습니다. 그래서 시간 만료와 취소가 같은 프레임에 겹쳐도 결과가 두 번 적히거나 성공과 취소가 섞이지 않습니다. 값도 쌉니다. 에디터에서 Interlocked.Increment 두 번이 3.1 ns, 빈 lock 한 번이 15.5 ns 였습니다.
이어질 코드는 누가 먼저 오든 한 번만 불린다
await 는 아직 안 끝났으면 “끝나면 이걸 불러 줘” 하고 이어질 코드를 continuation 칸에 맡깁니다. 완료하는 쪽은 그 칸에 무언가 있으면 부릅니다. 둘이 다른 스레드에서 동시에 오면 문제가 됩니다.
UniTask 는 둘 다 칸이 비어 있을 때만 자기 것을 꽂게 합니다(Interlocked.CompareExchange). 한 칸을 두고 겨루니 먼저 닿은 쪽만 성공하고, 진 쪽이 이긴 쪽의 값을 보고 이어질 코드를 부릅니다. 완료하는 쪽이 먼저 오면 “끝났는데 기다리는 사람이 없었다” 는 표시값(센티널)을 꽂아 둡니다.
기다리는 쪽은 아직 안 끝났을 때만 등록하러 갑니다.
// 기다리는 쪽: await
if (!awaiter.IsCompleted)
core.OnCompleted(cont, state, token);
등록은 칸이 비어 있을 때만 cont 를 꽂습니다. 이미 무언가 있으면 완료가 먼저 온 것이니 그 자리에서 직접 부릅니다.
// OnCompleted 안
object old = this.continuation;
if (old == null)
old = Interlocked.CompareExchange(ref this.continuation, cont, null); // 비었으면 cont 를 꽂는다
if (old != null)
{
// 센티널이 아니면 누가 이미 등록한 것이다 (두 번 await)
if (old != s_sentinel)
throw new InvalidOperationException(...);
cont(state); // 완료가 먼저 왔다. 등록한 쪽이 직접 부른다
}
완료하는 쪽도 칸이 비어 있을 때만 센티널을 꽂습니다. 이미 cont 가 있으면 그것을 부릅니다.
// 완료하는 쪽: TrySetResult
if (Interlocked.Increment(ref completedCount) == 1)
{
this.result = result;
if (continuation != null ||
Interlocked.CompareExchange(ref continuation, s_sentinel, null) != null) // 비었으면 센티널을 꽂는다
{
continuation(continuationState); // 등록이 먼저 왔다. 완료한 쪽이 부른다
}
}
두 쪽이 오는 순서에 따라 칸에 남는 값과 cont 를 부르는 쪽이 정해집니다.
| 경우 | 칸에 먼저 꽂힌 것 | cont 를 부르는 쪽 |
await 다음 줄이 도는 스레드 |
|---|---|---|---|
| A. 등록이 먼저 끝남 | cont |
완료하는 쪽 | 완료한 스레드 |
| B. 완료가 먼저 끝남 | 센티널 | 없음. IsCompleted 가 참이라 등록을 건너뜀 |
기다리던 스레드 |
| C.1 동시에 왔고 등록이 이김 | cont |
완료하는 쪽 | 완료한 스레드 |
| C.2 동시에 왔고 완료가 이김 | 센티널 | 등록하는 쪽 | 등록한 스레드 |
- 등록이 먼저면 완료하는 쪽이 부릅니다(A, C.1).
- 완료가 먼저면 등록하는 쪽이 센티널을 보고 그 자리에서 직접 부릅니다(B, C.2).
어느 쪽이든 한 번입니다. 다만 누가 부르느냐에 따라 await 다음 줄이 도는 스레드가 달라집니다. C.1 에서는 완료한 스레드에서 이어집니다.
이 모든 것이 구조체 하나라서 할당이 없다
완료 상태(결과, 예외, 완료 횟수, 이어질 코드)는 UniTaskCompletionSourceCore<TResult> 구조체 하나에 있습니다. Delay, Yield, async 메서드의 러너 같은 대기 객체가 모두 이것을 필드로 품습니다. 상태를 지키려고 따로 만드는 객체가 없습니다.
완료 한 번(만들고, 완료하고, 결과 꺼내기)을 백만 번씩 잰 중앙값입니다(에디터 Mono, Release 최적화).
| 한 주기 | 시간 | 할당 |
|---|---|---|
AutoResetUniTaskCompletionSource (풀 사용) |
71.1 ns | 0 B |
TaskCompletionSource<bool> |
135.6 ns | 104 B |
개발 빌드 플레이어에서도 Mono 67.5 ns, IL2CPP 57.8 ns 에 0 B 였습니다.
단, 0 B 는 성공으로 끝날 때입니다. 취소나 예외로 끝내면 예외 객체를 만들고 던지는 값이 붙습니다.
| 완료 방식 | Mono | IL2CPP |
|---|---|---|
TrySetResult |
0 B | 0 B |
TrySetCanceled() |
288 B | 368 B |
TrySetException(일반 예외) |
약 9.3 KB | 약 9.7 KB |
주의할 점
1. 늦게 온 완료는 버려진다
안 지키면 생기는 문제. 시간 만료와 취소, 또는 두 콜백이 같은 소스를 완료하려 하면 먼저 온 하나만 남습니다. 늦은 쪽은 예외 없이 false 만 받고 끝나므로, 그쪽이 기록하려던 결과나 로그는 조용히 사라집니다.
이렇게 쓴다. 어느 쪽이 이겼는지 알아야 하면 TrySet* 의 반환값을 봅니다. false 면 이미 다른 쪽이 끝낸 것입니다.
2. 워커 스레드에서 완료하면 await 다음 줄이 워커에서 돌 수 있다
안 지키면 생기는 문제. UniTaskCompletionSource 를 워커 스레드에서 TrySetResult 하면, await 가 먼저 등록돼 있던 경우 이어질 코드를 그 워커가 부릅니다. await 다음 줄에서 유니티 API 를 부르면 메인 스레드가 아니라서 예외가 납니다.
이렇게 쓴다. 워커에서 완료하는 소스를 기다렸다면 await 다음에 await UniTask.SwitchToMainThread(); 를 둡니다. 가능하면 완료 자체를 메인 스레드에서 합니다.
3. 자주 실패하는 흐름을 예외로 전하지 않는다
안 지키면 생기는 문제. 성공 완료는 0 B 지만 예외로 완료하면 한 번에 9 KB 남짓, 취소도 수백 B 가 듭니다. “없으면 실패” 같은 흔한 결과를 예외로 전하면 그만큼 매번 GC 에 쌓입니다.
이렇게 쓴다. 자주 일어나는 실패는 UniTask<(bool ok, T value)> 처럼 결과 값으로 돌려주고, 예외는 정말 예외적인 경우에만 씁니다.
정리
UniTask 는 완료와 이어 부르기를 딱 한 번씩만 합니다.
- 완료는
Interlocked.Increment로 먼저 온 하나만 받습니다. - 이어질 코드는
continuation칸을 두고 CAS 로 겨뤄, 누가 먼저 오든 한 번만 불립니다. 대신 부르는 스레드는 경우에 따라 다릅니다. - 이 상태가 구조체 하나라 성공 완료는 0 B 입니다. 예외로 끝내면 값이 붙습니다.