유니티 코루틴 대신 unitask

유니티에서 unitask를 사용하기 앞서, 유니티에서 사용하는 코루틴이 무엇인지 알아야 진정한 unitask의 장점을 알 수 있습니다.

지피지기 백전불태

 

그렇기에 유니티에 기본적으로 내장되어 있는 코루틴에 대해 자세히 알아본 다음, 유니티에서의 unitask 사용방법에 대해 알아보겠습니다!

 


코루틴이란?

유니티에서 사용되는 코루틴은 비동기 프로그래밍을 수행하기 위한 기능 중 하나입니다.

 

코루틴은 일시 중지할 수 있는 함수의 실행을 가능하게 하며,

특정 조건이 충족될 때까지 기다린 후(ex_ yield return)에 실행을 계속할 수 있도록 합니다.

 


UniTask란?

유니티에서 UniTask는 비동기 작업을 처리하기 위한 도구로,

유니티에 내장되어 있는 코루틴 기능을 대체하며 async/await 패턴을 지원합니다.

 

한 마디로, 비동기 작업을 수행하기 위한 async/await 라이브러리라고 생각하시면 됩니다.

 

유니티 Async & await

Async 란? 비동기 방식을 사용할 때 사용하는 "키워드"입니다. 비동기란? "비동기"는 대기하거나 특정 주문에 묶이지 않고 작업을 수행하는 것을 의미합니다. 친구와 카톡을 하고 있다고 가정하고

wlsdn629.tistory.com

 
일반적인 C#의 Task와 비교하여, UniTask는 유니티에서 더 효율적으로 동작하도록 설계되었다고 합니다.


코루틴과 UniTask의 차이

 

코루틴: 코루틴은 객체를 생성할 때 힙 메모리에 할당됩니다. 따라서 코루틴내부에서의 반복적인 객체 생성은 GC가 처리해야 할 '힙 할당'을 증가시켜 성능을 잡아먹는 요인이 됩니다.

 

UniTask: UniTask는 구조체 기반이기 때문에 스택에 할당됩니다. 코루틴과 달리 힙 할당이 발생하지 않아 GC의 부하가 줄어듭니다. 더 정확히는 내부적으로 큰 "Task Pool"을 미리 생성한 다음 자주 사용되는 비동기 작업 객체를 풀에 저장하고 필요할 때 재사용합니다. 따라서 Zero Allocation과 같은 효과를 기대할 수 있게 됩니다.

 

UniTask에서 사용되는  CancellationTokenSource의 경우 클래스 기반이므로 힙 할당이 발생하기 때문에 사용하지 않으면 Dispose를 통해 반드시 리소스를 해제해야 합니다.

 

정리

기본적으로 UniTask는 구조체 기반으로 설계되어 스택에서 할당되고 관리됩니다. 하지만, 일부 복잡한 비동기 작업에서는 상태를 지속적으로 관리가 필요한 경우가 있습니다. 이러한 작업은 StateMachineRunner라는 상태머신을 통해 관리되며, 이때 TaskPool을 이용하여 재사용하므로 Zero Allocation과 같은 효과를 낼 수 있습니다.

상태를 지속적으로 관리 필요한 경우의 예시로는 조건부 대기, 비동기 Loop와 같은 상황이 있습니다.

 

 

그렇다면 어떻게 UniTaskVoid와 같은 구조체 기반 비동기 작업이 메모리에서 휘발되지 않고 await를 통해 비동기 작업을 지속할 수 있을까요?

 

UniTask는 async/await 패턴을 사용하여 비동기 작업을 처리합니다.

예를 들어, await UniTask.Delay(3000)와 같은 대기 작업이 호출되면, 이 비동기 작업은 await 키워드를 통해 일시 중단되고, 이때, UniTask는 내부적으로 상태 머신(StateMachineRunner)을 생성하여 대기 작업을 관리하는 동작에 들어가게 됩니다.

 

정리

상태 머신(StateMachineRunner)생성: async 메서드가 호출되면, C# 컴파일러는 해당 메서드를 상태 머신으로 변환합니다. 상태 머신은 현재 상태를 저장하고, 비동기 작업이 다시 시작될 때 어디서부터 재개할지를 추적합니다.

힙 할당: UniTask 자체는 구조체 기반으로 설계되어 스택에 할당되지만, await 키워드로 인해 중단된 작업은 상태 머신에 의해 관리되며, 이 상태 머신은 힙에 할당됩니다. 상태 머신은 스택 메모리가 아닌 힙 메모리에 저장되므로, 메서드가 종료된 후에도 상태가 유지됩니다.

코루틴, UniTask 장단점

항목  코루틴  UniTask
장점 - 추가 라이브러리 불필요 - async/await 구문을 활용한 비동기 프로그래밍
  - 시간 제어 가능 (yield return 사용) - WhenAll, WhenAny 등의 내장 메서드 제공
    - Zero Allocation 기능으로 성능 향상
단점 - IEnumerator 반환 함수에 제한 - 추가 라이브러리 필요
  - 복잡한 비동기 작업 처리 어려움. 예를 들어, 'WhenAll' 메서드를 직접 구현해야 함  

Zero Allocation이란?

  • UniTask는 Zero Allocation 기능을 제공하여 메모리 할당을 최소화합니다. 불필요한 가비지 컬렉션(Garbage Collection, GC)을 방지하여 프레임 드랍이나 렉을 줄일 수 있습니다.
  • 코루틴은 메모리 할당을 필요로 하며, 이는 가비지 컬렉션 오버헤드를 초래할 수 있습니다. 
using System.Collections;
using UnityEngine;

public class CoroutineExample : MonoBehaviour
{
    private void Start()
    {
        StartCoroutine(MyCoroutine());
    }

    private IEnumerator MyCoroutine()
    {
        while (true)
        {
            yield return new WaitForSeconds(1f);  // 메모리 할당 발생!!!!!!
            Debug.Log("Coroutine running every 1 second");
        }
    }
}

 

위 코드는 코루틴으로 작성한 예시이며, 1초마다 메모리 할당이 발생합니다.

using Cysharp.Threading.Tasks;
using UnityEngine;

public class UniTaskExample : MonoBehaviour
{
    private async void Start()
    {
        await MyUniTask();
    }

    private async UniTask MyUniTask()
    {
        while (true)
        {
            await UniTask.Delay(1000);  // Zero Allocation
            Debug.Log("UniTask running every 1 second");
        }
    }
}

 

위 코드는 unitask로 작성한 예시이며, 1초마다 메모리 할당이 발생하지 않습니다.

 


WhenAll이란?

WhenAll는 여러 비동기 작업이 모두 완료될 때까지 기다리는데 사용되는 메서드입니다.

"UniTask"의 WhenAll은 내장 메서드로 제공되어 사용이 간편하지만, "코루틴"에서는 이러한 메서드를 직접 구현해야 합니다.

using System.Collections;
using UnityEngine;

public class CoroutineWhenAllExample : MonoBehaviour
{
    private void Start()
    {
        StartCoroutine(WaitForAll());
    }

    private IEnumerator WaitForAll()
    {
        Coroutine task1 = StartCoroutine(Task(2));  // 2초 대기
        Coroutine task2 = StartCoroutine(Task(3));  // 3초 대기

        yield return task1;
        yield return task2;

        Debug.Log("All tasks completed");
    }

    private IEnumerator Task(float seconds)
    {
        yield return new WaitForSeconds(seconds);
        Debug.Log($"Task completed after {seconds} seconds");
    }
}

 

위 코드는 코루틴으로 작성한 예시입니다.

모든 Task가 완료될 때까지 기다리기 위해, 각각의 코루틴을 생성하고 완료를 기다리는 코드(yield return task)를 추가로 작성한 것을 확인할 수 있습니다. 

using Cysharp.Threading.Tasks;
using UnityEngine;

public class UniTaskWhenAllExample : MonoBehaviour
{
    private async void Start()
    {
        await UniTask.WhenAll(Task1(), Task2());
        Debug.Log("All tasks completed");
    }

    private async UniTask Task1()
    {
        await UniTask.Delay(2000);  // 2초 대기
        Debug.Log("Task1 completed after 2 seconds");
    }

    private async UniTask Task2()
    {
        await UniTask.Delay(3000);  // 3초 대기
        Debug.Log("Task2 completed after 3 seconds");
    }
}

 

위 코드는 UniTask로 작성한 예시입니다.

WhenAll메서드를 이용하여 Task1메서드와 Task2메서드를 기다리는 비동기 작업을 손쉽게 만드는 것을 확인할 수 있습니다.

 


Unitask 다운

Unitask는 아래 깃허브에서 다운로드할 수 있습니다.

 

 

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

Provides an efficient allocation free async/await integration for Unity. - GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

 

 

 

github.com

 


UniTask 사용방법

다음은 UniTask에서 사용되는 문법에 대해 소개드리겠습니다.

코루틴과 UniTask의 문법은 어떻게 차이가 나는지 보여드리기 위해 코루틴 문법도 함께 작성해 봤습니다.

 

Delay

private void Start()
{
    StartCoroutine(CoroWait());
    UniWait().Forget();
}

IEnumerator CoroWait()
{
    yield return new WaitForSeconds(3f);
}

async UniTaskVoid UniWait()
{
    await UniTask.Delay(TimeSpan.FromSeconds(3f));
}

 

await UniTask.Delay(TimeSpan.FromSeconds(3f), DelayType.UnscaledDeltaTime);

DelayType.UnscaledDeltaTime을 사용하면 Time.ignore와 같은 상황을 무시할 수 있습니다.


WaitUntil

private int count = 0;
IEnumerator CoroWait()
{
    yield return new WaitUntil(() => count == 7);
}

async UniTaskVoid UniWait()
{
    await UniTask.WaitUntil(()=> count ==  7);
}

특정 조건이 될 때까지 기다려야 하는 조건은 위와 같이 작성할 수 있습니다.


Task 중단, CanellationTokenSource

private CancellationTokenSource cancel = new CancellationTokenSource();

private void Update()
{
    if (~~)
    {
        cancel.Cancel();
    }
}

async UniTaskVoid UniWait()
{
    await UniTask.Delay(TimeSpan.FromSeconds(3), cancellationToken: cancel.Token);
}


만약 특정 조건이 만족될 때까지 실행되는 while문의 경우

CancellationToken이 Cancel 될 때 while문을 종료해 주는 로직을 따로 작성해주어야 합니다.

private async UniTask WaitUntilInitializedAsync(CancellationToken cancellationToken)
{
     while (true)
     {
          if (cancellationToken.IsCancellationRequested)
          {
              break;
          }
                
          /~
             
          ~/

          await UniTask.Delay(100, ignoreTimeScale:true, cancellationToken: cancellationToken); 
     }
}

IsCancellationRequested은 CancellationToken이 취소 요청을 받았는지 여부를 나타냅니다.
 
CancellationToken의  Cancel 메서드가 호출되면 IsCancellationRequested이 true로 설정됩니다.


오브젝가 파괴될 때 UniTask 취소

var token = this.GetCancellationTokenOnDestroy();
await UniTask.Delay(1000, cancellationToken: token);

GetCancellationTokenOnDestroy 함수는 UniTask 라이브러리의 일부로 제공되며,

GameObject나 Component가 파괴될 때 발생하는 취소 토큰을 가져옵니다.
 
이 함수를 사용하면 GameObject나 Component가 파괴된 시점에 UniTask를 취소할 수 있습니다.
 
위 코드를 예시로 들면, UniTask.Delay는 1초 후에 완료되도록 예정되어 있지만,

해당 GameObject가 그전에 파괴되면 UniTask는 취소됩니다.
 


Frame 대기

// replacement of yield return null
await UniTask.Yield();
await UniTask.NextFrame();
// replacement of WaitForEndOfFrame(requires MonoBehaviour(CoroutineRunner))
await UniTask.WaitForEndOfFrame(this)

Update문과 같은 역할을 하는 UniTask

private async UniTaskVoid UpdateUniTask()
{
      while (true)
      {
           ///
           ~~
           ///
           await UniTask.Yield(PlayerLoopTiming.Update);
       }         
}

이때, PlayerLoopTiming에는 Update 외에 "PreUpdate, Update, LateUpdate 등"의 열거형 키워드 존재합니다.

 

PlayerLoopTiming Enum | UniTask

PlayerLoopTiming Enum Assembly: cs.temp.dll.dll public enum PlayerLoopTiming Fields Name Description EarlyUpdate FixedUpdate Initialization LastEarlyUpdate LastFixedUpdate LastInitialization LastPostLateUpdate LastPreLateUpdate LastPreUpdate LastUpdate Pos

cysharp.github.io


Touple 값 받기

var (google, bing, yahoo) = await UniTask.WhenAll(task1, task2, task3);

WhenAll은 task1, task2, task3 모두 완료될 때까지 기다린다는 뜻입니다.


Timeout handling

"CancellationTokenSouce.CancelAfterSlim(TimeSpan)"

var cancel = new CancellationTokenSource();
cancel.CancelAfterSlim(TimeSpan.FromSeconds(5)); 

try
{
    await UnityWebRequest.Get("http://~~").SendWebRequest().WithCancellation(cancel.Token);
}
catch (OperationCanceledException ex)
{
    if (ex.CancellationToken == cancel.Token)
    {
        Debug.Log("Timeout");
    }
}

5초 기다리는 동안 Task를 성공적으로 마무리하지 못하면 예외처리 하는 로직입니다.


Error-Handling

using Cysharp.Threading.Tasks;
using UnityEngine;

public class ErrorHandlingExample : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        try
        {
            await UniTask.Run(() => ThrowException());
        }
        catch (System.Exception e)
        {
            Debug.LogError(e.Message);
        }
    }

    private void ThrowException()
    {
        throw new System.Exception("An error occurred!");
    }
}

 

예외를 던지는 메서드 ThrowException을 UniTask.Run 내부에 캡슐화합니다.

그런 다음 이 UniTask를 try-catch 블록으로 감쌉니다. 예외가 발생하면 캐치 블록에 의해 예외가 잡히고 오류 메시지가 콘솔에 기록할 수 있습니다.


별도의 스레드에서 비동기적으로 실행시키기

using Cysharp.Threading.Tasks;
using UnityEngine;

public class UniTaskRunExample : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        int result = await UniTask.Run(() => Compute());
    }

    private int Compute()
    {
        int sum = 0;
        for (int i = 0; i < 1000000; i++)
        {
            sum += i;
        }
        return sum;
    }
}

UniTask.Run을 이용해서 동기식 코드를 스레드풀에서 비동기식으로 실행시킬 수 있습니다.

메인 스레드가 멈추는 것을 방지하기 위해 백그라운드 스레드에서 실행하려는 CPU 바운드 작업이 있을 때 유용합니다. 

 


UniTask와 UniTaskVoid 차이

두 타입의 주요 차이는 "반환 값의 유무"와 관련이 있습니다.

UniTask

결과를 반환하는 비동기 작업을 나타냅니다.  


UniTaskVoid

결과를 반환하지 않는 비동기 작업을 나타냅니다.



UniTaskVoid는 호출한 후 다음 줄로 넘어가는 반면,

UniTask는 비동기 작업을 호출하고 비동기 작업이 끝날 때까지 기다립니다.

public async UniTaskVoid FireAndForget()
{
    await UniTask.Delay(TimeSpan.FromSeconds(2));
    
    Debug.Log("Fire and forget task completed");
}

public void StartOperation()
{
    FireAndForget().Forget();
    Debug.Log("Started fire and forget task, not waiting for it");
}

FireAndForget 비동기를 호출하고 나면,

 

"Started fire and forget task, not waiting for it" Log가 바로 출력됩니다.

public async UniTask Fire()
{
    await UniTask.Delay(TimeSpan.FromSeconds(2));
    
    Debug.Log("Fire and forget task completed");
}

public async void StartOperation()
{
    await Fire();
    Debug.Log("Started fire and forget task, not waiting for it");
}

Fire 비동기를 호출하고 2초 대기 후,


"Fire and forget task completed", "Started fire and forget task, not waiting for it" Log가 순차적으로 출력됩니다.