UniTask 는 코루틴의 빈칸을 메우고 Task 의 GC 를 없앤다
IEnumerator ShowPopupCo()
{
popup.SetActive(true);
try { yield return new WaitForSeconds(3f); }
finally { popup.SetActive(false); } // 멈춰도 닫히겠지?
}
누가 StopCoroutine 을 부르면 팝업은 켜진 채 남습니다. async/await 와 Task 로 옮기면 풀리지만, 이번에는 기다릴 때마다 힙에 수백 바이트가 쌓입니다.
UniTask 는 async/await 를 그대로 쓰면서 Task 의 힙 할당과 유니티에 필요 없는 컨텍스트를 버린 라이브러리입니다. 기준 판본은 2.5.11 입니다.
코루틴은 결과, 예외, 취소를 다루지 못한다
- 반환값.
IEnumerator에는 결과 자리가 없어 콜백을 넘깁니다. - 예외.
yield문법 탓에try-catch로 예외를 받을 수 없습니다. - 취소.
StopCoroutine은 이터레이터를 버릴 뿐Dispose를 부르지 않아finally가 돌지 않습니다.
맨 위 팝업을 두 방식으로 걸어 봅니다. 둘 다 기다리는 도중에 멈춥니다.
async/await 는 취소를 OperationCanceledException 으로 바꿉니다. 예외는 finally 를 지나 올라가므로 정리 코드를 건너뛸 수 없습니다. 결과는 UniTask<int> 로, 실패는 try-catch 로 받습니다.
Task 로 옮기면 부를 때마다 할당한다
async Task 도 세 빈칸을 모두 풉니다. 대신 기다림이 걸리면 Task 객체, 상태 머신 박싱, 러너, MoveNext 델리게이트를 힙에 만듭니다. 같은 일을 세 방식으로 쟀습니다.
코드 모양은 거의 같은데 376 B 가 0.1 B 가 됩니다. await 없이 값만 돌려줘도 Task 는 80 B, UniTask 는 0.0 B 였고, IL2CPP 에서도 같은 모양이었습니다. UniTask 는 반환 타입을 구조체로 만들고, 기다리는 데 필요한 객체는 풀에서 빌려 씁니다.
컨텍스트를 버리고 PlayerLoop 에서 이어 간다
Task 의 await 는 SynchronizationContext 를 붙잡았다가, 끝나면 이어질 코드를 거기에 Post 합니다. 유니티의 비동기 작업은 끝났다는 알림이 이미 메인 스레드로 오므로 이 장치는 대부분 헛돕니다. UniTask 는 SynchronizationContext 도 ExecutionContext 도 붙잡지 않습니다. 그 자리는 PlayerLoop 가 채웁니다.
Delay 는 Update 도중 메인 스레드에서 이어지므로, 컨텍스트 없이도 유니티 API 를 부를 수 있는 자리로 돌아옵니다. 어느 지점에서 깨어날지는 PlayerLoopTiming 인자로 고릅니다.
가장 짧은 예제
오브젝트를 세 번 깜빡이다가 파괴되면 멈춥니다. 네임스페이스는 Cysharp.Threading.Tasks 입니다.
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
public class Blinker : MonoBehaviour
{
[SerializeField] GameObject target;
void Start() => BlinkAsync(3, destroyCancellationToken).Forget();
async UniTask BlinkAsync(int count, CancellationToken ct)
{
for (int i = 0; i < count; i++)
{
target.SetActive(false);
await UniTask.Delay(200, cancellationToken: ct); // 밀리초, timeScale 을 따른다
target.SetActive(true);
await UniTask.Delay(200, cancellationToken: ct);
}
}
}
async UniTask로 쓰면 UniTask 의 빌더로 컴파일됩니다. 값을 돌려주려면async UniTask<int>입니다.destroyCancellationToken은 유니티 2022.2 부터 있는 토큰으로, 오브젝트가 파괴될 때 취소됩니다. 그 전 버전에서는this.GetCancellationTokenOnDestroy()를 씁니다.Forget()은 기다리지 않을UniTask를 끝까지 지켜보다가 예외가 나면UniTaskScheduler.UnobservedTaskException으로 넘깁니다.
결과와 예외는 Task 와 똑같이 받습니다. async void 자리에는 async UniTaskVoid 를 씁니다.
async UniTask<int> LoadScoreAsync(CancellationToken ct)
{
await UniTask.Delay(1000, cancellationToken: ct);
return 42;
}
async UniTaskVoid OnClickAsync()
{
try { label.text = (await LoadScoreAsync(destroyCancellationToken)).ToString(); }
catch (OperationCanceledException) { } // 파괴되었다
catch (Exception ex) { Debug.LogException(ex); }
}
주의할 점
1. 토큰을 넘기지 않으면 멈추지 않는다
안 지키면 생기는 문제. StopCoroutine 같은 바깥 스위치가 없습니다. UniTask.Delay 에 토큰을 넘기지 않으면 오브젝트가 파괴되어도 대기는 끝까지 돌고, 다음 줄이 파괴된 오브젝트를 만집니다.
이렇게 쓴다. 기다리는 메서드는 CancellationToken 을 받아 모든 await 에 넘깁니다. MonoBehaviour 안이면 destroyCancellationToken 을 씁니다.
2. 반환 타입을 Task 로 남기면 그 할당도 남는다
안 지키면 생기는 문제. 안에서 UniTask 를 await 해도 메서드가 async Task 면 Task 의 빌더로 컴파일됩니다. 기다릴 때마다 Task 쪽 할당이 그대로 생깁니다.
이렇게 쓴다. 게임 루프에서 도는 async 메서드는 반환 타입을 UniTask, UniTask<T>, UniTaskVoid 로 바꿉니다.
3. AsyncLocal 에 기대지 않는다
안 지키면 생기는 문제. UniTask 는 ExecutionContext 를 붙잡지 않으므로 AsyncLocal<T> 에 넣은 값이 await 너머로 따라오지 않습니다.
이렇게 쓴다. await 너머로 넘길 값은 메서드 인자나 필드로 넘깁니다.
정리
UniTask 는 async/await 로 코루틴의 빈칸을 메우면서 Task 의 할당과 컨텍스트를 버린 라이브러리입니다.
- 코루틴은 결과, 예외, 취소를 다루지 못하고,
StopCoroutine은finally를 건너뜁니다. async/await 는 취소를 예외로 바꿔 정리 코드를 보장합니다. - 같은 코드가
Task로는 한 번에 80384 B, UniTask 로는 0.00.2 B 였습니다. - 컨텍스트 대신 PlayerLoop 가 이어 주고, 깨어날 지점은
PlayerLoopTiming으로 고릅니다.