21

I'm writing code using generators and Bluebird and I have the following:

var async = Promise.coroutine;
function Client(request){
    this.request = request;
}


Client.prototype.fetchCommentData = async(function* (user){
    var country = yield countryService.countryFor(user.ip);
    var data = yield api.getCommentDataFor(user.id);
    var notBanned = yield authServer.authenticate(user.id);
    if (!notBanned) throw new AuthenticationError(user.id);
    return {
        country: country,
        comments: data,
        notBanned: true
    };
});

However, this is kind of slow, I feel like my application is waiting too much for I/O and it's not in parallel. How can I improve the performance of my application?

The total response time is 800 for countryFor + 400 for getCommentDataFor + 600 for authenticate so in total 1800ms which is a lot.

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
  • Can we come up with a better title, something along the lines of "*Run promises in parallel in async generators*"? – Bergi Mar 06 '15 at 03:02
  • @Bergi by all means - go for it. – Benjamin Gruenbaum Mar 06 '15 at 12:30
  • It's just that I don't like the phrase "*run promises*", and I'd also like to incorporate the performance thing. Can you think of some better? – Bergi Mar 06 '15 at 13:34
  • Yeah, promises are not "run" by any measure but the longer I've been teaching people code and answering things on StackOverflow the less I care about exact terminology in favor of usefulness. The goal here was to let people know that generators can be slow in these scenarios and to let people know of a common performance bug, anything that better reaches people or accomplishes that goal is positive IMO @Bergi – Benjamin Gruenbaum Mar 06 '15 at 14:17
  • !notBanned means the user is banned? B/c you then return notBanned: true which would be the opposite, no? – Penguin9 Aug 23 '18 at 12:09

3 Answers3

20

You are spending too much time waiting for I/O from different sources.

In normal promise code, you'd use Promise.all for this, however - people have a tendency to write code that waits for requests with generators. Your code does the following:

<-client     service->
countryFor..
           ''--..
              ''--..
                 ''--.. country server sends response
               ..--''
          ..--''
     ..--''
getCommentDataFor
     ''--..
           ''--..
               ''--..
                     ''--.. comment service returns response
                ..--''
          ..--''
      ..--''
authenticate
       ''--..
            ''--..
                  ''--.. authentication service returns
             ..--''
       ..--''
 ..--''
 Generator done.

Instead, it should be doing:

<-client     service->
countryFor..
commentsFor..''--..
authenticate..''--..''--..
                 ''--..''--..''--.. country server sends response
                        ''--..--''..  comment service returns response
                   ..--''..--''..     authentication service returns response
          ..--''..--''..
 ..--''..--''..--''
 ..--''..--''
 ..--''
 Generator done

Simply put, all your I/O should be done in parallel here.

To fix this, I'd use Promise.props. Promise.props takes an objects and waits for all its properties to resolve (if they are promises).

Remember - generators and promises mix and match really well, you simply yield promises:

Client.prototype.fetchCommentData = async(function* (user){
    var country = countryService.countryFor(user.ip);
    var data = api.getCommentDataFor(user.id);
    var notBanned = authServer.authenticate(user.id).then(function(val){
          if(!val) throw new AuthenticationError(user.id);
    });
    return Promise.props({ // wait for all promises to resolve
        country : country,
        comments : data,
        notBanned: notBanned
    });
});

This is a very common mistake people make when using generators for the first time.

ascii art shamelessly taken from Q-Connection by Kris Kowal

Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
  • +1 for not only the clarity of this answer, but the third person reference. – Sterling Archer Jun 12 '14 at 20:46
  • @Bergi in generators, performing a `return` actually acts like a `yield` only with the `done` flag also set to true, so _technically_ it is using generators. I agree that generators are no longer useful though. I Q&Ad this because I see people getting this use case wrong with generators all the time, a more complex example with generators felt like the cruft wasn't worth the didactic value, I'm still open to improvement suggestions. – Benjamin Gruenbaum Jun 12 '14 at 20:49
  • 1
    Is `countryService.countryFor(user.ip)` synchronous networking? I don't see any callback or promise that would allow it to be async. – jfriend00 Jun 12 '14 at 20:49
  • @BenjaminGruenbaum: I'll make it an answer :-) – Bergi Jun 12 '14 at 20:50
  • @Bergi by all means go for it :) – Benjamin Gruenbaum Jun 12 '14 at 20:50
  • @jfriend00 `Promise.props` takes an object and waits for all properties on that object to resolve. `country` in this code is a promise. – Benjamin Gruenbaum Jun 12 '14 at 20:51
  • @BenjaminGruenbaum So `county`, `data`, and `notBanned` are all promises? And `notBanned` has an extra callback associated with it? – Ian Jun 12 '14 at 20:52
  • @Ian country and data are both promises, `notBanned` is also a promise. `then` is a semicolon, it's something to do when the promise resolves. Basically the `then` here does is take the promise, and make it throw if the user is not permitted to access the resource. – Benjamin Gruenbaum Jun 12 '14 at 20:53
  • There's no way for a reader to know that `country` and I guess `data` are promises without either knowing the APIs you're using or knowing `Promise.props` and inferring from that. I'd suggest you explain this better in both your question and answer if you want more people to understand. – jfriend00 Jun 12 '14 at 20:54
11

As it is mentioned in the Bluebird docs for Promise.coroutine, you need to watch out not to yield in a series.

var county = yield countryService.countryFor(user.ip);
var data = yield api.getCommentDataFor(user.id);
var notBanned = yield authServer.authenticate(user.id);

This code has 3 yield expressions, each of them stopping execution until the particular promise is settled. The code will create and execute each of the async tasks consecutively.

To wait for multiple tasks in parallel, you should yield an array of promises. This will wait until all of them are settled, and then return an array of result values. Using ES6 destructuring assignments leads to concise code for that:

Client.prototype.fetchCommentData = async(function* (user){
    var [county, data, notBanned] = yield [
//             a single yield only: ^^^^^
        countryService.countryFor(user.ip),
        api.getCommentDataFor(user.id),
        authServer.authenticate(user.id)
    ];
    if (!notBanned)
        throw new AuthenticationError(user.id);
    return {
        country: country,
        comments: data,
        notBanned: true
    };
});
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • 1
    +1 although node 11 doesn't have destructuring like this – Esailija Jun 13 '14 at 12:01
  • Does Bluebird support yielding an array of promises out of the box? – raine Feb 22 '15 at 00:28
  • @rane: Doesn't look like in the current version. See the last [example for `addYieldHandler`](https://github.com/petkaantonov/bluebird/blob/master/API.md#promisecoroutineaddyieldhandlerfunction-handler---void) though that adds this capability, if you don't like that you can use `Promise.all` – Bergi Feb 22 '15 at 12:57
4

The answer by Benjamin Gruenbaum is correct, but it loses the generators aspect completely, which tends to happen a bit when you're trying to run multiple things in parallel. You can, however, make this work just fine with the yield keyword. I'm also using some extra ES6 features like destructuring assignments and object initializer shorthand:

Client.prototype.fetchCommentData = async(function* (user){
    var country = countryService.countryFor(user.ip);
    var data = api.getCommentDataFor(user.id);
    var notBanned = authServer.authenticate(user.id).then(function(val){
        if(!val) throw new AuthenticationError(user.id);
    });

    // after each async operation finishes, reassign the actual values to the variables
    [country, data, notBanned] = yield Promise.all([country, data, notBanned]);

    return { country, data, notBanned };
});

If you don't want those extra ES6 features being used:

Client.prototype.fetchCommentData = async(function* (user){
    var country = countryService.countryFor(user.ip);
    var data = api.getCommentDataFor(user.id);
    var notBanned = authServer.authenticate(user.id).then(function(val){
        if(!val) throw new AuthenticationError(user.id);
    });

    var values = yield Promise.all([country, data, notBanned]);

    return { 
        country: values[0], 
        data: values[1], 
        notBanned: values[2]
    };
});
Joe Zim
  • 1,787
  • 1
  • 15
  • 16