JavaScript asíncrono: El Malo, el Feo ( callbacks chaining ), el bueno ( promises chaining ) y operaciones parallel

Aunque fué publicado hace varios meses, en los últimos días reibí este post: 3 formas de manejar la asincronía en JavaScript a través de un newsletter de Carlos Azaustre, me parece una excelente introducción al JavaScript asíncrono.

Se supone que estamos familiarizados con Node.js, y en particular con su naturaleza asíncrona.

En el mencionado post, Carlos comparte con nosotros tres orientaciones para manejar las llamadas asíncronas. Asumimos que ya sabemos como invocar una función que ejecuta operaciones asíncronas, y después ejecuta un fragmento de código cuando la función finaliza su ejecución. Pero, ¿qué pasa cuando hay varias funciones asíncronas que llamar, y un fragmento de código debe ser ejecutado cuando todas estas funciones hayan terminado?

Hemos preparado unos ejemplos con las diferentes paroximaciones, y las vamos a comparar.

Nota: Requester.getResponseCode es un helper de nuestra autoría, que utiliza request para hacer peticiones http.

(puedes descargar los fuentes de mi repositorio Git

Encadenando llamadas a funciones asíncronas

Una idea es anidar (encadenar) llamadas a funciones, y tal como Carlos nos muestra en el post, se puede hacer de varias formas.

El Malo: no funciona, por tanto... evítalo.

    var bingResult = false;
    var googleResult = false;

    console.log('Starting Bing');
    requester.getResponseCode('http://www.bing.es',
            function(statusCode) {
                var result = (statusCode == 200);
                bingResult = result;
                console.log('bing: '+statusCode);
            }
    );
    console.log('Starting Google');
    requester.getResponseCode('http://www.google.es',
            function(statusCode) {
                var result = (statusCode == 200);
                googleResult = result;
                console.log('google: '+statusCode);
            }
    );      
    console.log('bing: '+bingResult);   
    console.log('google: '+googleResult);

    console.log('Finished!');
    res.end('Finished!');

Es muy claro que esta orientación no funciona. Tal como podemos ver en el log:

Starting Bing

Starting Google

bing: false

google: false

Finished!

google: 200

bing: 200

las funciones son llamadas pero debido a su asincronicidad, la ejecución continua sin esperar que las funciones terminen. De esta forma, el mensaje final ("Finished!") aparece antes de los mensajes de ejecución de las funciones.

El Feo: Encadenamiento de Callbacks (el primero que funciona, en el mencionado post lo llaman "Callbacks").

La segunda función es invocada dentro del callback de la primera función. Fácil. Claro.

var bingResult = false;
var googleResult = false;
console.log('Starting Bing');
requester.getResponseCode('http://www.bing.es',
    function(statusCode) {
        var result = (statusCode == 200);
        bingResult = result;
        console.log('bing: '+statusCode);

        console.log('Starting Google');            
        requester.getResponseCode('http://www.google.es',
            function(statusCode) {
                var result = (statusCode == 200);                    
                googleResult = result;
                console.log('google: '+statusCode);

                console.log('bing: '+bingResult);                                
                console.log('google: '+googleResult);
                console.log('Finished!');
                res.end('Finished!');    
            }
        );                            
    }
);

As we can see in the log:

Starting Bing

bing: 200

Starting Google

google: 200

bing: true

google: true

Finished!

los mensajes de las funciones de callback aparecen primero, después (de todo) lo hace el mensaje final.

El Bueno: Encadenando Promesas (el más popular actualmente, en el mencionado post lo llaman "Promesas").

Este código puede ser ya considerado "PRO" ;-) Funciona, es claro y mantenible. Primero se crean las promesas, y después se encadenan usando .then().

    var bingResult = false;
    var googleResult = false;

    var promise1 = function() {
        return new Promise(function(resolver, cancelar) {

            console.log('Starting Bing');
            requester.getResponseCode('http://www.bing.es',
                    function(statusCode) {
                        var result = (statusCode == 200);
                        bingResult = result;
                        console.log('bing: '+statusCode);
                        resolver();                         
                    }
            );

        });
    }

    var promise2 = function() {
        return new Promise(function(resolver, cancelar) {

            console.log('Starting Google');
            requester.getResponseCode('http://www.googleg.es',
                    function(statusCode) {
                        var result = (statusCode == 200);
                        googleResult = result;
                        console.log('google: '+statusCode);
                        resolver();
                    }
            );

        });
    }

    res.writeHead(200, {'Content-Type': 'text/html'});

    promise1().then(promise2).then(function() {
        console.log('bing: '+bingResult);   
        console.log('google: '+googleResult);
        console.log('Finished!');
        res.end('Finished!');
    });

Como podemos ver en el log:

Starting Bing

bing: 200

Starting Google

google: 200

bing: true

google: true

Finished!

Funciona adecuadamente. La invoación de la segunda función ocurre únicamente después de que la primera es completada. Y por supuesto, después de todo, aparece el mensaje final.

La versión de lujo: Async/Await (tecnología puntera ;-) )

Las funciones Async han sido aceptadas en los estándares ES7. No vamos a hablar de esto ahora, si quieres conocer más acerca de ello, puedes prestar atención a la introducción de Carlos, tiene pinta de ser de las buenas.

Operaciones Parallel : Si necesitas ejecutar múltiples tareas que no dependen una de las otras y cuando todas ellas terminan hacer algo más, deberías ejecutarlas en paralelo.

Si estas operacions son del tipo E/S sobre archivos, acceso a BDD o tráfico de red. Si tus tareas contienen este tipo de llamadas, parecerán haber sido procesadas en paralelo. Si no, simplemente serán ejecutadas una detrás de otra.

¿Como puede ser esto posible? Las tareas de E/S consumen la mayor parte de su tiempo de procesador esperando el resultado de una llamada de E/S. Node.js empieza a procesar la primera tarea hasta que esta queda pausada por una llamada de E/S. En ese momento, Node.js la abandona y dedica su hebra principal a otra tarea.

Recuerda que en bucles de una sola hebra nunca puedes hacer más de una cosa a la vez. Pero puedes esperar varias cosas simultáneamente sin problema.

Hay diversas formas de codificar operaciones parallel en JavaScript, hemos preparado un par de ejemplos.

Operaciones Parallel con Callbacks usando async

Async es un módulo de utilidades que provee funciones sencillas pero potentes, para trabajar con JavaScript asíncrono. Aunque originalmente fué diseñado para ser usado con Node.js e instalable via npm install async, puede ser usado también directamente en el navegador.

Async provee alrededor de 20 funciones incluyendo los habituales aspectos 'funcionales', así como los patrones más comunes para control de flujo asíncrono (parallel, series, waterfall…). En nuestro ejemplo, vamos a usar una de estas funciones (async.parallel).

parallel(tasks, [callback])

Ejecuta un array de tareas (funciones) en paralelo, sin esperar a que la anterior función se haya completado. Nota: parallel arranca tareas de E/S en paralelo, pero no hace realidad "el sueño de la ejecución de código en paralelo".

    async.parallel({
        bingRequest: function(callback) {
            console.log('Starting Bing');
            requester.getResponseCode('http://www.bing.es',
                    function(statusCode) {
                        var result = (statusCode == 200);
                        console.log('bing: '+statusCode);
                        callback(null, result);
                    }
            );

        },
        googleRequest: function(callback) {
            console.log('Starting Google');
            requester.getResponseCode('http://www.google.es',
                    function(statusCode) {
                        var result = (statusCode == 200);
                        console.log('google: '+statusCode);
                        callback(null, result);
                    }
            );

        }
    }, function(err, results) {
        var bingResult = results.bingRequest;
        var googleResult = results.googleRequest;
        console.log('bing: '+bingResult);   
        console.log('google: '+bingResult);
        console.log('Finished!');
        res.end('Finished!');
    })      

Como podemos ver en el log:

Starting Bing

Starting Google

google: 200

bing: 200

bing: true

google: true

Finished!

La ejecución no espera a que la primera función termine para iniciar la segunda.

Operaciones Parallel con Promise.all

Promise.all te permite ejecutar múltiples operaciones asíncronas de una sola vez, y continuar hasta que todas ellas se hayan completado. El método Promise.all toma un array de promesas y dispara un callback una vez que todas ellas han sido resueltas.

var promise1 = function() {
        return new Promise(function(resolver, cancelar) {

            console.log('Starting Bing');
            requester.getResponseCode('http://www.bing.es',
                    function(statusCode) {
                        var result = (statusCode == 200);
                        console.log('bing: '+statusCode);
                        resolver(result);                           
                    }
            );

        });
    }

var promise2 = function() {
        return new Promise(function(resolver, cancelar) {

            console.log('Starting Google');
            requester.getResponseCode('http://www.googleg.es',
                    function(statusCode) {
                        var result = (statusCode == 200);
                        console.log('google: '+statusCode);
                        resolver(result);
                    }
            );

        });
    };

Promise.all([promise1(), promise2()]).then(function(results) {
        var bingResult = results[0];
        var googleResult = results[1];
        console.log('bing: ' + bingResult); 
        console.log('google: ' + googleResult);

        console.log('Finished!');
        res.end('Finished!');
    });

Evaluación comparativa

Hemos preparado una especie de comparativa de tiempo de ejecución entre las diferentes aproximaciones. Cada pieza de código invoca cuatro de las URL's más populares (bing.es, google.es, nasa.gov y loc.gov), y después de terminar, muestra el total de tiempo en milisegundos invertido para finalizar las cuatro request y recuperar las cuatro response. Corremos cada test 10 veces, tomamos nota de los milisegundos invertidos y calculamos los tiempos mínimo, máximo y promedio de ejecución. Si estás interesado en los resultados, por favor échale un vistazo a la siguiente tabla:

 Enadenando
Promesas
Parallel
con async
Parallel
con Promises.all
Min.1141359345
Max.3037807866
Media1730553477

Conclusión:

No deberíamos utilizar la aproximación 'series' porque es claramente más lenta que la aproximación 'parallel'. Promise.all (igual que lo hace asyc.parallel) lanza múltiples operaciones asíncronas de una vez, para reducir el tiempo total de ejecución.

Una vez más, este post no va simplemente acerca de Node.js, o JavaScript. Es acerca de la programación asíncrona en general, y sus diferentes aproximaciones.