유니티 비동기 프로그래밍: Coroutines, Tasks and UniTasks

06 Nov 2023  Khlee  21 mins read.

개요

유니티에서 비동기 프로그래밍을 하는 방법은 3가지가 있다. 기본적으로 제공되는 Coroutine과 Task가 있고, 패키지 형태로 추가할 수 있는 UniTask가 있다. 이번 포스트에서는 각 비동기 프로그래밍 방법들의 특징에 대해 정리할 것이다.

Coroutine은 멀티 스레딩?

Coroutine은 싱글 스레드로 유니티 메인 스레드에서 동작한다. 그 말은, Coroutine 내에서 yield 문을 통해 명시적으로 제어를 반납하지 않는 한 절대로 메인 스레드에서 다른 코드가 실행되지 않는다는 것을 의미한다. 그렇기 때문에 Coroutine 내에서 제어를 반납하지 않고 많은 작업을 하거나 무한루프에 빠지는 경우 프로그램 전체가 느려지거나 아예 멈추어버릴 수 있다. 한편으론 싱글 스레드이기 때문에 race condition, thread-safe 등에 대한 걱정에서 자유로우며 유니티 함수들을 안전하게 호출할 수 있다.

Coroutine 동작 원리

Coroutine의 반환 타입은 IEnumerator다. IEnumerator를 반환하는 함수에서는 아래와 같이 yield 문을 사용할 수 있다.

private IEnumerator MyCoroutine()
{
    Debug.Log("Coroutine 1");
    yield return null;
    Debug.Log("Coroutine 2");
    yield return null;
    Debug.Log("Coroutine 3");
}

그리고 이 IEnumerator 객체의 MoveNext()를 호출하면

private void Start()
{
    IEnumerator enumerator = MyCoroutine();

    Debug.Log("Start 1");
    enumerator.MoveNext();
    Debug.Log("Start 2");
    enumerator.MoveNext();
    Debug.Log("Start 3");
    enumerator.MoveNext();
}
Start 1
Coroutine 1
Start 2
Coroutine 2
Start 3
Coroutine 3

Coroutine 로그 사이에 Start가 들어가 있다는 것을 확인할 수 있다. 즉 MyCoroutine() 함수 내의 지정된 지점에서 제어를 반납할 수 있고 이후의 코드는 나중에 실행할 수 있다는 것이다. 이것이 비동기 동작의 핵심이다.

참고로 IEnumerator는 본래 foreach 등에서 컬렉션을 순회하기 위한 용도로 사용된다. 이에 관해 더 알고 싶다면 이 블로그를 참고할 수 있다.

그렇다면 우리의 Coroutine을 실행하기 위한 MoveNext()를 유니티 엔진에서 호출해주고 있음을 쉽게 짐작할 수 있다. 간단히 다음과 같은 Coroutine을 실행해서 로그를 확인해 보면

private void Start()
{
    StartCoroutine(MyCoroutine());
}

private IEnumerator MyCoroutine()
{
    yield return null;
    Debug.Log("MyCoroutine");
}
MyCoroutine
UnityEngine.Debug:Log (object)
TestController/<MyCoroutine>d__1:MoveNext () (at Assets/Scripts/TestController.cs:39)
UnityEngine.SetupCoroutine:InvokeMoveNext (System.Collections.IEnumerator,intptr) (at /Users/bokken/build/output/unity/unity/Runtime/Export/Scripting/Coroutines.cs:17)

Stack trace에 MoveNext가 있음을 확인할 수 있다.

이제 yield return에서 반환하는 값이 실제로 무슨 일을 하는지 알아볼 것이다. 우리는 yield return new WaitForSeconds(1.0f);를 호출하면 그 이후의 코드가 1초 뒤에 실행된다는 사실을 알고 있다. 그 원리를 알아보기 위해 IEnumerator가 무엇으로 이루어져 있는지 파악할 필요가 있다. IEnumerator 인터페이스를 구현하려면 다음과 같은 멤버를 구현해야 한다.

public class MyEnumerator : IEnumerator
{
    public object Current { get; }

    public bool MoveNext()
    {
        return false;
    }

    public void Reset() { }
}
  • public object Current { get; }: 가장 최근에 yield return으로 반환한 값이다.
  • public bool MoveNext(): Coroutine의 다음 작업(yield 문 다음의 코드들) 재개하는 함수이다.
  • public void Reset(): Coroutine에서는 쓰이지 않는데, 작업을 다시 처음으로 돌리는 함수이다. 이 함수를 호출하고 MoveNext()를 호출하면 다시 처음 작업이 실행된다.

여기서 Currentyield return으로 반환된 값이라는 것이 중요하다. 실제로 다음 코드를 실행하면

private void Start()
{
    IEnumerator enumerator = MyCoroutine();
    enumerator.MoveNext();
    Debug.Log($"Current={enumerator.Current}");
}

private IEnumerator MyCoroutine()
{
    yield return new WaitForSeconds(1.0f);
}

다음과 같은 로그를 출력한다.

Current=UnityEngine.WaitForSeconds

따라서 유니티 엔진에서 MoveNext()를 호출한 후 Current 값을 확인해서 다음 MoveNext()를 언제 호출할지 결정하는 로직이 구현되어 있음을 짐작할 수 있다.

유니티 문서의 ExecutionOrder를 보면 Current에 따라 MoveNext()가 언제 호출될지 확인할 수 있다.

대부분의 Current 값에 대해서는 MoveNext()Update() 직후에 호출된다.

General yield instructions

WaitForEndOfFrame의 경우 렌더링이 모두 끝나고 프레임이 종료되기 직전에 호출된다.

WaitForEndOfFrame yield instruction

WaitForFixedUpdate의 경우 물리 루프가 끝나기 직전에 호출된다. FixedUpdate()와는 반대로 물리 업데이트가 끝난 후에 호출되는 것을 확인할 수 있다.

WaitForFixedUpdate yield instruction

Task의 멀티 스레딩

Task는 기본적으로 스레드풀에서 동작할 수 있음을 감안해야 한다. 예를 들어 아래와 같은 코드는

private void Start()
{
    Debug.Log($"MainThreadID = {Thread.CurrentThread.ManagedThreadId}");

    Task.Run(() =>
    {
        Debug.Log($"ThreadID Task = {Thread.CurrentThread.ManagedThreadId}");
    });
}

아래와 같은 로그를 출력한다.

MainThreadID = 1
ThreadID Task = 48

메인 스레드에서 await를 만난 경우, await 이후의 코드도 메인 스레드에서 실행된다.

private async void Start()
{
    Debug.Log($"MainThreadID = {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() =>
    {
        Debug.Log($"ThreadID Task = {Thread.CurrentThread.ManagedThreadId}");
    });

    Debug.Log($"ThreadID Start = {Thread.CurrentThread.ManagedThreadId}");
}

private void Update()
{
    Debug.Log($"ThreadID Update = {Thread.CurrentThread.ManagedThreadId}");
}
MainThreadID = 1
ThreadID Task = 92
ThreadID Update = 1
ThreadID Start = 1

로그에서 ThreadID TaskThreadID Start 사이에 ThreadID Update가 껴 있다. 이 말은 await 이후의 코드가 비동기적으로 실행되었다는 의미이다.

반면 메인 스레드가 아닌 스레드에서 await를 만난 경우 항상 같은 스레드에서 실행되는 것이 보장되지는 않는다.

private void Start()
{
    Debug.Log($"MainThreadID = {Thread.CurrentThread.ManagedThreadId}");

    Task.Run(async () =>
    {
        Debug.Log($"ThreadID Task 1 = {Thread.CurrentThread.ManagedThreadId}");
        await Task.Yield();
        Debug.Log($"ThreadID Task 2 = {Thread.CurrentThread.ManagedThreadId}");
    });
}
MainThreadID = 1
ThreadID Task 1 = 121
ThreadID Task 2 = 128

메인 스레드에서 await 이후의 코드가 스레드 풀에서 실행되기 원하는 경우 ConfigureAwait(false)를 사용할 수 있다.

private async void Start()
{
    Debug.Log($"MainThreadID = {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() =>
    {
        Debug.Log($"ThreadID Task = {Thread.CurrentThread.ManagedThreadId}");
    }).ConfigureAwait(false);

    Debug.Log($"ThreadID Start = {Thread.CurrentThread.ManagedThreadId}");
}
MainThreadID = 1
ThreadID Task = 156
ThreadID Start = 156

Task 내부에서의 스레드와 await 이후의 스레드가 같다는 것을 확인할 수 있다!

Task 동작 원리

앞 문단에서는 Taskasync / await를 구분하지 않고 사용했지만 사실은 차이가 있다. async / await는 언어 차원에서 제공되는 비동기 프로그래밍 기능에 관한 키워드이고, Taskasync / await를 이용해서 작업을 스레드풀에서 비동기적으로 실행할 수 있도록 해 주는 구현체이다. 따라서 꼭 Task가 아니더라도 Task 같은 무언가를 직접 만들어서 async / await를 통해 비동기 프로그래밍을 할 수 있다. 이 문단에서는 비동기 작업을 의미하는 MyTask<T>라는 클래스를 만들어보면서 Task의 동작 원리에 대해 살펴 볼 것이다.

어떤 작업을 비동기적으로 실행하려면 awaitable 해야 한다. awaitable 하다는 것은 await 키워드와 함께 사용할 수 있는 객체라는 뜻이다. 아래와 같이 awaitable하지 않은 객체를 await 키워드와 함께 사용하면 컴파일 오류가 발생한다.

private async void Start()
{
    await MyFunc();
}

private int MyFunc()
{
    return 1;
}
'int' does not contain a definition for 'GetAwaiter' and no accessible extension method 'GetAwaiter' accepting a first argument of type 'int' could be found (are you missing a using directive or an assembly reference?)

오류 메시지를 보면 MyFunc()의 반환 타입인 intGetAwaiter에 대한 정의가 없다고 하는 것을 알 수 있다. 따라서 awaitable이 되려면 GetAwaiter를 구현하면 된다.

public class MyTask<T>
{
    public MyAwaiter<T> GetAwaiter()
    {
        return new MyAwaiter<T>(this);
    }
}

다음 문제가 생겼다. GetAwaiter는 뭔가 Awaiter를 반환해야 한다는 것이다. Awaiter가 되려면 다음과 같은 멤버를 구현해야 한다.

  • public bool IsCompleted { get; }: 해당 작업이 이미 종료되었는지를 확인하는 함수다. true를 반환하면 OnCompleted() 호출 없이 바로 GetResult()를 호출하며 await 이후의 코드가 동기적으로 실행된다.
  • public T GetResult(): 해당 작업의 결과를 가져오는 함수다. 결과를 가져 올 필요가 없는 작업이라면 반환타입을 void로 선언하면 된다.
  • public void OnCompleted(Action continuation): INotifyCompletion 인터페이스를 상속받아 구현되는 함수로, 해당 작업이 완료되었을 때 실행해야 할 함수를 지정하는 함수다. continuationawait 이후의 코드가 들어있다.

INotifyCompletion 말고 ICriticalNotifyCompletion 인터페이스를 구현할 수도 있다. 이 경우 OnCompleted() 대신 UnsafeOnCompleted()가 호출된다.

여기서 OnCompleted()를 통해 전달받는 continuationawait 이후의 코드가 들어있다는 것이 async / await 비동기 동작의 핵심이다. 우리는 이 continuation의 호출을 우리가 원하는 시점에 호출해서 await 이후의 코드의 실행을 우리가 원하는 지점까지 지연시킬 수 있다. 그렇게 지연된 동안 현재 스레드는 다른 작업을 할 수 있는 것이다.

위의 멤버를 구현한 MyAwaiter<T>는 다음과 같다.

public struct MyAwaiter<T> : INotifyCompletion
{
    private MyTask<T> task;
    public bool IsCompleted => false;

    public T GetResult()
    {
        return default;
    }

    public MyAwaiter(MyTask<T> task)
    {
        this.task = task;
    }

    public void OnCompleted(Action continuation)
    {
        Debug.Log("called OnCompleted");
        continuation?.Invoke();
    }
}

public class MyTask<T>
{
    public MyAwaiter<T> GetAwaiter()
    {
        return new MyAwaiter<T>(this);
    }
}

public class TestController : MonoBehaviour
{
    private void Start()
    {
        Debug.Log("Start a");
        AsyncWork();
        Debug.Log("Start b");
    }

    private async void AsyncWork()
    {
        Debug.Log("AsyncWork a");
        await new MyTask<int>();
        Debug.Log("AsyncWork b");
    }
}
Start a
AsyncWork a
called OnCompleted
AsyncWork b
Start b

OnCompleted() 함수의 매개변수로 들어오는 continuation을 호출하면 await 이후의 코드인 로그 AsyncWork b가 출력되는 것을 확인할 수 있다. 하지만 이렇게 구현된 MyAwaiter<T>를 사용하면, 로그 Start b가 항상 called OnCompleted 다음에 출력된다. 즉, OnCompleted() 함수는 동기적으로 실행되므로 비동기 작업을 여기서 실행하면 안 된다. 비동기적인 작업을 수행할 수 있도록 MyTask<T>를 다음과 같이 수정한다.

public struct MyAwaiter<T> : INotifyCompletion
{
    private MyTask<T> task;

    public bool IsCompleted => task.IsTaskCompleted;

    public T GetResult()
    {
        return task.Result;
    }

    public MyAwaiter(MyTask<T> task)
    {
        this.task = task;
    }

    public void OnCompleted(Action continuation)
    {
        task.OnTaskCompleted += continuation;
        task.Start();
    }
}

public class MyTask<T>
{
    private Thread thread;
    public T Result { get; private set; }
    public bool IsTaskCompleted { get; private set; } = false;
    public event Action OnTaskCompleted;

    public MyTask(Func<T> func)
    {
        thread = new Thread(() =>
        {
            Result = func();
            IsTaskCompleted = true;
            OnTaskCompleted?.Invoke();
        });
    }

    public MyAwaiter<T> GetAwaiter()
    {
        return new MyAwaiter<T>(this);
    }

    public void Start()
    {
        thread.Start();
    }
}

public class TestController : MonoBehaviour
{
    private void Start()
    {
        Debug.Log($"ThreadID Start a = {Thread.CurrentThread.ManagedThreadId}");
        AsyncWork();
        Debug.Log($"ThreadID Start b = {Thread.CurrentThread.ManagedThreadId}");
    }

    private async void AsyncWork()
    {
        Debug.Log($"ThreadID AsyncWork a = {Thread.CurrentThread.ManagedThreadId}");
        int value = await new MyTask<int>(() =>
        {
            Debug.Log($"ThreadID Task = {Thread.CurrentThread.ManagedThreadId}");
            return 5;
        });
        Debug.Log($"value={value}, ThreadID AsyncWork b = {Thread.CurrentThread.ManagedThreadId}");
    }
}
ThreadID Start a = 1
ThreadID AsyncWork a = 1
ThreadID Start b = 1
ThreadID Task = 1100
value=5, ThreadID AsyncWork b = 1100

이제 MyTask<T>는 작업을 비동기적으로 실행하기 때문에 Start bAsyncWork b보다 먼저 출력되는 것을 확인할 수 있다. 하지만 여기에서는 Task와 한 가지 차이점이 있다. 바로 로그 AsyncWork a가 메인 스레드에서 실행되었음에도 로그 AsyncWork b는 메인 스레드에서 실행되지 않았다는 점이다. 로그 AsyncWork b도 메인 스레드에서 실행되기를 원한다면, 작업이 종료되었을 때 continuation을 메인 스레드로 디스패치해서 실행하는 방법을 알아야 한다. 이것을 위해 SynchronizationContext가 있다.

SynchronizationContext

SynchronizationContext는 스레드간 동기화 컨텍스트를 담는 역할을 한다. SynchronizationContext 클래스 자체는 개념적인 것이고 유니티의 경우 UnityEngine.UnitySynchronizationContext가 구현되어 있다.

메인 스레드에서는 기본적으로 SynchronizationContext.CurrentUnitySynchronizationContext가 있고, 그 외의 모든 백그라운드 스레드에서는 따로 정의하지 않는 한 SynchronizationContext.Currentnull이다.

private void Start()
{
    Debug.Log($"Start: {SynchronizationContext.Current?.ToString() ?? "null"}");

    Task.Run(() =>
    {
        Debug.Log($"Task: {SynchronizationContext.Current?.ToString() ?? "null"}");
    });
}
Start: UnityEngine.UnitySynchronizationContext
Task: null

마지막으로 SynchronizationContext에는 Post()Send() 함수가 있다. 이 함수를 통해 특정 함수를 SynchronizationContext가 정의된 스레드에서 실행되도록 디스패치할 수 있다. 예를 들어 UnitySynchronizationContextPost()Send() 함수를 통해 특정 함수가 유니티 메인 스레드에서 실행되도록 디스패치할 수 있다. Post()Send()의 차이점은, Post()는 비동기적으로 함수를 디스패치하고 현재 스레드에서 다음 작업을 실행할 수 있는 반면 Send()는 디스패치 후 그 결과를 받아야 하므로 디스패치 된 함수가 끝나야만 현재 스레드를 재개할 수 있다는 것이다.

이제 SynchronizationContext를 고려해서 수정한 MyTask<T>는 다음과 같다. MyAwaiter<T>.OnCompleted() 함수의 구현만 변경하였다.

public void OnCompleted(Action continuation)
{
    SynchronizationContext context = SynchronizationContext.Current;

    if (context == null)
    {
        task.OnTaskCompleted += continuation;
    }
    else
    {
        task.OnTaskCompleted += delegate
        {
            context.Post((object arg) => { continuation?.Invoke(); }, null);
        };
    }

    task.Start();
}
ThreadID Start a = 1
ThreadID AsyncWork a = 1
ThreadID Start b = 1
ThreadID Task = 1391
value=5, ThreadID AsyncWork b = 1

이제 로그 AsyncWork b가 메인 스레드에서 실행되는 것을 확인할 수 있다.

UniTask

마지막으로 UniTask 차례다. UniTask는 유니티를 위한 async / await 통합 기능을 제공한다. 사용법과 기능은 대부분 Task와 유사하나 다음과 같은 중요한 차이점이 있다.

  • 기본적으로 메인 스레드에서 동작한다.
  • 유니티의 Coroutine이나 Player loop와 연동하기 쉽다.

먼저 스레드 부분부터 확인해 본다. Task의 경우 Task.Run()를 사용해서 쉽게 작업을 스레드풀에서 실행할 수 있었지만, UniTask를 사용해서 백그라운드 스레드에서 작업을 실행하려면 다음과 같이 명시적인 함수를 사용해야 한다.

private void Start()
{
    Debug.Log($"ThreadID Start = {Thread.CurrentThread.ManagedThreadId}");
    UniTask.RunOnThreadPool(() =>
    {
        Debug.Log($"ThreadID Task = {Thread.CurrentThread.ManagedThreadId}");
    }).Forget();
}
ThreadID Start = 1
ThreadID Task = 17

백그라운드 스레드에서 실행되었다 하더라도 “일반적인” await UniTask를 만나면 다시 메인 스레드로 돌아온다.

private void Start()
{
    Debug.Log($"ThreadID Start = {Thread.CurrentThread.ManagedThreadId}");
    UniTask.RunOnThreadPool(async () =>
    {
        Debug.Log($"ThreadID Task 1 = {Thread.CurrentThread.ManagedThreadId}");
        await UniTask.Yield();
        Debug.Log($"ThreadID Task 2 = {Thread.CurrentThread.ManagedThreadId}");
    }).Forget();
}
ThreadID Start = 1
ThreadID Task 1 = 7
ThreadID Task 2 = 1

한편 await 전후로 스레드를 전환할 수 있는 기능을 제공한다.

UniTask.SwitchToMainThread()를 사용하면 await 이후의 코드를 메인 스레드에서 실행되도록 할 수 있다. 그러나 이것은 다른 “일반적인” await UniTask와 큰 차이는 없다. 다만 메인 스레드로 전환하는 용도로 사용한다면 UniTask.SwitchToMainThread()를 사용하는 것이 가독성 면에서 더 좋을 것이다.

private void Start()
{
    Debug.Log($"ThreadID Start = {Thread.CurrentThread.ManagedThreadId}");
    UniTask.RunOnThreadPool(async () =>
    {
        Debug.Log($"ThreadID Task 1 = {Thread.CurrentThread.ManagedThreadId}");
        await UniTask.SwitchToMainThread();
        Debug.Log($"ThreadID Task 2 = {Thread.CurrentThread.ManagedThreadId}");
    }).Forget();
}
ThreadID Start = 1
ThreadID Task 1 = 14
ThreadID Task 2 = 1

반대로 UniTask.SwitchToThreadPool()를 사용하면 await이후의 코드를 스레드 풀에서 실행되도록 할 수 있다.

private async void Start()
{
    Debug.Log($"ThreadID Start 1 = {Thread.CurrentThread.ManagedThreadId}");
    await UniTask.SwitchToThreadPool();
    Debug.Log($"ThreadID Start 2 = {Thread.CurrentThread.ManagedThreadId}");
}
ThreadID Start 1 = 1
ThreadID Start 2 = 16

두 번째 차이점으로 유니티와의 호환성을 확인 해 본다. 우선 다음과 같이 Coroutine이나 유니티 Async operationawait 할 수 있게 된다. (출처)

var asset = await Resources.LoadAsync<TextAsset>("foo");
var txt = (await UnityWebRequest.Get("https://...").SendWebRequest()).downloadHandler.text;
await SceneManager.LoadSceneAsync("scene2");

유니티 Player loop 관련된 Awaitable도 제공한다. (출처)

// await frame-based operation like a coroutine
await UniTask.DelayFrame(100); 

// replacement of yield return new WaitForSeconds/WaitForSecondsRealtime
await UniTask.Delay(TimeSpan.FromSeconds(10), ignoreTimeScale: false);

// yield any playerloop timing(PreUpdate, Update, LateUpdate, etc...)
await UniTask.Yield(PlayerLoopTiming.PreLateUpdate);

// replacement of yield return null
await UniTask.Yield();
await UniTask.NextFrame();

// replacement of WaitForEndOfFrame
await UniTask.WaitForEndOfFrame();

// replacement of yield return new WaitForFixedUpdate(same as UniTask.Yield(PlayerLoopTiming.FixedUpdate))
await UniTask.WaitForFixedUpdate();

여기서 UniTask.Yield() 대신 Task.Yield()를 사용해도 되지 않을지 의문이 있는데, 실험해 본 결과 동작은 크게 다르지 않았다. 다만 Task.Yield()가 정확히 한 프레임을 기다린다는 보장은 없다. await Task.Yield()를 실행하면 UnitySynchronizationContext.Post()await 이후의 코드가 넘어가겠지만 유니티가 이것을 다음 프레임에 실행해줄지 아니면 그 다음 프레임에 실행해줄지 알 수 없다. 따라서 프레임 단위 로직을 구현하는 경우 Coroutine을 사용하거나 UniTask를 사용하는 것이 좋다.

khlee
khlee