Unity C# | 코루틴 대신 UniTask를 추천하는 이유: StateMachineRunner를 통한 Zero allocation

seunghyun·2024년 9월 18일
0

유니티 코루틴

코루틴(Coroutine)이란, Co + Routine의 의미로서, 상호협력하는 루틴이라고 볼 수 있고
또는 상호 연계 프로그램, 또는 함수를 일컫기도 한다.

사실 코루틴 개념은 스레드 개념이 나타나기도 전인 1950년대에 등장했다. 이 시기에는 스레드가 없었기 때문에 동시성을 가지는 프로그램을 작성하려면 어쩔 수 없이 동기 방식으로 비동기 프로그래밍을 가능하게 해주는 코루틴을 사용해야 했다.

그래서 아마 예측 가능하겠지만, 유니티의 코루틴은 유니티에서 제공하는 시스템이지만, 코루틴의 모든 것을 유니티에서 만든 것은 아니다. 실제로 유니티 또한 C#에서 제공하는 반복자(Iterator)를 활용해서 코루틴이라는 시스템을 만들었을 뿐이다.

C#의 반복자는 사실 배열과 같은 컬렉션을 단계적으로 순회하기 위해 만들어진 개념이기 때문에, 일반적으로 한 프레임에 모든 반복이 끝나는 것이 기본이다. 하지만 유니티에서는 한 단계가 진행될 때마다 다음 프레임에서 실행되도록 하는 방식을 채택하면서 현재의 코루틴이 탄생하게 되었다.

코루틴은 어떤 원리로 동작하는지

코루틴과 일반 함수와의 차이점이 뭐느냐고 묻는다면, 일반 함수는 반환된 후 프로세스 주소 공간의 스택 영역에 더 이상 어떤 함수 실행 시 정보도 저장하지 않는다. 하지만 코루틴이 반환될 때는 함수의 실행 시 정보를 저장할 필요가 있는데, 코루틴의 실행이 멈추었던 지점에서 다시 실행할 때 이 정보가 필요하기 때문이다. (코루틴은 여러 프레임에 걸쳐 실행되므로, 스택에 할당되면 메서드 호출이 끝날 때 사라지게 될 것이다)

코루틴은 매 yield 마다 새로운 IEnumerator 객체를 생성하여 힙에 할당하는데, 이러한 이유로 코루틴 내부에서의 반복적인 객체 생성은 GC가 처리해야 할 '힙 할당'을 증가시켜 성능을 잡아먹는 요인이 된다.

코루틴 자체는 클래스는 아니지만, 내부적으로 클래스 형태의 열거자(Enumerator)를 사용하는데, C#에서 IEnumerator를 반환하는 메서드는 실제로 상태 머신(state machine)을 구현한 클래스의 인스턴스를 반환한다.

참고) 열거자

  • 열거자는 참조 타입
  • 현재 실행 상태, 로컬 변수 등의 정보를 유지해야 함

예를 들어서 아래 코드는 1초마다 매번 새로운 WaitForSeconds 객체와 IEnumerator를 생성하게 될 수도... OTL 그 잡채

IEnumerator CoroutineExample()
{
    yield return new WaitForSeconds(1f);
    Debug.Log("After 1 second");
}

UniTask

UniTask는 유니티의 Task라는 의미이다.

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

Zero Allocation

  • 메모리 할당을 최소화하거나 완전히 없애는 프로그래밍 기법
  • 가비지 컬렉션(GC)의 부하를 줄여 성능을 향상시키는 것이 목적

즉, 일부 복잡한 비동기 작업에서는 상태를 지속적으로 관리가 필요한 경우 (조건부 대기, 비동기 Loop 등) 이러한 작업은 StateMachineRunner라는 상태머신을 통해 관리되며, 이때 TaskPool을 이용하여 재사용하므로 Zero Allocation과 같은 효과를 낼 수 있다.

예를 들어 아래 코드에서는 추가적인 할당 없이 기존 풀링된 객체를 사용한다.

async UniTaskVoid UniTaskExample()
{
    await UniTask.Delay(1000);
    Debug.Log("After 1 second");
}

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

StateMachineRunner (상태 머신)

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

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

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

StateMachineRunner는 UniTask 라이브러리의 일부로, 비동기 작업을 효율적으로 관리하기 위한 내부 메커니즘이다.

  • async 메서드가 호출되면, C# 컴파일러는 해당 메서드를 상태 머신으로 변환한다.
  • 상태 머신은 현재 상태를 저장하고, 비동기 작업이 다시 시작될 때 어디서부터 재개할지를 추적한다.
  • UniTask는 C#의 기본 상태 머신 구현을 최적화하여 자체 StateMachineRunner를 구현했다.
  • 각 await 지점은 상태 머신의 한 상태가 된다. 각 await 지점에서 StateMachineRunner는 현재 상태를 저장하고 제어를 반환한다.
  • 비동기 작업이 완료되면 StateMachineRunner가 다시 활성화되어 다음 상태로 진행한다.

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

아래는 StateMachineRunner 상태 머신의 코드 예시 (내부 동작 추상화)이다. (개발자가 직접 StateMachineRunner를 다룰 필요는 없으며, UniTask API를 통해 간접적으로 사용된다)

public struct StateMachineRunner<TStateMachine> where TStateMachine : IAsyncStateMachine
{
    private TStateMachine stateMachine;
    private bool isCompleted;

    public void Run()
    {
        while (!isCompleted)
        {
            stateMachine.MoveNext();
        }
    }

    public void SetResult()
    {
        isCompleted = true;
    }
}

UniTask를 추천하는 이유

위에서 본 것처럼 코루틴은 매 yield 지점마다 새로운 객체를 생성하지만, UniTask의 StateMachineRunner는 동일한 인스턴스를 재사용하므로 상태 전환과 메모리 관리가 더 효율적이다.

정리해보자면 코루틴은 메모리 할당을 필요로 하며, 이는 가비지 컬렉션 오버헤드를 초래할 수 있는 반면, UniTask는 Zero Allocation 기능을 제공하여 메모리 할당을 최소화한다. 불필요한 가비지 컬렉션(Garbage Collection, GC)을 방지하여 프레임 드랍이나 렉을 줄일 수 있다!

profile
game client programmer

1개의 댓글

comment-user-thumbnail
2024년 10월 13일

똑똑이 !

답글 달기