ReactiveX/rxjs

Why doesn't a hot observable stay hot?

jhoguet opened this issue · 6 comments

Why doesn't a hot observable stay hot?

For instance...

const s = new Rx.Subject();

o = s.map(i => {
  console.log('side effect ' + i)
  return i * 2;
});

o.subscribe(v => console.log(v));
o.subscribe(v => console.log(v));

s.next(2)

outputs

side effect 2
4
side effect 2
4

But this is not what I would expect. I can fix this by using o.share()

But why would I need to do that? Given that the Subject is pushing the same input, I should get the same output to functions like map. Isn't this the whole point of shifting to an RFP approach? From my point of view this only benefits those that are causing side effects in map because otherwise they would never know or care. Given that you have an operator like do we could preserve the current default by making do be the point at which the observable stops sharing.

I suspect there is either a fundamental RFP reason for this which I have not learned yet, or this is incidental complexity caused by some implementation detail. If it is the latter I would love to find a way to fix that before 5 comes out of beta.

Moved the question from #1121 (comment)

@trxcllnt provided some great feedback that the .map is producing a new cold observable here

This is not what I would expect. I would not expect two subscribers to get different values from the same map of the same Subject. Therefore the computation of the map should be memoized / only happen once for all subscribers.

Because this isn't true, I need to re-coerce the Observable back to hot everytime I perform an operation on it by calling share on it.

While this doesn't seem terrible in one module

const s = new Rx.Subject();

o = s.map(i => i * 2 }).share();

it starts to smell like a leaking concern when the operations are happening across modules.

function doubleIt(stream){
    return stream.map(i => i*2);
}

It adds to the cognitive overhead of using RxJS as I need to track which observables are hot or cold or sacrifice performance by recomputing unnecessarily.

Why doesn't a hot observable stay hot?

PS - I have been watching the hard work you guys are doing and am very thankful!

Well, the hot observable did stay hot, it's just that there's a cold observable returned by map that is subscribing to it under the hood.

If you don't want the projection function from map to run twice, you'd share it afterwards: s.map(whatever).share().

Why would you ever want the projection function from map to run more than once for a single emission from s?

@jhoguet because, 1. Observables are decoupled, such that map doesn't know anything about its source, and 2. subscriptions to cold Observables are executed in isolated memory contexts.

Observables are a language-agnostic push-implementation of Haskell's (potentially infinite) lists. An Observable is a monad, aka a computation that you can compose with map, concat, reduce, and flatMap. You compose the Observable computation once, but you can execute the computation any number of times.

Most operator Observables (including map) do pretty much the same thing every time you subscribe to them:

  1. Create the operator's special Subscriber type
  2. Subscribe to the operator's source with this Subscriber instance

The question, "why don't hot Observables multicast every operator?" is fair, but the answer is mundane: it's a classic space-time trade-off.

Multicasting every operator requires allocating memory to track multiple Subscribers for each operator. Most operators won't have multiple subscribers, but there's no way to guarantee that, and thus no safe optimization we can make for that. Resubscribing to the source is the cheaper option (and only grows with your subscription).

Why would you ever want the projection function from map to run more than once for a single emission from s?

Simple. Consider s to be clicks, a Hot Observable of clicks.

Then

cold = clicks.map(() => Math.random());

cold.subscribe(console.log); // 3, 70, 28, 58, ...
cold.subscribe(console.log); // 62, 81, 4, 9, ...

There are applications for that. Say you want to make a game, and each subscription is a toss of the die. Multiple subscriptions mean you have many dice. The dice are tossed at each click.

Usually the mapping is a pure function, though, so it always gives the same output:

cold = hotNumbers.map(x => 2 * i);

hot.subscribe(console.log); // 1, 2, 3, 4, ...
cold.subscribe(console.log); // 2, 4, 6, 8, ...
cold.subscribe(console.log); // 2, 4, 6, 8, ...
// cold looks hot because same events happen at the same time

The point of cold-by-default is: one-observer-per-observable is cheaper (this confirms what Paul just said above about the tradeoff). So many-observers-per-observables is an opt-in. You usually don't need hot, but when you need it, you are in control, you choose to be hot.

Thank you @staltz @Blesh and @trxcllnt

In summary, operators create new observables instead of sharing the subscription to the hot observable for performance reasons. This is desired because a hot observable being subscribed to more than once is rare.

My limited experience has not been consistent with this. I have a lot of code that is having to call share to prevent my flatMapLatest to ajax calls from firing for every subscriber. I'll keep tinkering to try and figure out what I am doing wrong - meanwhile I'll close this.

Thanks again for considering this request.

lock commented

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.