코루틴(Coroutine) 이해하기

Unity 프로그래밍에서 중요한 개념 중 하나는 코루틴(Coroutine)입니다. 게임 프로그래밍은 수많은 게임 오브젝트들의 상호 작용으로 표현될수 밖에 없기 때문에 어떤 식으로는 동시성(concurrency)을 표현하는 것이 중요합니다. Unity는 .NET을 사용함에도 불구하고 멀티쓰레드가 아닌 코루틴으로 동시성을 표현하는데, 이는 멀티쓰레드 프로그래밍이 버그 없는 코드를 작성하기 어렵기로 악명 높기 때문입니다.

일단 코루틴이 무엇인지 용어 정의부터 시작하겠습니다. 코루틴은 서브루틴(C#의 메소드에 해당)을 다음 두 축으로 확장한 강력한 서브루틴입니다.

  • 여러 개의 입구(entry point)를 허용하여 실행 재개가 가능
  • 멈춤 시 돌아갈 위치 지정

일반적인 서브루틴은 호출될 때마다 메소드의 처음부터 실행을 시작하고 리턴될 때 항상 호출자(caller)로 돌아가는 반면, 코루틴은 이전 상태를 기억하고 있어서 호출 시 이전 호출에서 멈춘 위치에서부터 다시 실행을 재개하게 되고, 멈출 때는 호출자로 돌아가는 대신 돌아갈 위치를 지정할 수 있습니다.

Unity 프로그래밍에서 코루틴이 중요한 이유는 Unity는 코루틴을 협력형 멀티태스킹(cooperative multitasking)을 구현하는 용도로 사용하기 때문입니다. 협력형 멀티태스킹은 일종의 시분할(time-sharing) 방식으로 여러 task가 하나의 CPU를 나눠쓰는 방식인데, 선점형 멀티태스킹(preemptive multitasking)과 달리 운영체제의 개입 없이 각 task가 독점적으로 CPU를 사용하고 사용이 끝나면 자발적으로 양보하는 방식입니다. 이 방식의 장점은 리소스를 사용하는 도중에 강제로 CPU를 뺏기는 일이 없으므로, 크리티컬 섹션(critical section)을 보호하기 위한 락(lock)이나 세마포어(semaphore) 등 동기화 수단이 필요 없다는 점입니다.

그럼 코루틴으로 협력형 멀티태스킹을 어떻게 구현하는 걸까요? 이해를 돕기 위해 큐에 아이템을 생성하여 집어넣는 produce task와 큐에서 아이템을 꺼내와서 사용하는 consume task를 의사코드(pseudo code)로 구현해 보겠습니다. 이 예제는 위키피디아의 Coroutine 페이지를 참고하였습니다.

var q := new queue
coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume

produce task는 q가 가득 차지 않았으면 새로운 아이템을 생성하여 q에 집어 넣습니다. q가 가득 찼으면 더 이상 할 수 있는 일이 없으므로 consume task에 양보합니다.

coroutine consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield to produce

consume task는 q가 가득 차 있으면 q에서 아이템을 꺼내서 사용합니다. 꺼낼 아이템이 없으면 더 이상 할 수 있는 일이 없으므로 produce task에 양보합니다.

위 코드에서 주목할 부분은 별도의 스케쥴러가 없다는 점입니다. produce task와 consume task 모두 다음 task를 명시적으로 지정하고 있기 때문에 제3자가 개입하여 다음 task를 지정할 필요가 없습니다. “협력형 멀티태스킹”이란 말도 이런 특징에서 기인하였습니다.

여기까지 이해했으면 의문점이 생기게 됩니다. 코루틴으로 협력형 멀티태스킹을 구현할 수 있다는 건 알겠는데, 정작 Unity게임 프로그래밍에 사용하는 C#이란 언어는 코루틴을 제공하지 않기 때문입니다. C#이 코루틴이 없는데, 어떻게 Unity는 코루틴으로 협력형 멀티태스팅을 구현하는 걸까요? 정답은 C#의 이터레이터(iterator)를 사용하는 것입니다.

C# 개발자라면 yield 키워드로 대표되는 C#의 이터레이터를 사용해 본 경험이 있을 겁니다. C# 이터레이터는 프로그래머가 IEnumerableIEnumerator를 쉽게 구현할 수 있게 프로그래밍 언어가 제공하는 기능입니다. 파이썬, EcmaScript 6 등 다른 프로그래밍 언어에서는 제너레이터(generator)로 알려져 있는 기능이기도 합니다. 이해를 돕기 위해 다시 위키피디아 Generator 페이지에 나와있는 C# 이터레이터 예를 하나 살펴보겠습니다.

public class CityCollection : IEnumerable<string> {
    public IEnumerator<string> GetEnumerator() {
        yield return "New York";
        yield return "Paris";
        yield return "London";
    }
}

위 예제의 GetEnumerator 함수는 IEnumerator 타입을 리턴하는데, 이 IEnumeratorMoveNext() 메소드는 호출될 때마다 “New York”, “Paris”, “London”을 차례대로 리턴하게 됩니다. 다시 말해, 일반적인 서브루틴과 달리 위 메소드는 yield return이 값을 리턴하고 이 위치를 기억해 두었다가 다음 호출 시에는 그 다음 위치부터 실행을 재게합니다.

입구가 여러 개이고 실행이 멈춘 지점을 기억했다가 다시 재개한다는 점은 앞서 설명한 코루틴에 대한 설명과 무척 닮았습니다. 물론 코루틴은 리턴될 때 다음 실행할 위치를 명시적으로 지정할 수 있고, 제너레이터는 반드시 호출자로 돌아간다는 차이점도 있습니다. 이런 유사점 때문에 제너레이터를 세미 코루틴(semi coroutine)이라고 부르기도 합니다. 한국말로는 반쪽 코루틴 혹은 약한 코루틴으로 표현할 수도 있겠네요.

여기서 제너레이터-코루틴-협력형 멀티태스킹의 연결 고리가 생기게 됩니다. 제너레이터가 있으면 코루틴을 만들어낼 수 있기 때문입니다. 앞서 살펴본 produce, consume 코루틴의 예를 제너레이터로 다시 구현해 보겠습니다.

generator produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield consume
generator consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield produce

코루틴 예와 달라진 부분은 yield to consumeyield to produce가 각각 yield consumeyield produce로 바뀌었다는 점입니다. 고작 to라는 단어 하나가 빠진 것 같지만, 실제로는 큰 차이가 있습니다. 코루틴의 yield to consumeconsume 코루틴 실행을 재개하라는 뜻이고, yield consume는 이 제너레이터를 호출한 호출자로 리턴되는 값이 consume이라는 뜻입니다.

yield consume의 의미는 함수가 first-class인 자바스크립트나 함수형 언어를 생각하면 쉽게 이해됩니다. consume이라는 제너레이터를 함수 값으로 생각하고 단순히 리턴하는 것입니다. 따라서 consume을 리턴한다고 해서 자동으로 consume 제너레이터가 실행되는 것이 아닙니다. 제너레이터를 코루틴처럼 사용하기 위해서는 다음과 같이 다음으로 실행할 task를 지정해 줄 스케쥴러가 필요하게 됩니다.

subroutine dispatcher
    var d := new dictionary(generator → iterator)
    d[produce] := start produce
    d[consume] := start consume
    var current := produce
    loop
        current := next d[current]

위 코드의 dispatcher는 스케쥴러 역할을 합니다. 일단 dispatcher가 먼저 실행되어 produceconsume 제너레이터를 만들고 루프를 돌면서 제너레이터가 리턴하는 값을 이용해 다음 task를 정하는 방식입니다. 위 예제의 경우 current의 값이 produceconsume로 계속 바뀌면서 번갈아 실행되게 되므로 앞서 살펴본 코루틴의 예제와 마찬가지로 협력형 멀티태스킹이 구현됩니다.

꼭 각 제너레이터가 다음 task를 지정하는 방식이 아니어도 괜찮습니다. 스케쥴러가 전체 task 목록을 가지고 스케쥴링 하는 방식이 되면 일반적인 운영체제의 스케쥴러와 마찬가지로 다양한 스케쥴링 알고리즘을 적용할 수도 있습니다. 예를 들어 yield 문에 다음으로 실행할 제너레이터를 지정하는 대신에 해당 task가 다시 스케쥴링될 시간을 지정할 수도 있습니다. 그리고 이 방식이 바로 Unity가 협력형 멀티태스킹을 구현하는 방법입니다.

IEnumerator Fade() {
    for (float f = 1f; f >= 0; f -= 0.1f) {
        Color c = renderer.material.color;
        c.a = f;
        renderer.material.color = c;
        yield return new WaitForSeconds(.1f);
    }
}

위 코드는 yield 문이 있는 C#의 이터레이터이고 따라서 리턴 타입도 IEnumerator입니다. 하지만 이 코드가 실제로 의미하는 바는 여러 개의 입구가 존재하는 제너레이터이고 yield를 통해 해당 task가 다음에 스케쥴링될 시간을 지정하고 있습니다. Unity 내부에는 이런 제너레이터의 목록을 가지고 있는 스케쥴러가 있어서 앞서 살펴본 방식과 동일한 방식으로 스케쥴링을 해주고 있습니다. MonoBehaviour.StartCoroutine 메소드가 Unity 스케쥴러에 코루틴을 추가하는 방법입니다.

정리하면

  • C#의 이터레이터는 제너레이터이다.
  • 제너레이터는 세미 코루틴이라고도 불리며 스케쥴러의 도움을 받으면 코루틴을 구현할 수 있다.
  • 코루틴을 이용하면 협력형 멀티태스킹이 가능하다.

제너레이터로 코루틴 및 협력형 멀티태스킹을 구현하는 방식은 Unity뿐만 아니라 여러 프레임워크나 라이브러가 사용해 온 일반적인 방법입니다. C++ 대신 쉬운 C#으로 코딩하라는 모토를 가지고 나온 Unity 입장에서 프로그래머가 실수하기 쉬운 멀티쓰레드 프로그래밍보다는 코루틴을 이용한 협력형 멀티태스킹을 강조한 것도 이해가 되는 부분입니다.

하지만 제너레이터를 이용한 코루틴 구현은 어느 정도 “해킹”스러운 면이 있고, IEnumerator를 본래 의미인 여러 collection 타입에 대한 단일 인터페이스가 아닌 전혀 다른 의미로 사용하면서 프로그래머들에게 혼란을 초래하는 면이 있습니다. 게다가 이후 .NET의 Task Parallel Library, C# 5의 async 키워드 등이 나오고 .NET의 멀티쓰레드 프로그래밍 모델이 상당한 발전을 하면서 Unity의 협력형 멀티태스킹 방식의 장점이 상당 부분 희석되었다고 생각합니다. 이 부분은 다음에 다시 한 번 다루도록 하겠습니다.

Advertisements

One thought on “코루틴(Coroutine) 이해하기

댓글이 닫혀있습니다.