반응형

Unity 코루틴이란?

무의식적으로 코루틴은 쓰레드가 생성되는 멀티스레드 방식으로 느껴질 수 있다.

하지만 코루틴은 싱글 스레드로 비동기 방식을 구현한다.

따라서 실제로 병렬 처리가 아니다. 순차 처리로부터 태스크를 분할 처리한다.

 

🚧 2022년 7월 수정 사항
코루틴은 싱글 스레드로 구현되기 때문에 비동기 방식이 아닙니다.
멀티 스레딩 모델의 비동기 방식은 함수 A의 완료와 함수 B의 실행 시점이 일치하지 않습니다.
왜냐하면 병렬로 처리되기 때문인데 그에 반해 코루틴은 순차적으로 처리합니다.
코루틴의 작업 처리가 늦을 수록 다음 작업에 딜레이가 생기는 이유입니다.
그러므로 코루틴은 함수 실행과 완료 시점이 일치하지 않더라도 동기 방식입니다.

MSDN에서는 '순차적으로 작업을 분할해서 처리'하는 것도 비동기로 분류하고 있습니다.
이런 점에서 코루틴도 비동기 방식이라 할 수 있습니다.
하지만 MSDN이 설명하는 것은 엄밀히 따지면 싱글스레드-비동기가 아닌 멀티스레드-비동기입니다.
사용자 코드만 놓고 봐서는 싱글스레드이지만 실제로 시스템 스레드풀에 위임해버리기 때문입니다.
즉, 사용자는 싱글스레드를 쥐어주고 시스템에선 작업자 스레드를 운용하는 것입니다.
이런 방식을 싱글스레드-비동기라고 합니다.

반면 코루틴은 사용자는 싱글스레드, 시스템은 싱글스레드 또는 멀티스레드 일 수 있습니다. 그래서 싱글 스레드를 yield return 하는 경우엔 동기방식이 될테고 멀티 스레드를 운용하는 API를 yield return 하는 경우 비동기방식이라 할 수 있습니다. 하지만 대부분 싱글 스레드로 운영될 것입니다. 함수를 아주 잘게 테스크로 쪼개어서 여러 프레임에 걸쳐 분산 처리 하는 것입니다.

자세한 내용은 여기를 참고해주세요.
cf. 자바스크립트는 싱글 스레드인데 왜 비동기가 가능할까?
📖 레퍼런스
코루틴은 LateUpdate 주기에 실행된다.
In-depth analysis of the Unity Coroutine principle
그 근거는 위 아티클의 테스트 코드를 통해 얻을 수 있다.
e.g. 게임오브젝트의 enabled가 false로 바뀌면 실행 중인 코루틴이 모두 중단 됨.
e.g. yield return 이후 코드는 LateUpdate 호출 시점에 실행됨.

 

그 동안 궁금했던 동작원리를 알아보자.

IEnumerator

코루틴의 반환 타입은 IEnumerator이다. 왜 IEnumerator를 반환해야 할까?

먼저 IEnumerator에 대해 알아보자. 

IEnumerator는 클래스가 IEnumerable이 되려면 반드시 구현해야 할 인터페이스다.

// IEnumerator
class Enumerator : IEnumerator
{
    int _idx = 0;
    int _current;
    int[] _pmArr = new int[] { 2, 3, 5, 7 };

    public object Current => _current;
    // 다음 데이터 있는지 체크 & current 갱신
    public bool MoveNext()
    {
        if (_pmArr.Length - 1 < _idx)
        {
            return false;
        }
        _current = pmArr[_idx];
        _idx++;
        return true;
    }
    public void Reset()
    {
        _idx = 0;
    }
}

IEnumerator은 배열의 Length를 체크하고 다음 데이터가 있는지 여부 Current값을 저장한다.

// IEnumerable
class PrimeNums : IEnumerable
{
    // IEnumerator
    class Enumerator : IEnumerator
    {
        // 중략
    }
    public IEnumerator GetEnumerator()
    {
        return new Enumerator();
    }
}

클래스가 IEnumerable이 되면

// foreach
private static void DoForeach()
{
    PrimeNums primeNums = new PrimeNums();

    foreach (var item in primeNums)
    {
        Console.WriteLine(item);
    }
}

 foreach문 사용이 가능하다.

✏️ 더 알아보기
IEnumerable<T>를 구현하면 IEnumerable<T>를 통해 System.Linq도 쓸 수 있다.
e.g. Selcet, Where, Orderby 등

✏️ IEnumerable 캡슐화 하는 데 도움이 된다.
IEnumerable은 단순히 컬렉션을 순회하는 데에만 쓰이므로 컬렉션의 데이터 변경을 막는 데 사용할 수 있다. List, Array 등 데이터 변경이 가능한 컬렉션 대신 IEnumerable을 참조하도록 하는 것이다

 

foreach를 사용하는 데에 IEnumerator가 필요한 이유는

결국 내부적으로 필요하기 때문일 것이다

// foreach - pseudo native C# code
private static void DoEnumerate()
{
    PrimeNums primeNums = new PrimeNums();

    var enumerator = primeNums.GetEnumerator();
    while (enumerator.MoveNext())
    {
        var item = enumerator.Current;
        Console.WriteLine(item);
    }
}

foreach문의 IL코드를 들여다보면 위와 유사한 형태의 로직이 나타난다.

 

foreach에서 쓰인 IEnumerator는 '다음 데이터가 있는지 여부'를 체크하고 요소를 Current에 저장했다.

즉, 코루틴에서 반환하는 IEnumerator도 이런 역할을 하기 위해서 존재한다.

 IEnumerator : 컬렉션 vs 코루틴
컬렉션(List, Array 등)의 IEnumerator Current에 순회할 요소를 담지만,
코루틴은 지연 함수 또는 비동기 함수의 IEnumerator를 담는다.

 

yield return

foreach가 내부적으로 IEnumerator를 사용하듯

IEnumerator를 사용하는 예약어가 또 있는데, 그 것은 바로 yield return이다.

다음 그림은 yield return 예약어가 생성한 MoveNext의 IL코드이다.

yield return foreach와 다르게 switch 분기문으로 로직이 구성됐다.

Console.WriteLine("DoEnumerate 1")
Console.WriteLine("DoEnumerate 2")
Console.WriteLine("DoEnumerate 3")

각 구문을 스위치를 통해 분기했다.

<>1__state는 스위치 분기문의 인덱스로 활용된다.

즉, 컬렉션 순회를 위해 _idx가 있었던 것처럼 스위치 분기를 위해 <>1__state가 있는 것이다.

  foreach vs yield return
IL코드를 들여다보면 컴파일 전에는 볼 수 없었던 예약어의 native code를 조금 엿볼 수 있다.

foreach는 IEnumerable.GetEnumerator() IEnumerator.MoveNext() 등 우리가 구현한 클래스를 참조한다. 그러려면 IEnumerable IEnumerator의 구현 코드를 우리가 직접 작성 해야만 한다.

반면 yield return IEnumerator 클래스 구현도 컴파일러가 대신 작성해주고 있다. 
즉, IEnumerator를 구현해야만 하는 수고로운 일이 사라진 것이다.

cf. C# 라이브러리의 컬렉션(List, Array 등)은 대부분 이미 IEnumerable이 구현된 상태이다. 그래서 곧바로 System.Linq나 foreach 등 순회문을 사용할 수 있는 것이다.

 

코루틴 구현

코루틴도 IEnumerator.MoveNext 함수를 호출하는 것을 볼 수 있다.

using System.Collections;
using UnityEngine;

public class CoroutineStudy : MonoBehaviour
{
    private void Start()
    {
        StartCoroutine(DoCoroutine());
        Debug.Log("Start");
    }
    
    IEnumerator DoCoroutine()
    {
        Debug.Log("DoCoroutine WaitForSeconds");
        yield return new WaitForSeconds(1.0f);
        Debug.Log("DoCoroutine Null");
        yield return null;
        Debug.Log("DoCoroutine end");
    }
}

IEnumerator의 동작원리를 이용해 위 코루틴을 직접 구현해보자.

using System.Collections;
using UnityEngine;

public class CoroutineStudy : MonoBehaviour
{
    // native code written by compiler service
    class PseudoCoroutineEnumerator : IEnumerator
    {
        int _state = 0;
        object _current = null;
        public object Current => _current;

        public bool MoveNext()
        {
            switch (_state)
            {
                case -1:
                    return false;
                case 0:
                    Debug.Log("DoPseudoCoroutine WaitForSeconds");
                    _state++;
                    _current = new WaitForSeconds(1.0f);
                    return true;
                case 1:
                    Debug.Log("DoPseudoCoroutine Null");
                    _state++;
                    _current = null;
                    return true;
                case 2:
                    _state = -1;
                    Debug.Log("DoPseudoCoroutine end");
                    return true;
            }
            return false;
        }

        public void Reset()
        {
            _state = 0;
        }
    }

    IEnumerator enumerator;                         // native code of UnityEngine
    private void Start()
    {
        StartPseudoCoroutine(DoPseudoCoroutine());  // native code of UnityEngine
        Debug.Log("Start");

    }

    float seconds = 1.0f;    // = WaitForSeconds.m_seconds
    float elapsed = .0f;     // native code of UnityEngine
    private void LateUpdate()
    {
        // native code of UnityEngine
        if (enumerator.Current != null)
        {
            if (enumerator.Current is WaitForSeconds)
            {
                if (seconds < elapsed)
                {
                    enumerator.MoveNext();
                }
                elapsed += Time.deltaTime;
            }
        }
        else
        {
            enumerator.MoveNext();
        }
    }

    // native code of UnityEngine
    void StartPseudoCoroutine(IEnumerator enumerator)
    {
        this.enumerator = enumerator;
        enumerator.MoveNext();
    }

    IEnumerator DoPseudoCoroutine()
    {
        // native code written by compiler service
        return new PseudoCoroutineEnumerator();
    }
}

native code(기계어로 컴파일되는 코드)는 IL코드에 의해 생성될 것을 가정하고 임의로 작성했다.

결과는 코루틴과 같았다. 

싱글 스레드만으로 마치 병렬처리인 것 처럼 구현이 가능한 것이다.

🚧 
"이렇겠다"라는 추측에서 나온 결과물이다. 실제로는 코루틴을 관리하는 클래스가 존재한다. 그리고 이 것보다 복잡할 것이다. 하지만 기본적인 아이디어는 이 것과 크게 다르지 않을 거라 생각된다.

 

결론

1. 코루틴은 싱글스레드로 구현 가능하다.

2. C# 예약어(foreach, yield return 등)는 IEnumerator 클래스를 참조 또는 작성한다.

3. yield return이 작성한 IEnumerator.MoveNext()에 의해 코루틴 구문들이 Switch로 분기된다.

4. yield return구문으로 반환되는 값은 Current에 저장된다(주로 AsyncOperation이나 Wait~개체).

5. MonoBehaviour MoveNext() Current를 참조해 코루틴을 구현한다.

"코루틴은 싱글스레드 비동기이다."

 

ref : https://planek.tistory.com/36

반응형

+ Recent posts