R3 의 값이 오는 스레드와 프레임은 소스가 정한다
흔히 이렇게 기대합니다.
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으로 쌓을지, 버릴지, 취소할지, 같이 돌릴지 고릅니다.