R3 의 값이 오는 스레드와 프레임은 소스가 정한다
Esc

    R3 의 값이 오는 스레드와 프레임은 소스가 정한다

    2026년 9월 25일 수정–UnityRx라이브러리

    흔히 이렇게 기대합니다.

    • SubscribeOnThreadPool() 을 붙이면 구독자도 백그라운드에서 돈다
    • ObserveOnMainThread() 는 한 프레임 늦게 온다
    • Observable.Timer(1초) 는 1초 뒤에 온다

    재보니 셋 다 달랐습니다. R3 에서 값이 어느 스레드, 어느 프레임에 오는지는 연산자 이름이 아니라 소스와 PlayerLoop 러너가 정합니다.

    SubscribeOn 은 구독 호출 한 번만 옮긴다

    // R3/Operators/SubscribeOn.cs
    public void Execute()
    {
        disposable.Disposable = source.Subscribe(this);   // 스레드풀로 옮겨진 것은 이 한 줄뿐
    }
    
    protected override void OnNextCore(T value)
    {
        observer.OnNext(value);                           // 스레드를 바꾸지 않고 통과시킨다
    }

    그래서 같은 연산자를 붙여도 소스에 따라 결과가 정반대입니다.

    SubscribeOn 은 “조립을 누가 하느냐” 를, ObserveOn 은 “배달을 누가 하느냐” 를 바꿉니다. 소스가 조립하면서 배달까지 끝내면 둘이 같이 끌려갑니다. 그래도 동기 블로킹 소스에는 결정적입니다. Thread.Sleep(2000) 을 품은 소스를 Update() 에서 구독하면 붙이지 않았을 때 한 프레임이 2.042초로 튀고, 붙이면 그 2초 동안 930프레임이 렌더됐습니다.

    ObserveOnMainThread 는 다음 러너 차례에 배달한다

    지연의 단위는 프레임이 아니라 다음 러너 차례입니다. R3 러너는 Update 단계의 맨 앞에 있습니다.

    발행 위치 발행 프레임 수신 프레임
    Start() 1 1 (같은 프레임)
    Update() 60 61 (다음 프레임)

    어디서 발행했느냐에 따라 0프레임 또는 1프레임 늦습니다.

    Timer 는 프레임 델타를 더해서 잰다

    유니티에서 시간축 타이머는 프레임 워크아이템입니다. R3.Unity 가 어셈블리 로드 직후 기본 시간 프로바이더를 UnityTimeProvider.Update 로 바꾸기 때문에, 인자 없는 Timer 와 Interval 은 스레드풀이 아니라 메인 스레드에서 돌고 Time.timeScale 을 탑니다. 그리고 절대 시각을 보지 않고 델타를 더합니다.

    // R3.Unity/Runtime/UnityTimeProvider.cs (FrameTimer)
    elapsed += GetDeltaTime();
    if (elapsed >= dt) { callback(state); elapsed = 0; }

    timeScale 을 0으로 두고 TimerFrame(60) 과 Timer(1초, UnityTimeProvider.UpdateIgnoreTimeScale) 을 같이 걸되 시작 프레임만 바꿨습니다.

    시작 그 프레임의 unscaledDeltaTime TimerFrame(60) Timer(1초, UpdateIgnoreTimeScale)
    1프레임 약 4.0초 60프레임 1프레임
    100프레임 0.0014초 160프레임 621프레임

    같이 건 기본 프로바이더의 Timer(1초) 는 timeScale 이 0이라 끝까지 발동하지 않았습니다. 긴 프레임 하나가 짧은 타이머를 첫 틱에 터뜨리고, elapsed 를 0으로 되돌리니 밀린 주기도 몰아 보내지 않습니다. 정밀도의 하한은 1프레임이고, 결정론이 필요하면 프레임축입니다.

    겹치는 비동기 처리는 AwaitOperation 이 정한다

    값 하나를 받아 await 가 걸린 일을 하려면 SubscribeAwait 를 쓰고, 앞 작업이 안 끝났는데 다음 값이 오면 어떻게 할지를 AwaitOperation 으로 고릅니다.

    button.OnClickAsObservable()
        .SubscribeAwait(async (_, ct) =>
        {
            Debug.Log("시작");
            await Awaitable.WaitForSecondsAsync(1f, ct);   // 1초짜리 작업
            Debug.Log("끝");
        }, AwaitOperation.Drop)
        .AddTo(this);

    모드는 여섯입니다. 위 넷에 동시에 시작하되 결과는 들어온 순서대로 받는 SequentialParallel, 실행 중 들어온 값 중 첫 값과 마지막 값만 처리하는 ThrottleFirstLast 가 있습니다. 체인 중간에서 결과를 넘기려면 같은 모드를 받는 SelectAwait 를 씁니다.

    주의할 점

    1. SubscribeOn 을 붙였다고 구독자가 백그라운드에서 돈다고 가정하지 않는다

    안 지키면 생기는 문제. EveryUpdate 에 붙이면 아무 효과가 없고, Range 처럼 구독 도중에 값을 다 뱉는 소스에 붙이면 값 처리까지 백그라운드로 끌려갑니다. 뒤쪽에서 유니티 API 를 부르면 메인 스레드가 아니라는 예외가 납니다.

    이렇게 쓴다. 무거운 동기 소스를 메인에서 빼낼 때만 SubscribeOnThreadPool() 을 붙이고 그 뒤에 ObserveOnMainThread() 로 결과를 되돌립니다. 어느 스레드인지 헷갈리면 Debug.Log 스택의 맨 아래 줄을 봅니다(UnityFrameProvider:Run 이면 메인, _ThreadPoolWaitCallback 이면 백그라운드).

    2. 일시정지 중에도 돌아야 하는 타이머는 시계를 바꿔서 건다

    안 지키면 생기는 문제. 인자 없는 Timer 와 Interval 은 Time.timeScale 을 탑니다. 일시정지 메뉴에서 timeScale 을 0으로 두면 메뉴의 연출 타이머까지 멈춥니다.

    이렇게 쓴다. UnityTimeProvider.UpdateIgnoreTimeScale 을 인자로 넘깁니다. 게임 시간과 무관해야 하는 서버 하트비트 같은 것은 UnityTimeProvider.UpdateRealtime 입니다.

    3. 로딩 직후에 짧은 시간축 타이머를 걸지 않는다

    안 지키면 생기는 문제. 씬 로드 직후나 알트탭 복귀 직후처럼 한 프레임이 몇 초씩 걸리면 짧은 타이머가 첫 틱에 바로 터집니다. 10초 자리를 비웠다 돌아와도 Interval(0.1초) 는 한 번만 발동합니다.

    이렇게 쓴다. 로딩이 끝나고 프레임이 안정된 뒤에 구독합니다. 발동 시점이 매번 같아야 하면 TimerFrame 같은 프레임축을 씁니다.

    4. SubscribeAwait 안에서는 받은 토큰을 끝까지 넘긴다

    안 지키면 생기는 문제. Switch 는 토큰을 취소해서 이전 작업을 멈춥니다. 람다 안에서 ct 를 넘기지 않으면 이전 작업이 끝까지 돌아 Parallel 과 같은 결과가 됩니다. Sequential 은 아무것도 버리지 않아 연타가 쌓이면 작업이 계속 밀립니다.

    이렇게 쓴다. 람다가 받은 ct 를 안에서 부르는 모든 비동기 메서드에 넘깁니다. 모드는 “밀려도 되는가, 버려도 되는가” 로 고릅니다.

    정리

    R3 에서 값이 오는 스레드와 프레임은 소스와 PlayerLoop 러너가 정합니다.

    • SubscribeOn 은 구독 호출만 옮깁니다. 값의 스레드는 소스가 정하고, 동기 블로킹 소스에서만 결정적입니다.
    • ObserveOnMainThread 는 다음 러너 차례라 발행 위치에 따라 0 또는 1프레임 늦고, Timer 는 델타를 더해서 긴 프레임에 즉시 터집니다.
    • 겹치는 비동기 처리는 AwaitOperation 으로 쌓을지, 버릴지, 취소할지, 같이 돌릴지 고릅니다.