UniTask 는 깨운 쪽 스레드에서 이어진다
Esc

    UniTask 는 깨운 쪽 스레드에서 이어진다

    2026년 10월 4일 수정–Unity라이브러리동시성
    async UniTask<Mesh> BuildMeshAsync(CancellationToken ct)
    {
        await UniTask.SwitchToThreadPool();      // 여기서부터 워커 스레드
        var verts = HeavyCalculation();
        await UniTask.SwitchToMainThread(ct);    // 여기서부터 다시 메인 스레드
    
        var mesh = new Mesh();
        mesh.SetVertices(verts);
        return mesh;
    }

    Task 는 await 가 끝나면 유니티의 동기화 컨텍스트가 메인 스레드로 돌려줍니다. UniTask 로 옮기면서 세 번째 줄을 빼먹으면 new Mesh() 가 워커 스레드에서 불립니다.

    UniTask 에는 되돌려 주는 장치가 없어서, await 다음 줄은 완료를 알린 쪽이 부르는 스레드에서 돕니다. 메인으로 돌아갈 자리는 코드에 직접 적고, 예외로 빠져나가는 길에도 적어야 합니다.

    깨운 쪽이 스레드를 정한다

    await 는 아직 안 끝났으면 이어서 할 일(continuation)을 awaiter 에게 넘기고 빠져나옵니다. awaiter 가 그것을 어느 스레드에서 부르느냐가 곧 await 다음 줄의 스레드입니다. 이미 끝났으면(IsCompleted 가 true) 넘기지 않고 그 자리에서 이어 갑니다.

    스레드를 옮기는 awaitable 은 이 두 지점만 바꿉니다. 모두 구조체라 할당이 없습니다.

    Awaitable IsCompleted continuation 을 넘기는 곳
    SwitchToMainThread 지금 메인이면 true 지정한 PlayerLoop 타이밍의 큐
    SwitchToThreadPool 항상 false ThreadPool.UnsafeQueueUserWorkItem
    SwitchToSynchronizationContext 항상 false 지정한 컨텍스트의 Post

    SwitchToMainThread 는 이미 메인이면 await 자체가 사라져, 습관처럼 넣어도 손해가 없습니다. 대신 그때는 타이밍 인자도 무시됩니다. SwitchToThreadPool 은 이미 워커여도 한 번 더 큐에 넣고, 취소 토큰을 받지 않습니다.

    예외로 빠져나가는 길에도 돌아올 자리를 적는다

    맨 위 코드를 따라갑니다. 메인 스레드를 1, 워커를 6 으로 잡았습니다.

    // SwitchToThreadPoolAwaitable.Awaiter
    public bool IsCompleted => false;                      // 항상 false. 반드시 넘어간다
    
    public void UnsafeOnCompleted(Action continuation)
        => ThreadPool.UnsafeQueueUserWorkItem(switchToCallback, continuation);   // 이어질 코드를 스레드 풀 큐에 넣는다
    
    static void Callback(object state) => ((Action)state)();   // 워커가 이어질 코드를 부른다
    
    // SwitchToMainThreadAwaitable.Awaiter
    public bool IsCompleted => PlayerLoopHelper.MainThreadId == Thread.CurrentThread.ManagedThreadId;   // 이미 메인이면 넘어가지 않는다
    
    public void UnsafeOnCompleted(Action continuation)
        => PlayerLoopHelper.AddContinuation(playerLoopTiming, continuation);     // 이어질 코드를 그 타이밍의 큐에 넣는다
    
    public void GetResult() { cancellationToken.ThrowIfCancellationRequested(); }
    
    // AsyncUniTaskMethodBuilder<T>
    public void SetException(Exception exception)
    {
        if (runnerPromise == null)
            ex = exception;
        else
            runnerPromise.SetException(exception);         // 기다리던 호출자를 그 자리에서 깨운다
    }

    정상 경로는 이렇게 갑니다.

    1. 메인(스레드 1)에서 SwitchToThreadPool 을 await 합니다. 이어질 코드가 스레드 풀 큐에 들어가고 메서드는 빠져나갑니다.
    2. 워커(스레드 6)가 이어질 코드를 부릅니다. 깨운 쪽이 워커이니 다음 줄도 워커에서 돕니다. 메인은 그동안 자기 프레임을 계속 돕니다.
    3. 워커에서 SwitchToMainThread 를 await 합니다. 지금 스레드가 메인이 아니라 이어질 코드가 Update 큐에 들어가고 워커는 빠져나갑니다.
    4. 다음 Update 에서 PlayerLoop 가 큐를 비우며 이어질 코드를 부릅니다. 깨운 쪽이 메인이니 여기부터 유니티 API 를 불러도 됩니다.

    워커 구간에서 예외가 나면 3 번에 닿지 못합니다. 상태 머신이 예외를 잡아 SetException 을 부르고, 러너가 기다리던 호출자를 그 자리에서 깨웁니다. 깨우는 스레드가 워커이니 호출자의 catch 도 워커에서 돕니다.

    완료 원천은 완료 신호를 받는 그 자리에서 continuation 을 부릅니다. 그래서 워커에서 난 예외는 호출자를 워커에서 깨웁니다.

    개발 빌드 플레이어에서 재 보니 메인이 1 번일 때 호출자의 catch 가 Mono 9 번, IL2CPP 10 번 스레드에서 돌았습니다. 에디터에서는 1 번이었습니다. 워커가 너무 빨리 던져 호출자가 await 할 때 이미 끝나 있었기 때문입니다. catch 의 스레드는 워커가 언제 던지느냐에 달려 있습니다.

    어느 쪽이든 되도록 예외 경로에도 복귀를 적습니다. 가장 짧은 방법이 ReturnToMainThread 입니다.

    await using (UniTask.ReturnToMainThread())
    {
        await UniTask.SwitchToThreadPool();
        HeavyWork();
        if (failed) return;       // 어디로 빠져나가든
    }                             // 블록 끝에서 메인으로 돌아온다

    블록 안은 워커, 블록 뒤와 블록 안에서 return 한 호출자는 메인(1)에서 이어졌습니다. 일반 using 은 CS1674 로 컴파일되지 않습니다.

    워커에서 SwitchToMainThread 로 돌아오는 데는 60 fps 플레이어에서 평균 16.3~16.6 ms, 곧 정확히 한 프레임이 걸렸습니다. 다음에 오는 그 타이밍의 큐를 기다리기 때문입니다.

    유니티 비동기 작업은 이미 메인에서 깨운다

    Resources.LoadAsync, SceneManager.LoadSceneAsync 같은 작업은 유니티 엔진이 메인 스레드에서 끝을 알립니다. 돌아올 자리를 적을 필요가 없습니다.

    await op;                          // completed 이벤트만. 취소 불가, WhenAll 에 못 넣음
    await op.WithCancellation(ct);     // 이벤트 + PlayerLoop 폴링. 취소 가능
    await op.ToUniTask(progress, timing, ct);

    고르는 기준은 스레드가 아니라 취소와 조합입니다. Resources.LoadAsync 1회당 할당은 세 방식 모두 UniTask 가 더한 몫이 0 이었습니다.

    코루틴을 await 하면 UniTask 가 깨우는 쪽이 된다

    await SomeCoroutine() 은 StartCoroutine 을 부르지 않습니다. UniTask 가 PlayerLoop 에서 직접 MoveNext() 를 돌리고, yield return 값을 스스로 해석합니다. 그래서 언제 깨우는지도 UniTask 의 해석을 따릅니다.

    while (enumerator.MoveNext())
    {
        var current = enumerator.Current;       // 코루틴이 yield return 한 값
    
        if (current == null)
        {
            yield return null;                  // 한 프레임 쉰다
        }
        else if (current is CustomYieldInstruction cyi)
        {
            while (cyi.keepWaiting)
                yield return null;
        }
        else if (current is WaitForSeconds wfs)
        {
            // 초를 읽는 공개 API 가 없어 private 필드 m_Seconds 를 리플렉션으로 꺼낸다
            // 매 프레임 deltaTime 을 더하다가 그 초를 넘으면 빠져나온다
        }
        else if (current is AsyncOperation ao)
        {
            // isDone 이 될 때까지 yield return null
        }
        else if (current is IEnumerator e)
        {
            // 같은 해석을 재귀로
        }
        else
        {
            // 모르는 타입. 경고를 남기고 한 프레임만 쉰다
            Debug.LogWarning("... is not supported ...");
            yield return null;
        }
    }

    WaitForSeconds(0.2f) 는 아는 타입이라 원래와 같은 때 깨어납니다. WaitForEndOfFrame 은 어느 분기에도 걸리지 않아 마지막 else 로 갑니다. 예외는 없고 경고가 한 번 남으며, 프레임 끝이 아니라 다음 프레임 Update 에서 깨어납니다. 타이밍이 조용히 틀어집니다.

    모르는 타입은 경고만 남기고 다음 프레임 Update 로 넘깁니다. ToUniTask(this) 를 쓰면 진짜 코루틴 엔진이 돌리므로 모든 YieldInstruction 이 원래대로 동작합니다.

    얻는 것도 있습니다. UniTask 가 MoveNext() 를 try 로 감싸 부르므로, 코루틴 안의 예외가 await 한 쪽의 try/catch 로 올라옵니다. 반대 방향 uniTask.ToCoroutine() 은 StartCoroutine 에 넘길 IEnumerator 를 만듭니다.

    주의할 점

    1. 워커 구간은 예외가 나도 메인으로 돌아오게 짠다

    안 지키면 생기는 문제. 워커 구간에서 예외가 나면 호출자의 catch 가 워커에서 돌 수 있습니다. 거기서 오류 UI 를 띄우면 유니티 API 를 워커에서 부르게 됩니다.

    이렇게 쓴다. 워커 구간을 await using (UniTask.ReturnToMainThread()) 로 감싸거나 finally { await UniTask.SwitchToMainThread(); } 를 둡니다.

    2. 타이밍을 미루려면 SwitchToMainThread 가 아니라 Yield 를 쓴다

    안 지키면 생기는 문제. 메인에서 SwitchToMainThread(PlayerLoopTiming.LastPostLateUpdate) 를 부르면 IsCompleted 가 true 라 그 자리에서 진행합니다. 프레임 끝까지 미룬다는 의도가 조용히 무시됩니다.

    이렇게 쓴다. 스레드 복귀는 SwitchToMainThread, 타이밍 이동은 await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate) 입니다. Yield 는 메인에서도 항상 큐에 넣습니다.

    3. WaitForEndOfFrame 이 든 코루틴은 ToUniTask(this) 로 돌린다

    안 지키면 생기는 문제. await OldCoroutine() 은 WaitForEndOfFrame, WaitForFixedUpdate, 중첩 Coroutine 을 해석하지 못해 다음 프레임 Update 로 넘깁니다. 스크린샷 캡처나 물리 동기화 코드가 예외 없이 틀린 타이밍에 돕니다.

    이렇게 쓴다. 그런 코루틴은 await OldCoroutine().ToUniTask(this) 로 돌립니다. 콘솔에 is not supported on await IEnumerator 경고가 없는지 확인합니다.

    정리

    UniTask 는 await 다음 줄을 깨운 쪽 스레드에서 이어 갑니다. 메인으로 돌아갈 자리는 코드에 직접 적습니다.

    • SwitchToMainThread 는 이미 메인이면 사라지고 타이밍 인자도 무시됩니다. 워커에서는 한 프레임 뒤에 돌아옵니다.
    • 워커에서 난 예외는 호출자를 워커에서 깨울 수 있어, await using (UniTask.ReturnToMainThread()) 로 복귀를 보장합니다.
    • 코루틴을 await 하면 UniTask 가 yield 값을 해석해, 모르는 타입은 다음 프레임 Update 로 바뀝니다.