-
Notifications
You must be signed in to change notification settings - Fork 90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Discussion: Multicast vs. Unicast #66
Comments
I'm a strong proponent of RxJS, and I'm comfortable with the concept of hot (multicast) and cold (unicast) observables. I know there are valid use cases for both of these, but like @bripkens, I think multicast makes more sense as a default than unicast does. It is more common to require an observable to be shared than it is to require strict non-shared executions of that observable. If you make no assumption on how consumers want to use an observable, it is safer to choose to multicast the producer/observable. Usually consumption requirements force us to change the producer to multicast, and that's not a good separation of concerns. |
Perhaps those with more direct experience with Rx would like to comment? @Blesh @jhusain I would like to point out that we should be able to support "multicast chains" via subclassing. Here's a sketch which should illustrate the general idea: class MulticastForever extends Observable {
constructor(subscriber) {
// Maintain a list of subscribers and the source subscription
this._listeners = new Map();
this._subscription = null;
super(subscriber);
}
subscribe(observer) {
let sub = this._listeners.get(observer);
if (this._subscription && sub)
return sub;
sub = {
unsubscribe() {
this._listeners.remove(observer);
if (this._listeners.size === 0)
this._subscription.unsubscribe();
}
};
this._listeners.set(observer, sub);
if (!this._subscription) {
this._subscription = super.subscribe({
next(v) {
// TODO: Error handling
this._listeners.forEach(observer => observer.next(v));
},
error(e) {
// TODO: Error handling
this._listeners.forEach(observer => observer.error(e));
},
complete(v) {
// TODO: Error handling
this._listeners.forEach(observer => observer.complete(v));
},
});
}
return sub;
}
} Because of the way subclassing works in ES6 (and es-observable), when you call |
Honestly, the majority of the time with Observables, all you want is Unicast. (Cold)... and in reality I don't think it should even be thought of as "Multicast" and "Unicast" but more in terms of: 1 Producer -> 1 Consumers or 1 Producer -> N Consumers. At the end of the day, an Observable is really a template for creating a Producer and pushing values to a Consumer. If a Producer already exists, you could wrap it in an Observable and it's magically 1 Producer -> N Consumers. Consider a shared web socket: var socket = new WebSocket('ws://echo.websocket.org');
var messages = new Observable(observer => {
socket.onmessage = ::observer.next;
socket.onclose = (e) => {
if(e.wasClean) {
observer.complete();
} else {
observer.error(e);
}
};
socket.onerror = ::observer.error;
}); In the above scenario, the socket is shared with all subscribers to the observable. That's really the simplest way to do it. When you're talking about multicast observables, you're talking about using observables in more advanced ways (in order to leverage the set up and tear down processes to do things like connect the websocket or close it cleanly). I do this all the time, but it's not completely necessary. |
Another thing to think about is Observables are analogous to Functions. Anything you can do with a Function you can do with an Observable. If Observables become multicast-only, that's not necessarily true. Functions, by default, execute some logic and return a value to a user, and/or create some side effects, once per call. Observables can model that. Functions could be written to share that logic per N calls, as can Observables. ... I read that and it looks like babbling, haha.. Does it make sense? |
TL;DR: They should say unicast/cold by default, IMO. |
Wait, does this mean that I'll need to create several observables for a dom event if I have multiple consumers for that event? I thought that Observables would be able to pipe into any number of consumers via the subscribe function. Am I missing something? |
Yes, technically an Observable is analogous to a function. So if your Observable calls In essence, what's the difference between: let counter = 0;
function alertEveryOther(e) {
if(counter++ % 2 === 0) alert(e);
}
function alertHi(e) {
alert('hi');
}
domElement.addEventListener('click', alertEveryOther);
domElement.addEventListener('click', alertHi); and... var clicks = new Observable(observer => {
var handler = ::observer.next;
domElement.addEventListener('click', handler);
return () => {
domElement.removeEventListener('click', handler);
};
});
clicks.filter((e, i) => i % 2 === 0).subscribe({ next(e) { alert(e) } });
clicks.map(e => 'hi').subscribe({ next(e) { alert(e) } }); It's really not going to be any more performant to maintain an extra array of consumers in JavaScript in the first example. Why would that be any different with the observable? The native DOM event wiring will handle "multicasting" for you. |
Interesting, I guess I just hadn't thought about it like that. In a module I made, streamemitter, when you subscribed to an event, you'd get back a readable stream which attached one listener on the actual event emitter. Then you could pipe that stream into whatever number of writable streams for processing. Though, I admit a lot of the time I did end up having a single writable stream per readable stream, having multiple consumers was also quite common. In your example, could you have something like
|
I disagree with @Blesh about the common use case - multicast is pretty common (but probably not as common as unicast). I think multicast is what people would expect in the first place. That said - I think unicast is a lower level primitive and there are APIs where it makes more sense. It should indeed be the default. That fact needs to be communicated very well since unlike languages like C# where a lot of APIs are lazy in JS all the APIs are strict which means you work with concrete values which are multicast a lot more often. Analogy to generators also indicates unicast since both iterators would re-run the generator. |
No I don't want. Unless you're talking about radically changing the way I write RxJS, which I don't want to change.
Assuming strictly 1 consumer by default is less generic than assuming many consumers, that's why I think multicast/hot by default is safer for the average case. |
The behavior is identical whether the Observer list is maintained at the tail of an Observable sequence, or further up in the chain, though the allocation count for the latter is higher (due to the extra Subscribers created for each of the unicast operators between edit:
I couldn't disagree more. |
... and how? |
So you're saying you
No way. If you make Observable multicast, then the Observable itself becomes stateful. That's super gross. Think about it, every Observable has a collection of subscribers it has to maintain? Assume this: let source = getSomeObservable();
let filtered1 = source.filter(inSomeWay);
let filtered2 = source.filter(inSomeOtherWay);
let mapped1 = filtered1.map(x => x + '!!!');
filtered1.subscribe(print);
filtered2.subscribe(print);
mapped1.subscribe(print); Everything is happy, right? Now what do you do if you want There is a reason in Rx that there are Observables and there are Subjects. Observables are better off as stateless templates for setting up a producer and pushing values to a observer provided by a consumer. |
Thanks @Blesh and @trxcllnt for providing input. It seems clear to me that unicast (the current spec) represents the core primitive, and that we can build multicast observables on top. It seems there is also potential here for controversy, which we of course want to avoid. @bripkens @staltz What do you think of the subclassing solution above? Those that want always-multicast semantics can use the subclass. |
FWIW: I used to think "why aren't these all multicast?"... I really, really did... until I started working so heavily with the type. So I've struggled to come to the mindset I have. (Further truth: when I first saw Rx I was like "why do we need another lodash or whatever?" LOL) |
+1
Unfortunately it is not identical to the behavior of Bacon and Kefir. See the following example which calls the http://jsbin.com/tivivesudi/1/edit?html,js,console
The applications we are writing at Instana may not be representative, but we found that the cost of maintaining one array per observable is negligible when compared to the processing overhead of multiple operator (
This is not meant to offend you, I am generally curious: In what situation do you need operators to be unicast? I am having trouble understanding the need for the following behavior: "Please make it look as if we can reuse intermediate processing steps, but actually incur the processing overhead per subscriber."
I'd be happy to see the requirements of all parties being satisfied. The sub classing approach still lacks support for multicast operators and a few other things though. If you would be interested to incorporate support for multicast and "unicast", I could provide more information about multicast usage patterns and help you out coming up with a base for discussion. |
|
Since your source is a Subject, and none of the intermediate operators are maintaining state, publishing the filtered Observable is unnecessary. Additionally, given the new |
By "multicast operators", do you mean multicast versions of "map", "filter", etc? The way that this spec is currently written, Take a look at https://github.com/zenparsing/es-observable/blob/master/src/Observable.js#L401 to see how that works. Basically, it consults |
I am not sure I understand how |
Without |
@zenparsing: Cool, didn't know that! I have a set of test cases ready for our custom multicast implementation and I could translate them to es-observable. It would probably be a good idea to come up with another name though. Multicast may not be the most accurate description of what we want to achieve? |
Scan doesn't emit values, it encapsulates the logic to perform a computation with state. This example (correctly) prints different sums: const randSum = Observable.range(0, 10)
.map(_ => Math.random())
.reduce((sum, rand) => sum + rand, 0);
randSum.subscribe((x) => console.log(x));
randSum.subscribe((x) => console.log(x)); This example doesn't: const randSum = Observable.range(0, 10)
.map(_ => Math.random())
.reduce((sum, rand) => sum + rand, 0)
.publish();
randSum.subscribe((x) => console.log(x));
randSum.subscribe((x) => console.log(x));
randSum.connect();
Yes, this is by design. Observables aren't EventEmitters, they are very deliberately pure functions with isolated memory space for each Observer. Again, without this guarantee, parallelism is impossible. |
If this is the case and this spec wants to model this, then we might want to prominently state this. I questioned this because it is often mentioned as a source of confusion (recent JavaScript Jabber and RxJS docs) and two of the three libraries referenced above have different default behaviors. This would be okay for me, though it would mean that this is of little / no value to our organization.
Yes, when the assumption is that observables share nothing and a new observable chain is created for every consumer. I do not share this assumption. |
I fully agree with @bripkens. At the very least it'd be nice to have the 1 Producer -> 1 Consumer thing explicitly stated in the README somewhere.
Since we're talking about JavaScript here, it's not like we're actually going to have parallel computation for observables any time soon, so I'm not so sure this is an entirely relevant point. |
@bripkens Yeah, "MulticastForever" was just a (somewhat cheeky) fill-in name : ) Let me know if I can help validate that the subclassing approach will work. |
@bripkens in cases were you want to control the number of producers set up and torn down. Consider the following:
Observable.interval(1000).flatMap(() => longAjaxObservable) If all Observables were multicast the above would share the first request the following 4-5 times until it completes, then repeat the results. |
Actually, a more simplistic example would be the Observable equivalent of
If Observables were multicast, in the above scenario, five seconds after the first subscription In essence: Multicast actually hurts the reusability of Observables ... basically because it makes them stateful. |
https://msdn.microsoft.com/en-us/library/ee850490%28v=vs.110%29.aspx Historically, the Observer Pattern has been 1-to-N. If you have to deviate from this for practical reasons, maybe it should have a different name? |
Let's simplify it: observables aren't unicast or multicast. They tie any producer to any consumer. This thread is a giant misnomer. |
Do you mean Observer? const eventObservable = new Observable((observer) => {
domElement.addEventListener(function handler(e) { observer.next(e) });
return () => domElement.removeEventListener(handler);
});
eventObservable.subscribe((x) => console.log(x));
eventObservable.subscribe((x) => console.log(x)); Only one Observable exists, but two Observers are created/ @domenic |
Put another way: observables are no more "unicast" or "multicast" than a function that accepts a callback. They're a primitive type. That's all. |
That has been the general theme. Erik Meijer didn't leave much undone when he formalized Rx, and most every deviation from the behavior (async subscribe, etc.) break the library for a certain subset of use-cases (and users). "Any sufficiently complicated FRP or Stream library contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Reactive Extensions." Observables are referentially transparent, so each Observer exists in its own isolated memory space. This makes Observables a great primitive to build on, because you can solve problems that can't be expressed if your primitive is stateful. For the problems where you do need to introduce state, you can graduate the state-less Observable to a stateful Observable by using your choice of Subject (the four default Subject types are distinguished by how they notify Observers, whether they replay events from the past, etc.). |
@domenic an interesting data-point: Facebook is investing in a new language with native Observables (similar to what Google's done with Dart) to power their next-gen cloud infrastructure. There's precedent for language-level Observables, but the mathematics leaves very little room to deviate from the design. |
I completely realize observables have been outlined in a mathematical fashion in a way their designers find appealing. What I have been trying to ask about in this thread is how or whether that carries over in to ecosystems like the DOM in practice, especially in relation to this spec. Otherwise they are best left as a library, if they form an isolated ecosystem that is self consistent but requires extensive machinery to integrate into existing (stateful, etc.) systems. Unfortunately nobody has really taken the time to answer my questions, or if they did, I was not able to understand the answers :(. |
@domenic I'll keep it simple: yes, these ES Observables would be a practical interface for event streams from the DOM, e.g. to wrap click listeners. Observable is just an interface. However, we usually talk about the Observable class implementing the Observable interface (to be more correct, that'd be
That's perfect. And to me this issue is closeable because of that. |
@staltz sorry, you lost me. JavaScript doesn't have interfaces, so how can we use IObservable to wrap "DOM streams" (whatever they are?)? Is this spec's Observable class going to be useless for the DOM, and some un-specced IObservable thing is necessary? |
@domenic Well, this spec won't really cover how to create event producers. For example the method for actually getting click events into the rest of an application will have to be defined elsewhere, but actually working with the events will be easier with Observables. Rather than having a bunch of callback spaghetti for propogating the events between different functions, one could construct something like a pipeline declaratively. Now I'm not too knowledgeable on the actual implementations of low level DOM APIs, however I'm sure that parts involve streams of events, and those parts will likely benefit from this as a primitive. Are there any particular areas of the DOM that you think might want a primitive for handling events over time? It'd be easier to get concrete code examples on how to solve particular problems if we had some more knowledge on what existed to be solved. |
If we're talking about potential new APIs, then making |
@staltz Sounds good. Let me know if you run into any issues with that approach. @domenic Thanks for jumping in here. Let's first just look at the specific issue called out in this thread and then we can zoom out to the larger issue and how it relates to the DOM. In some reactive libraries, all "observables" maintain their own subscriber lists, and each node in a chain supports fan-out. Let's say your origin source is a sequence of numbers, and then you map it, and then subscribe two times. let mappedNumbers = numbers.map(x => x * 2);
mappedNumbers.subscribe(observerA);
mappedNumbers.subscribe(observerB); In Rx, and the current spec here, that will create two separate chains:
(I'm leaving If each node supports fan-out by default, then it would look like this:
Clearly we're not going to get agreement on fan-out-by-default. However, those that want fan-out-by-default can "enable" it by using a subclass. As an aside, you could also opt-in to fan-out by using a multicast combinator. To get the second graph above, you could do something like: let mappedNumbers = numbers.map(x => x * 2)::multicast();
mappedNumbers.subscribe(observerA);
mappedNumbers.subscribe(observerB); More on the DOM situation in a bit... |
|
@Blesh so the DOM won't even use this type we're speccing here, it'll just use the "thenable-equivalent"? |
(Sorry for the length on this one!) So what does this mean for the DOM? Even if we specced "Subject" it wouldn't be directly applicable to EventTarget, because with EventTarget listeners are registered by event name and capture flag. If we wanted to re-spec https://github.com/zenparsing/es-observable/blob/master/dom-event-dispatch.md ...where the element.on("click").map(evt => ({ x: evt.x, y: evt.y })).forEach(point => {
console.log(point);
}); We want Since the DOM wouldn't use a Subject type anyway, then it seems to me that there's no problem in leaving it out. Let's look at another question. Assume that Observable.prototype has a Say we have this: let mappedPoints = element.on("click").map(e => {
return { x: e.x, y: e.y };
});
mappedPoints.forEach(p => console.log(p));
mappedPoints.forEach(p => console.log(p)); So far so good. But what if I add some side-effects to our map callback? let sideEffectCount = 0;
function doSideEffect() { sideEffectCount++ }
let mappedPoints = element.on("click").map(e => {
doSideEffect();
return { x: e.x, y: e.y };
});
mappedPoints.forEach(p => console.log(p));
mappedPoints.forEach(p => console.log(p));
// After one click, sideEffectCount will be 2 Does this situation represent a footgun? (It's an honest question, I'm interested in the answer.) |
Definitely. However, I'd argue it's not because of multicast vs. unicast - it's because of non-strict semantics. It's a footgun programmers in other languages with any deferred execution streams like Dart, C#, Java and Python have learned to deal with - and it's a footgun programmers that use generators in ECMAScript have to deal with today anyway. Causing side effects in a method that runs in a deferred way is always risky. Even with multicast semantics I think the following would be a much more common footgun:
Or, similarly:
So - I think unicast is just one of those things people are going to have to grasp about observables. Observables were never meant to be "easy" - they were meant to be simple to use in complex concurrency scenarios where other solutions are less elegant. Let's not forget a large majority of the JS community doesn't know how to return the response from an AJAX call :) |
No. It represents a property of the type. Observables are analogous to functions. If you called a function twice, you'd expect two side effects in most cases, unless the function was written in a "multicast" manner and was stateful. |
Making Observable stateful and adding a list of observers to it will break the type. It would be a mistake. |
For sure! I just want to explore all of the ramifications. I'm fine if there's a small, graded learning curve (there certainly is with promises). |
@zenparsing thanks for the clear illustration of what's going on; that's the first thing in this thread that actually seems to address my questions. I tend to agree with @benjamingr on this I guess. It is a footgun, but there are simpler footguns in the observable paradigm, and in the end it's all down to whether people expect methods named "map" etc. to be lazy or eager. Our only precedent so far in the language is eager, so his alert example will be surprising to people trying to use observables in contexts like the DOM where they have clear expectations already. (It obviously won't be surprising to @Blesh, who is able to use phrases like "it's a property of the type" to dismiss peoples' concerns.) I guess what's really required here would be acclimatizing people to the idea of lazy combinators, probably by putting some on Iterator.prototype. Once they are present on Iterator.prototype and lazy there, it makes sense for them to be on Observable.prototype and lazy there as well. The only alternative I can see is coming up with some new convention like In any case, that makes me more comfortable, since the current proposal only includes the straightforward forEach and not the lazy map or filter; we can leave those for a later proposal that adds them to both Iterator and Observable at the same time. |
Ok so to conclude:
I'd love to see |
Sounds like we can close this one. I've also removed map and filter from the polyfill and tests. Thanks everyone! |
@zenparsing I kind of wish that a multicast wrapper were standardized, because if even two different dependencies need to do multicast, they might provide their own redundant implementations (or use two different libraries people have created to provide a centralized multicast implementation) |
FWIW, I don't think Flow or TypeScript currently provide a way for base class method type defs to specify that the return type is an instance of the inheriting class. In other words, without manually overriding the return type defs for all methods in |
@jedwards1211 this proposal is stalled for over 5 years. You can just use RxJS which can multicast. |
What is your stance on multicast vs. unicast for observables? Well known stream / observable implementations have varying opinions on this topic. RxJS is "unicast" by default [1] while Kefir [2] and Bacon [3] are multicast by default. From what I can tell based on the existing es-observable implementation, es-observable is aligned with RxJS and only provides a "unicast" option.
Note that the RxJS'
multicast
operator does not result in the behavior of Kefir and Bacon. Kefir and Bacon retain automatic laziness and multicast enabled for all operators. The RxJSmulticast
operator only lifts the previous operator in the observable chain to a multicast one. Successive operators remain unicast.We found multicast to be extremely valuable during the development of our application. We used it to model expensive operations that are executed lazily. Result are shared between all subscribers.
[1] http://jsbin.com/cidaheqoqa/edit?html,js,console
[2] http://jsbin.com/wuqarebegi/edit?html,js,console
[3] http://jsbin.com/maciradiya/edit?html,js,console
The text was updated successfully, but these errors were encountered: