UniTask 는 PlayerLoop 에서 await 를 이어 준다
Esc

    UniTask 는 PlayerLoop 에서 await 를 이어 준다

    2026년 10월 4일 수정–Unity라이브러리동시성
    async void Awake()
    {
        await UniTask.Yield();      // 누가, 언제 이어 주나?
        await UniTask.Delay(500);
    }

    코루틴은 MonoBehaviour 가 돌리고, Task 는 스레드 풀이나 동기화 컨텍스트가 이어 줍니다. UniTask 에는 숨은 게임오브젝트도 전용 스레드도 없는데, await 뒤는 누가 언제 이어 줄까요.

    UniTask 는 첫 씬 전에 PlayerLoop 단계마다 맨 앞과 맨 뒤, 열여섯 자리에 큐와 러너를 꽂고, await 뒤 코드는 그 자리에서 메인 스레드로 이어집니다.

    첫 씬 전에 열여섯 자리를 잡는다

    // Runtime/PlayerLoopHelper.cs (줄임)
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]   // 2020.1 이상
    static void Init()
    {
        var playerLoop = PlayerLoop.GetCurrentPlayerLoop();
        Initialize(ref playerLoop);    // 단계마다 앞뒤로 끼워 넣는다
    }

    AfterAssembliesLoaded 는 첫 씬을 불러오기 전입니다. 그래서 어떤 Awake 에서 await 해도 받아 줄 자리가 이미 있습니다.

    자리는 최상위 단계 여덟 개의 맨 앞과 맨 뒤(Last 가 붙은 타이밍), 모두 열여섯입니다. 자리마다 둘을 꽂습니다. 한 번 깨우면 되는 일을 담는 큐(ContinuationQueue)와 매 프레임 물어야 하는 일을 담는 러너(PlayerLoopRunner)입니다. 같은 자리에서는 큐, 러너 순으로 돌고, 앞 자리는 둘 다 MonoBehaviour.Update 보다 먼저입니다.

    Mono 개발 빌드 플레이어의 PlayerLoop 를 덤프했습니다. 숫자는 그 단계 하위 시스템 목록에서의 위치(0 부터)입니다.

    프레임 안 순서 단계 하위 수 앞 (Yield · Runner) 뒤 (LastYield · Last)
    0 TimeUpdate 7 0 · 1 5 · 6
    1 Initialization 15 1 · 2 12 · 13
    2 EarlyUpdate 43 0 · 1 40 · 41
    3 FixedUpdate 23 0 · 1 20 · 21
    4 PreUpdate 18 0 · 1 16 · 17
    5 Update 13 0 · 1 10 · 11
    6 PreLateUpdate 22 0 · 1 19 · 20
    7 PostLateUpdate 56 0 · 1 54 · 55
    • PlayerLoopTiming 번호는 실행 순서가 아닙니다. 번호가 14, 15 로 가장 큰 TimeUpdate 가 가장 먼저 돕니다.
    • “맨 앞” 은 끼워 넣는 순간의 이야기입니다. 이 프로젝트에서는 나중에 끼어든 다른 패키지가 Initialization 의 0 번을 차지했습니다.

    Update() 안에서 기본 타이밍으로 await 하면, 이번 프레임의 Update 자리는 이미 지나갔으니 다음 프레임에 이어집니다.

    Yield 는 큐가 프레임당 한 번 부른다

    await UniTask.Yield() 는 이어질 델리게이트를 그 타이밍의 큐에 넣을 뿐입니다. 큐는 배열을 두 개 둡니다. 비우는 동안 들어온 것은 다른 배열로 받아 두었다가 다음 프레임에 부릅니다. while (true) await UniTask.Yield(); 루프 P, Q 가 한 프레임을 지나는 모습입니다.

    넣을 때는 큐가 비우는 중인지를 보고 배열을 고릅니다.

    // Enqueue(cont)
    if (dequing)
        waitingList[waitingListCount++] = cont;   // 비우는 중이면 대기 배열로
    else
        actionList[actionListCount++] = cont;

    비울 때는 actionList 만 돌고, 다 돌면 두 배열을 맞바꿉니다.

    // RunCore(): 프레임마다 이 타이밍에서 한 번
    if (actionListCount == 0)
        return;
    
    dequing = true;
    
    for (int i = 0; i < actionListCount; i++)
    {
        var action = actionList[i];
        actionList[i] = null;
    
        try
        {
            action();                             // 깨어난 루프가 다시 Yield 를 부르면 waitingList 로 간다
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);
        }
    }
    
    dequing = false;
    
    (actionList, waitingList) = (waitingList, actionList);   // 복사 없이 두 배열을 맞바꾼다
    actionListCount = waitingListCount;
    waitingListCount = 0;

    actionList 에 P, Q 가 들어 있는 프레임은 이렇게 돕니다.

    1. dequing 을 켭니다. 이제부터 들어오는 것은 waitingList 로 갑니다.
    2. P 를 부릅니다. P 는 한 바퀴 돌고 다시 Yield() 를 불러 waitingList 에 들어갑니다. 같은 프레임에 또 불리지 않습니다.
    3. Q 도 같습니다. actionList 는 비고 waitingList 는 [P, Q] 가 됩니다.
    4. dequing 을 끄고 두 배열을 맞바꿉니다. actionList 가 다시 [P, Q] 가 되어 다음 프레임에 한 번씩 불립니다.

    배열이 하나였다면 P 가 넣은 자기 자신을 같은 for 가 또 만나 한 프레임 안에서 끝없이 돕니다.

    그래서 Yield 루프는 정확히 프레임당 한 번 돕니다. 하나가 예외를 던져도 로그만 남기고 다음으로 넘어갑니다. 항목 하나를 깨우는 값은 개발 빌드 플레이어에서 Mono 약 0.036 µs, IL2CPP 약 0.076 µs 였습니다.

    러너는 매 프레임 묻고, 순서는 지키지 않는다

    Delay, WaitUntil 처럼 “아직인가” 를 물어야 하는 대기 객체는 러너에 들어갑니다. 러너는 프레임마다 항목마다 MoveNext() 를 한 번 부르고, 끝난(false) 자리는 맨 뒤 항목을 당겨 메웁니다. 배열 복사가 없는 대신 순서가 섞입니다.

    // PlayerLoopRunner.RunCore (줄인 코드)
    var j = tail - 1;                                  // j 는 맨 뒤 항목을 가리킨다
    
    for (int i = 0; i < loopItems.Length; i++)         // i 는 앞에서부터 간다
    {
        if (loopItems[i] != null)
        {
            if (loopItems[i].MoveNext())
                continue;                              // 아직 안 끝났다. 그대로 두고 다음 칸으로
    
            loopItems[i] = null;                       // 끝났다. i 번 칸이 구멍이 된다
        }
    
        // i 번 칸이 구멍이다. 맨 뒤에서 살아 있는 항목을 찾아 메운다
        while (i < j)
        {
            var fromTail = loopItems[j];
    
            if (fromTail != null && fromTail.MoveNext())   // 뒤 항목의 이번 프레임 MoveNext 를 여기서 부른다
            {
                loopItems[i] = fromTail;               // 아직 안 끝났으면 구멍으로 옮긴다
                loopItems[j--] = null;
                goto NEXT;
            }
    
            loopItems[j--] = null;                     // 뒤 항목도 끝났으면 버리고 그 앞을 본다
        }
    
        tail = i;                                      // i 와 j 가 만났다. 살아 있는 것은 i 앞까지다
        break;
    
        NEXT: ;
    }

    [A, B, C, D, E] 다섯이 들어 있고 이번 프레임에 B, D 가 끝난다고 하면 이렇게 돕니다.

    1. A 는 아직이라 그대로 둡니다.
    2. B 가 끝나 1 번 칸이 구멍이 됩니다. 맨 뒤 E 의 MoveNext() 를 부르고, E 가 아직이라 1 번 칸으로 옮깁니다. 배열은 [A, E, C, D, _] 가 됩니다.
    3. C 는 아직이라 그대로 둡니다.
    4. D 가 끝나 3 번 칸이 구멍이 됩니다. 뒤에 남은 항목이 없어 tail 을 3 으로 줄이고 끝냅니다. 배열은 [A, E, C] 가 됩니다.

    다섯 모두 MoveNext() 가 한 번씩 불렸고, 배열을 당겨 복사하지 않았습니다. 대신 E 가 C 앞으로 왔습니다.

    다음 프레임에 A, C, E 가 함께 끝나게 했더니 이어지는 코드는 A, C, E 순서였습니다. 뒤에서부터 메우며 한 번 더 뒤집힌 우연이라, 끝나는 프레임이 조금만 달라져도 바뀝니다. 같은 자리에서 깨어나는 작업들의 순서는 정해져 있지 않습니다.

    에디터에서는 플레이마다 러너를 새로 만든다

    도메인 리로드를 끄면 정적 필드가 플레이 사이에 남습니다. UniTask 는 이때 플레이에 들어갈 때마다 러너와 큐를 새로 만들어 다시 꽂습니다. 꽂기 전에 같은 타입을 걷어 내므로 두 벌이 되지 않습니다.

    끊기는 것은 러너에 걸린 대기 작업까지입니다. 풀 같은 다른 정적 상태와 사용자 코드의 정적 필드는 다음 플레이로 넘어갑니다.

    플레이하지 않을 때는 에디터 갱신(EditorApplication.update)이 열여섯 자리의 큐와 러너를 한꺼번에 돌립니다. 타이밍 구분이 사라지고, Delay 는 DelayType.Realtime 으로 바뀝니다.

    주의할 점

    1. 같은 프레임에 끝나는 작업의 순서에 기대지 않는다

    안 지키면 생기는 문제. 러너가 끝난 자리를 뒤의 항목으로 메워서, A, B 순서로 건 Delay 가 같은 프레임에 끝나면 B 가 먼저 이어질 수 있습니다. 순서에 기대는 초기화 코드가 가끔 틀립니다.

    이렇게 쓴다. 순서가 필요하면 한 async 메서드 안에서 차례로 await 합니다.

    2. 직접 만든 IPlayerLoopItem 의 MoveNext 는 던지지 않게 한다

    안 지키면 생기는 문제. 러너는 MoveNext() 에서 난 예외를 로그로만 남기고 그 항목을 뺍니다. 완료가 불리지 않았으니 그것을 기다리던 await 는 영영 깨어나지 않습니다.

    이렇게 쓴다. MoveNext() 본문을 try/catch 로 감싸고, 잡은 예외는 core.TrySetException(ex) 로 넘긴 뒤 false 를 돌려줍니다.

    3. 삽입 지점을 줄였으면 빠진 타이밍을 쓰지 않는다

    안 지키면 생기는 문제. 자리를 줄이려면 직접 다시 초기화합니다.

    var loop = PlayerLoop.GetCurrentPlayerLoop();
    PlayerLoopHelper.Initialize(ref loop, InjectPlayerLoopTimings.Minimum);   // Update | FixedUpdate | LastPostLateUpdate

    빠진 타이밍을 누가 쓰면(UniTask.Yield(PlayerLoopTiming.PreLateUpdate) 등) Target playerLoopTiming is not injected 예외로 멈춥니다. 아끼는 양은 대기 작업이 없는 프레임에서 개발 빌드 플레이어 Mono 7.0 µs, IL2CPP 3.7 µs 뿐입니다.

    이렇게 쓴다. 기본값 All 로 둡니다. 줄일 때는 프로젝트 전체에서 쓰는 타이밍을 찾아본 뒤 그것이 든 조합을 고릅니다.

    정리

    UniTask 의 await 뒤 코드는 PlayerLoop 열여섯 자리에 꽂아 둔 큐와 러너가 메인 스레드에서 이어 주고, 언제 깨어나는지는 고른 타이밍이 정합니다.

    • 자리 잡기는 첫 씬보다 먼저라서 Awake 에서도 바로 await 할 수 있습니다.
    • 큐는 Yield 를 프레임당 한 번 부르고, 러너는 매 프레임 MoveNext() 를 묻되 순서는 지키지 않습니다.
    • 도메인 리로드를 끄면 플레이마다 러너를 새로 만들고, 에디트 모드에서는 에디터 갱신이 전부를 한꺼번에 돌립니다.