UniTask 는 완료와 이어 부르기를 딱 한 번씩만 한다
Esc

    UniTask 는 완료와 이어 부르기를 딱 한 번씩만 한다

    2026년 10월 4일 수정–Unity라이브러리동시성
    await UniTask.Delay(1000, cancellationToken: ct);   // 1초가 차는 그 프레임에 ct 도 취소되면?

    완료가 두 번 들어올 수 있습니다. 완료가 워커 스레드에서 오는 순간 메인 스레드가 막 await 를 등록하고 있을 수도 있습니다. 그러면 await 뒤 코드가 두 번 돌거나, 한 번도 안 돌 수 있습니다.

    UniTask 는 이 둘을 완료 상태를 담은 구조체 하나로 막습니다. 완료는 먼저 온 하나만 받고, 이어질 코드는 누가 먼저 오든 정확히 한 번 부릅니다. lock 없이 원자적 연산만 쓰고, 할당도 없습니다. 기준 판본은 2.5.11 입니다.

    완료는 먼저 온 하나만 받는다

    TrySetResult, TrySetException, TrySetCanceled 는 모두 같은 줄로 시작합니다.

    // Runtime/UniTaskCompletionSource.cs (줄임)
    public bool TrySetResult(TResult result)
    {
        if (Interlocked.Increment(ref completedCount) == 1)   // 동시에 불러도 1 은 한 쪽만 받는다
        {
            this.result = result;
            // 이어질 코드 처리 (다음 절)
            return true;
        }
        return false;                                          // 늦게 온 완료는 버린다
    }

    Interlocked.Increment 는 여러 스레드가 동시에 불러도 각자 다른 값을 돌려줍니다. 1 을 받은 쪽만 결과를 적습니다. 그래서 시간 만료와 취소가 같은 프레임에 겹쳐도 결과가 두 번 적히거나 성공과 취소가 섞이지 않습니다. 값도 쌉니다. 에디터에서 Interlocked.Increment 두 번이 3.1 ns, 빈 lock 한 번이 15.5 ns 였습니다.

    이어질 코드는 누가 먼저 오든 한 번만 불린다

    await 는 아직 안 끝났으면 “끝나면 이걸 불러 줘” 하고 이어질 코드를 continuation 칸에 맡깁니다. 완료하는 쪽은 그 칸에 무언가 있으면 부릅니다. 둘이 다른 스레드에서 동시에 오면 문제가 됩니다.

    UniTask 는 둘 다 칸이 비어 있을 때만 자기 것을 꽂게 합니다(Interlocked.CompareExchange). 한 칸을 두고 겨루니 먼저 닿은 쪽만 성공하고, 진 쪽이 이긴 쪽의 값을 보고 이어질 코드를 부릅니다. 완료하는 쪽이 먼저 오면 “끝났는데 기다리는 사람이 없었다” 는 표시값(센티널)을 꽂아 둡니다.

    기다리는 쪽은 아직 안 끝났을 때만 등록하러 갑니다.

    // 기다리는 쪽: await
    if (!awaiter.IsCompleted)
        core.OnCompleted(cont, state, token);

    등록은 칸이 비어 있을 때만 cont 를 꽂습니다. 이미 무언가 있으면 완료가 먼저 온 것이니 그 자리에서 직접 부릅니다.

    // OnCompleted 안
    object old = this.continuation;
    
    if (old == null)
        old = Interlocked.CompareExchange(ref this.continuation, cont, null);   // 비었으면 cont 를 꽂는다
    
    if (old != null)
    {
        // 센티널이 아니면 누가 이미 등록한 것이다 (두 번 await)
        if (old != s_sentinel)
            throw new InvalidOperationException(...);
    
        cont(state);   // 완료가 먼저 왔다. 등록한 쪽이 직접 부른다
    }

    완료하는 쪽도 칸이 비어 있을 때만 센티널을 꽂습니다. 이미 cont 가 있으면 그것을 부릅니다.

    // 완료하는 쪽: TrySetResult
    if (Interlocked.Increment(ref completedCount) == 1)
    {
        this.result = result;
    
        if (continuation != null ||
            Interlocked.CompareExchange(ref continuation, s_sentinel, null) != null)   // 비었으면 센티널을 꽂는다
        {
            continuation(continuationState);   // 등록이 먼저 왔다. 완료한 쪽이 부른다
        }
    }

    두 쪽이 오는 순서에 따라 칸에 남는 값과 cont 를 부르는 쪽이 정해집니다.

    경우 칸에 먼저 꽂힌 것 cont 를 부르는 쪽 await 다음 줄이 도는 스레드
    A. 등록이 먼저 끝남 cont 완료하는 쪽 완료한 스레드
    B. 완료가 먼저 끝남 센티널 없음. IsCompleted 가 참이라 등록을 건너뜀 기다리던 스레드
    C.1 동시에 왔고 등록이 이김 cont 완료하는 쪽 완료한 스레드
    C.2 동시에 왔고 완료가 이김 센티널 등록하는 쪽 등록한 스레드
    • 등록이 먼저면 완료하는 쪽이 부릅니다(A, C.1).
    • 완료가 먼저면 등록하는 쪽이 센티널을 보고 그 자리에서 직접 부릅니다(B, C.2).

    어느 쪽이든 한 번입니다. 다만 누가 부르느냐에 따라 await 다음 줄이 도는 스레드가 달라집니다. C.1 에서는 완료한 스레드에서 이어집니다.

    이 모든 것이 구조체 하나라서 할당이 없다

    완료 상태(결과, 예외, 완료 횟수, 이어질 코드)는 UniTaskCompletionSourceCore<TResult> 구조체 하나에 있습니다. Delay, Yield, async 메서드의 러너 같은 대기 객체가 모두 이것을 필드로 품습니다. 상태를 지키려고 따로 만드는 객체가 없습니다.

    완료 한 번(만들고, 완료하고, 결과 꺼내기)을 백만 번씩 잰 중앙값입니다(에디터 Mono, Release 최적화).

    한 주기 시간 할당
    AutoResetUniTaskCompletionSource (풀 사용) 71.1 ns 0 B
    TaskCompletionSource<bool> 135.6 ns 104 B

    개발 빌드 플레이어에서도 Mono 67.5 ns, IL2CPP 57.8 ns 에 0 B 였습니다.

    단, 0 B 는 성공으로 끝날 때입니다. 취소나 예외로 끝내면 예외 객체를 만들고 던지는 값이 붙습니다.

    완료 방식 Mono IL2CPP
    TrySetResult 0 B 0 B
    TrySetCanceled() 288 B 368 B
    TrySetException(일반 예외) 약 9.3 KB 약 9.7 KB

    주의할 점

    1. 늦게 온 완료는 버려진다

    안 지키면 생기는 문제. 시간 만료와 취소, 또는 두 콜백이 같은 소스를 완료하려 하면 먼저 온 하나만 남습니다. 늦은 쪽은 예외 없이 false 만 받고 끝나므로, 그쪽이 기록하려던 결과나 로그는 조용히 사라집니다.

    이렇게 쓴다. 어느 쪽이 이겼는지 알아야 하면 TrySet* 의 반환값을 봅니다. false 면 이미 다른 쪽이 끝낸 것입니다.

    2. 워커 스레드에서 완료하면 await 다음 줄이 워커에서 돌 수 있다

    안 지키면 생기는 문제. UniTaskCompletionSource 를 워커 스레드에서 TrySetResult 하면, await 가 먼저 등록돼 있던 경우 이어질 코드를 그 워커가 부릅니다. await 다음 줄에서 유니티 API 를 부르면 메인 스레드가 아니라서 예외가 납니다.

    이렇게 쓴다. 워커에서 완료하는 소스를 기다렸다면 await 다음에 await UniTask.SwitchToMainThread(); 를 둡니다. 가능하면 완료 자체를 메인 스레드에서 합니다.

    3. 자주 실패하는 흐름을 예외로 전하지 않는다

    안 지키면 생기는 문제. 성공 완료는 0 B 지만 예외로 완료하면 한 번에 9 KB 남짓, 취소도 수백 B 가 듭니다. “없으면 실패” 같은 흔한 결과를 예외로 전하면 그만큼 매번 GC 에 쌓입니다.

    이렇게 쓴다. 자주 일어나는 실패는 UniTask<(bool ok, T value)> 처럼 결과 값으로 돌려주고, 예외는 정말 예외적인 경우에만 씁니다.

    정리

    UniTask 는 완료와 이어 부르기를 딱 한 번씩만 합니다.

    • 완료는 Interlocked.Increment 로 먼저 온 하나만 받습니다.
    • 이어질 코드는 continuation 칸을 두고 CAS 로 겨뤄, 누가 먼저 오든 한 번만 불립니다. 대신 부르는 스레드는 경우에 따라 다릅니다.
    • 이 상태가 구조체 하나라 성공 완료는 0 B 입니다. 예외로 끝내면 값이 붙습니다.