Node: Add context argument to emitter

Created on 3 Oct 2017  路  26Comments  路  Source: nodejs/node

As part of the hapi v17 work, I removed every closure from the hot path. This provided significant performance improvements. All these closures came from handlers passed to node EventEmitters (specifically from req and res object). In order to do that, I had to hang my own property on the emitter object. For example:

const handler = function (err) {
    this._context.log(err);
};

// Inside some prototype methods:

req._context= this;
req.on('error', handler);

This works and can be improved by using symbols, but it's still messy. It also means adding multiple context properties per handler because they might need different context data. Instead, I would like to be able to do something as simple as:

const handler = function (err, context) {
    context.log(err);
};

// Inside some prototype methods:

req.on('error', handler, this);

The idea is to pass a third optional argument to on() and then append that argument to the end of the emit arguments list. We already store listeners as objects with properties. When this feature is not used, it means two additional if statements: once to check if a third argument was provided and another to check if one is present and needs to be appended. If no third argument is provided, these two extra if statements should not have any noticeable impact on performance.

However, since 99% of the time, emitter handlers require the creation of a callback closure, this should make applications using it much faster overall. Potentially, this can be used internally by node as well.

There is already a pattern for this in setTimeout(). Most new JS features with callbacks now accept some kind of extra binding argument. Because we already bind the handler to the emitter, we cannot use this pattern.

I'm happy to do the work if the idea is acceptable.

events feature request stalled

Most helpful comment

Wouldn't that make emitter unnecessary complex? Currently it's a great simple API, and use cases where we e.g. needed to handle _context_ etc. were always without much hassle addressed in user-land.

All 26 comments

Why not use handler.bind(...)? It is fast in recent node versions and we even use it in core in hot paths (including http).

I don't want to bind the handler. I want to leave the handler's binding as-is (the emitter). I just want to pass some random context the same way I can pass it to most other functional interfaces and timers. But more importantly, why force everyone to bind handlers when we can instead just pass an argument? This is the most common use case of emitters...

I'm generally +1 on the need for this, as a technical detail adding a new argument does have a non-zero risk of breaking existing code. There is another approach I've been considering that also covers another discussion that's been ongoing... that is, adding a new API to EventEmitter that allows a more generic handler to be registered for all events... e.g.

emitter.addHandler((event, context, ...args) => { /* ... */ }, context);

Here, the handler is invoked for every emitted event, with the event name passed as the first argument. I've augmented what I was thinking previously with the additional context argument.

Because this is a new API, it wouldn't have a risk of breaking existing stuff.

@jasnell I like it a lot, even better than my idea!

On second thought, I need to be able to choose the events because I鈥檓 dealing with streams and I don鈥檛 want to get called on every chuck of data.

Ok. I think the same principle applies with regards to adding a new api. But definitely can work with that constraint

And your argument order is the correct one where context comes before the event arguments, not after.

Ok, so there are a couple of requirements from this and the other discussion ... that I think we can easily capture in a single new API.

(we can bikeshed the name of the new function later)

emitter.addListenerWithContext( [ 'event1', 'event2' ], context, (event, context, ...args) => { /* ... */ });

The context argument can be made optional here. If it is supplied, then it is passed to the handler.

@mcollina @silverwind ... what do you think?

Wouldn't that make emitter unnecessary complex? Currently it's a great simple API, and use cases where we e.g. needed to handle _context_ etc. were always without much hassle addressed in user-land.

Node core itself could strongly benefit from having something like proposed above (addListenerWithContext) so 馃憤 here.

Can you add benchmarks between a) using bind vs b) changing the request? I think it would be interesting data for everyone.

I'm :+1: with this change. I would prefer the 3rd parameter to on(), but I fear that it precludes the road to land it in a semver-minor. The real blocker on this work are regressions: we could not land it if it's slower than the current code.

As you said, the current best practice involves modifying the request, however this often leads to every request having different "shapes" on the V8 eyes, and it prevents a great deal of optimisations from their side. If you are mostly interested in just attaching a context to req and res, you might want to have a look at https://github.com/nodejs/node/pull/15752 and https://github.com/nodejs/node/pull/15560. I think they might solve your issue in a different way (@hekike is working on Restify at the moment), and they have almost zero side-effects and potential for regressions.

Instead of adding a third boolean parameter for .on, I think we're better off introducing a options object for .on, analogous to addEventListener in browsers. context could be easily realized as a option there, not so sure about a possible multiple option as it would change the function signature, so a separate method might still be cleaner there.

Aside from performance, is there an advantage to adding a context argument rather than just using a closure in userland?

It seems to me that this isn't a very common use case (in most code paths, the slight performance penalty of using a closure is worthwhile for the benefit of more idiomatic code). I agree with @medikoo's comment in https://github.com/nodejs/node/issues/15763#issuecomment-334081123 that this seems a bit like feature creep.

If we do add this to the EventEmitter API, I think we should add it as a new method rather than a third argument to on. There are a few reasons I think a new method would be better from an API design perspective:

  • Adding a new method avoids confusing new users who are just trying to understand how on works, and who usually don't need the performance benefits of context.
  • Adding context as a third argument to on would permanently block Node from adding anything else to on in the third-argument position. If we found a use-case later for passing another customization value to on, we would be forced to put it in the fourth-argument position. If a user only wanted to use that new customization value without using context, they would be forced to put null (e.g.) as the third argument. This could lead to confusing APIs in the future. (Using an object with a context property as the third argument would avoid this problem.)
  • Subjectively, I think adding a third argument would hurt readability for users who are unfamiliar with what the third argument does. (It would look like another value that gets passed to on with a seemingly arbitrary and non-obvious functionality.) Using another method or adding an object with a context property would avoid this problem.

Yeah I agree that if we should add this at all in core it should be a different method.

I still don't understand why .bind() can't be used. You can pass arguments with .bind() as well:

function handler(context, arg) {
  // context === req
  // arg === 'bar'
}

// ...

req.on('foo', handler.bind(req, contextArg));
req.emit('foo', 'bar');

https://jsperf.com/emitter-bind-vs-context

25% better is meaningful.

@hueniverse FWIW I only get a ~8-9% difference with current node master when comparing actual built-in EventEmitter + bind() vs. a copy of EventEmitter with a simple context implementation + passing a context object.

It seems V8 is getting better with calling bound functions.

My best argument is that every new JS API is providing this functionality one way or another. If we are not making any changes/new APIs, fine. But if we are still evolving the node APIs, this seems like an easy win. I also think node has been historically more aggressive at looking for 8-9% gains...

My best argument is that every new JS API is providing this functionality one way or another.

Could you provide some examples? I personally haven't noticed a pattern like this.

@not-an-aardvark Map.prototype.forEach() and Set allow you to pass a bind option - both are newish additions.

I would just like to note that the context argument has already been done in userland, eg: https://www.npmjs.com/package/eventemitter3#contextual-emits.

Somewhat related: #13338 - which was voted down for being unidiomatic.

My .02: if the performance gap is currently < 10%, a better strategy is to try and close that gap than add an ad hoc new API.

Map.prototype.forEach() and Set allow you to pass a bind option - both are newish additions.

But they inherit that from Array.prototype.forEach(), which is not new. And it's the thisArg, it's not an extra context argument to the callback.

If a thisArg is what is needed, then .bind() should already be quite competitive. It only starts to lose out when you prepend arguments because then V8 has to resize and copy the arguments array with each call.

With bind performance these days I don't really see the issue with just binding? I'm sure there are plenty other APIs that require bind for similar things.

Relevant: v8/v8@594803c94671fa19db1420f2b43023b75264e101 [turbofan] Inline Function#bind in more cases.

I like the .bind way but In my opinion, the .bind has a drawback: You can't remove the listener without saving the binded function. eventEmitter.on('myEvent', callback.bind(this)) can't be removed from eventEmitter.removeListener('myEvent', callback.bind(this)).

Do you have a nice solution for removing an binded callback without saving it?

@Ulrikop No, that's just something you have to learn to live with.

You'd have the same issue with a context object. You should be able to add the same function with different contexts but then you need the context object again for disambiguation when you remove one.

Closing due to lack of further progress.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

addaleax picture addaleax  路  146Comments

feross picture feross  路  208Comments

speakeasypuncture picture speakeasypuncture  路  152Comments

aduh95 picture aduh95  路  104Comments

nicolo-ribaudo picture nicolo-ribaudo  路  147Comments