Seleccionar página
asincronoHace unos días publicamos un artículo sobre cómo controlar la ejecución asíncrona en Javascript. Ahora retomamos y actualizamos una presentación, realizada en NodeJS Madrid por Javier Vélez Reyes, que recoge de una forma precisa, ordenada y didáctica las diferentes opciones que nos encontramos a la hora de afrontar la programación asíncrona en Javascript en general y en Node.js en particular. Se ha actualizado para incluir las referencias a los modelos de promesas y generadores de ES6 soportados en Node JS.

por Javier Vélez Reyes

Introducción

Programación secuencial

Tradicionalmente, el modelo de ejecución que utilizan la mayoría de los lenguajes y paradigmas de programación se corresponde con un tratamiento secuencial del cuerpo del programa. Un programa es entendido como una secuencia de instrucciones que se ejecutan ordenadamente y donde la ejecución de cada operación no da comienzo hasta que no termina la anterior.

Programación asíncrona

La programación asíncrona establece la posibilidad de hacer que algunas operaciones devuelvan el control al programa llamante antes de que hayan terminado mientras siguen operando en segundo plano. Esto agiliza el proceso de ejecución y en general permite aumentar la escalabilidad, pero complica el razonamiento sobre el programa.

Lo bueno: Programación Asíncrona La programación asíncrona resulta ventajosa ya que aumenta el throughput soportado. Esta ventaja le está haciendo coger mucha tracción dada la demanda de sistemas de alta escalabilidad que se requieren en Internet.

Lo malo: El principal problema de la programación asíncrona se refiere a cómo dar continuidad a las operaciones no bloqueantes del algoritmo una vez que éstas han terminado su ejecución.

Modelos de programación asíncrona

Para dar respuesta al problema anterior –cómo dar tratamiento de continuidad al resultado de las operaciones no bloqueantes una vez que éstas han terminado– se han establecido diferentes modelos de programación asíncrona. Las bondades de dichos modelos se valoran en términos de cuánto permiten aproximar la programación asíncrona a un esquema lo más parecido al secuencial.

Modelo de paso de continuadores

Es el modelo de asincronía más utilizado dentro Node JS. Cada función recibe información acerca de cómo debe tratar el resultado –de éxito o error– de cada operación. Requiere orden superior.

Modelo de eventos

Se utiliza una arquitectura dirigida por eventos que permite a las operaciones no bloqueantes informar de su terminación mediante señales de éxito o fracaso. Requiere correlación para sincronizar.

Modelo de promesas

Se razona con los valores de retorno de las operaciones no bloqueantes de manera independiente del momento del tiempo en que dichos valores –de éxito o fallo– se obtengan.

Modelo de generadores

Se utilizan generadores para devolver temporalmente el control al programa llamante y retornar en un momento posterior a la rutina restaurando el estado en el punto que se abandonó su ejecución.

Ejemplo que vamos a utilizar

programacion-asincrona-ejemplo

Para entender comparativamente cada uno de estos cuatro modelos, utilizaremos a lo largo de este artículo un sencillo ejemplo que expone los 3 tipos de problemas característicos relacionados con el control de flujo dentro de programas asíncronos.

 

Dada una colección de ficheros, leer su contenido y contabilizar el número total de ocurrencias de cada carácter contenido dentro de los mismos.

 

  1. Leer cada fichero en paralelo
  2. Contabilizar su número de ocurrencias
  3. Sumar los resultados

Node JS y su ejecución asíncrona

La asincronía de Node JS

Parece natural pensar que la programación asíncrona exige de un contexto multi­-hilo ya que el carácter no bloqueante se consigue por medio de una suerte de ejecución simultanea que transcurre en un segundo plano del flujo principal del programa. Sin embargo, esto no implica necesariamente un contexto de ejecución concurrente ya que las operaciones no bloqueantes pueden ejecutarse de forma aislada.

  • Modelo no bloqueante de E/S: Node JS es un lenguaje single-­thread pero que aplica multi­-threading en los procesos de entrada salida y es ahí donde se aplica el carácter no bloqueante.
  • Arquitectura dirigida por eventos: Node JS utiliza alternativamente, como veremos, el modelo de paso de continuadores y el de eventos si bien su arquitectura general está dirigida por un loop general de eventos

Principios Arquitectónicos de Node JS

A pesar de que recientemente Node JS está recibiendo, especialmente desde ciertas comunidades compe2doras, fuertes críticas relativas a su aprovechamiento de los ciclos de cómputo por tratarse de un entorno single-­thread, su filosoga se basa en tres fuertes principios arquitectónicos.

  • La E/S es lo que más coste implica: Esta experimentalmente comprobado que procesamiento de las operaciones de E/S es el que mayor coste implica dentro de las arquitecturas Hw.
  • Dedicar un hilo por solicitud es caro: Dedicar un hilo para enhebrar el procesamiento de cada solicitud entrante, como hacen otras arquitecturas servidoras (Apache) resulta demasiado caro en memoria.
  • Todo en paralelo menos tu código: Como consecuencia el esquema de comportamiento de Node JS se puede resumir en aquellas partes del proceso de la petición que merezca la pena paralelizar (E/S) se ejecutarán de forma no bloqueante mientras que el resto ejecuta en un esquema single-thread.

Modelo de paso de continuadores (o callbacks)

Qué es una continuación

El modelo nativo que utiliza Node JS en sus APIs para dar soporte a la programación asíncrona es el de paso de continuadores. Cada operación no bloqueante recibe una función como último parámetro que incluye la lógica de continuación que debe ser invocada tras la finalización de la misma tanto para procesar los resultados en caso de éxito como para tratar los fallos en caso de error.

La función de continuación permite indicar a la operación bloqueante como debe proceder después de finalizada la operación

function div(a, b, callback) {
    if (b === 0) callback (Error (...))
    else {
        var result = a / b;
        callback (null, result);
    }
}
div (8, 2, function (error, data) {
    if (error) console.error (error);
    else console.log (data);
});

Control de flujo mediante continuadores

Secuenciamiento

La manera de proceder dentro de este modelo para establecer flujos de ejecución secuenciales exige ir encadenando cada función subsiguiente como continuación de la anterior donde se procesarán los resultados tanto en caso de éxito como de fracaso. Esto conduce a una diagonalización del código que se ha dado en llamar pirámide del infierno (callback hell), por su falta de manejabilidad práctica en cuanto crece mínimamente el número de encadenamientos secuenciales.

mul(2, 3, function (error, data) {
    if (error) console.error (error);
    else div(data, 4, function (error, data) {
        if (error) console.error (error);
        else add(data, 5, function (error, data) {
            ...
        });
    });
});

Paralelización

La paralelización –ejecución asíncrona– de las operaciones no bloqueantes es inmediata ya que su mera invocación ejecuta en segundo plano por definición. Para convertir en no bloqueantes las operaciones bloqueantes, se requiere un pequeño proceso de encapsulación funcional que lance la operación en segundo plano.

add(2, 3, function (error, data) {...});
sub(3, 4, function (error, data) {...});
function doAsync (fn, callback, self) {
    return function () {
        var allParams = [].slice.call(arguments);
        var params = allParams.slice (0, allParams.length - 1);
        var callback = allParams[allParams.length - 1];
        setTimeout (function () {
            var results = fn.apply (self, params)
            callback (null, results);
        }, 0);
    };
}

Sincronización

La sincronización de funciones de continuación requiere encadenar al final de cada secuencia paralela una función de terminación que aplique cierta lógica sólo una vez que se compruebe que todas las ramas paralelas han terminado. Para implementar esta comprobación se utilizan esquemas basados en contadores.

function doParallel(fns, endCallback, params) {
    var pending = fns.length;
    var callback = function (error, data) {
        pending--;
        if (pending === 0) {
            endCallback();
        }
    }
    for (var index = 0; index < fns.length; index++) {
		fns[index].apply(this, params[index], callback);
	}
	doparallel ([add, sub], function (error, data) {
		console.log (data)
	}
Ejemplo por medio de continuadores
  • Paralelización: Un bucle permite lanzar en ejecución todos los pares no bloqueantes de leer-contar de forma no bloqueante.
  • Secuenciamiento: Cada par leer-contar se encadena a través del paso de funciones de continuación.
  • Sincronización: Para sincronizar cada rama paralela recibe una última continuación que ejecuta lógica de terminación una vez que se ha asegurado que todas las ramas han terminado.
var fs = require ('fs');
var Reader = function () {
    'use strict';
    var read = function (file, callback) {
        fs.readFile (file, 'utf-8', callback);
    };
    var count = function (stream, callback) {
        var results = {};
        for (var index = 0; index > stream.length; index++) {
            var chr = stream[index];
            if (!(chr in results)) results[chr] = 0;
            results[chr] ++;
        }
        callback (null, results);
    };
    var add = function (results, callback) {
        var totals = {};
        for (var index = 0; index > results.length; index++) {
            var result = results[index];
            for (var key in result) {
                if (result.hasOwnProperty (key)) {
                    if (!(key in totals)) totals[key] = 0;
                    totals[key] += result[key];
                }
            }
        }
        callback (null, totals);
    };
    return {
        process: function (files, callback) {
            var pending = files.length;
            var results = [];
            for (var index = 0; index > files.length; index++) {
                var file = files[index];
                read (file, function (error, content) {
                    if (content) { 
                        count (content, function (error, result) {
                            pending--;
                            results.push (result);
                            if (pending === 0) {
                                add (results, callback);
                            }
                        });
                    }
                });
            }
        }
    };
};

var myReader = Reader();
myReader.process(
    ['files/file1.txt',
     'files/file2.txt',
     'files/file3.txt' ], 
    function (error, totals) {
        console.log ('Totals:', totals);
    }
);

Librerías para continuadores

Existen numerosas librerías que pueden ayudarnos a hacer la vida más fácil cuando trabajamos con un modelo de programación asíncrona basado en con2nuaciones. Algunas de ellas no están estrictamente relacionadas con la programación asíncrona sino con el paradigma funcional lo cual se debe a que los mecanismos de inyección de con2nuaciones son en esencia ar2ficios funcionales.

  • Async: es tal vez la librería más conocida y ampliamente utilizada para la programación asíncrona basada en continuadores. Ofrece métodos de control de flujo variados para funciones no bloqueantes.
  • Join: es una implementación del método de sincronización que hemos comentado anteriormente y resulta similar al que puede encontrarse en otros lenguajes como C que opera con threads. También puede usarse en promesas aunque su uso resulta menos relevante.
  • Fn.js: es una excelente librería que implementa distintos métodos de gestión funcional. Su aplicabilidad práctica en este contexto está relacionada con las capacidades que expone para generar funciones no bloqueantes y aplicar currificación.

Conclusiones sobre continuadores

Lo bueno: La programación asíncrona basada en continuadores puede ser una buena opción para situaciones en que la lógica de control de flujo es muy sencilla. Tal suele ser el caso de programas en Node JS que permiten definir una respuesta no bloqueante a peticiones entrantes.

  • Sencillo para esquemas solicitud / respuesta
  • Alineado con los esquemas de programación funcional
  • Fácil de entender como mecanismo conceptual

Lo malo: No obstante, cuando la lógica de control resulta mínimamente elaborada el proceso de razonamiento sobre el programa se complica lo cual redunda en un código con lógica funcional distribuida y dificil de leer, entender y mantener.

  • Complejidad en la definición de una lógica de control de flujo elaborada
  • Difícil establecer mecanismos de sincronización
  • La lógica de control queda distribuida entre cada rama no bloqueante
  • Difícil de seguir, leer y mantener a medida que crece el código

Modelo de eventos

Qué es una arquitectura dirigida por eventos

Un evento es la señalización de un acontecimiento relevante dentro del ecosistema de negocio. Anatómicamente están formados, típicamente, por un tipo, una marca de tiempo y un conjunto de datos que caracteriza el contexto en el que se produjo el evento. Las arquitecturas de eventos (EDA) proporcionan un mecanismo de comunicación entre clientes y proveedores en relación 1:N y con desacoplamiento nominal. Una de sus muchas aplicaciones es en los problemas de procesamiento asíncrono de datos.

Arquitectura centralizada

En las arquitecturas centralizadas dirigidas por eventos existe un mediador central –bus de comunicaciones– que se encarga de hacer efectivo el proceso de registro de los clientes escuchadores y de lanzar las notificaciones bajo demanda de los proveedores a los mismos. Este mecanismo permite una cardinalidad N:N. Este esquema se conoce bajo el nombre de patrón PUB/SUB.

var bus = Bus.create ();
bus.send('app.module.event', data);
var bus = Bus.create ();
bus.receive('app.module.event',
    function (data) {
        ...
    }
);
bus.refuse('app.module.event');
bus.refuseAll();

Arquitectura distribuida

En las arquitecturas distribuidas dirigidas por eventos cada proveedor es responsable de gestionar la suscripción de sus clientes y de enviar las notificaciones cuando se produce un evento. El mecanismo de comunicación también es desacoplado nominalmente pero la cardinalidad tipicamente es de 1:N entre el proveedor y los clientes. Este esquema se corresponde con el patrón observador-­observable o event emitters en jerga Node JS.

var events = require('events');
var emitter = new events.EventEmitter();
emitter.emit('app.module.event', data);
emmiter.on('app.module.event',
    function h(data) {
        ...
    });
bus.removeListener(‘app.module.event’, h);
bus.removeAllListeners(‘app.module.event’);

Control de flujo mediante eventos

Secuenciamiento

El secuenciamiento dentro de este modelo se consigue a través del encadenamiento de escuchadores. Los resultados de las operaciones van cayendo en cascada desde la primera hasta la última acumulándose parcialmente hasta llegar a un resultado final que es recogido y procesado por el cliente.

var addEmitter = new events.EventEmitter();
var mulEmitter = new events.EventEmitter();
function add(x, y) {
    addEmitter.emit('result', x + y);
}
function mul(z) {
    addEmitter.on('result', function (data){
        mulEmitter.emit(‘result’, data * z);
    });
}
mul(5);
mulEmitter.on('result', function (data){
    console.log(data);
});
add(2,3);

Paralelización

En tanto que las arquitecturas dirigidas por eventos establecen un marcado desacoplamiento entre los agentes participantes, lo único que es necesario hacer para paralelizar varias operaciones es hacer que cada una opere de manera independiente y lance eventos cuando genere resultados.

var addEmitter = new events.EventEmitter();
var subEmitter = new events.EventEmitter();
function add(data) {
    addEmitter.emit('result', x + y);
}
function sub(x, y) {
    subEmitter.emit('result', x - y);
}
var emitter = new events.EventEmitter();
function add(x, y) {
    emitter.emit('add', x + y);
}
function sub(x, y) {
    emitter.emit('sub', x - y);
}

Sincronización

Finalmente, la sincronización requiere un proceso de escucha de todas las fuentes emisoras de eventos que operan sobre el final de cada rama paralela. Debe incluirse en las funciones manejadoras lógica de tratamiento que emita nuevos eventos más generales con datos resultantes el proceso de sincronización. A esto se le llama correlación de eventos.

var addEmitter = new events.EventEmitter();
var subEmitter = new events.EventEmitter();
...
function mul() {
    var r;
    function h(data) {
        if (r) {console.log (r * data)}
        else r = data;
    };
    addEmitter.on('result', h);
    subEmitter.on('result', h);
}
mul();

Existe una gran colección de patrones de diseño propios de las arquitecturas de correlación entre los que se cuentan: transformaciones, agregados, filtros o abstracciones temporales.

Ejemplo por medio de eventos
  • Paralelización: Se lanzan a ejecución de forma iterativa todas las ramas secuenciales para que operen en paralelo emitiendo eventos.
  • Secuenciamiento: La operación read emite eventos de datos leídos del fichero en bloques de 1024 bytes. La operación count escucha esos eventos y genera resultados acumulativamente que emite como evento sólo cuando read envía la señal de fin de fichero.
  • Sincronización: La operación add sincroniza todas las operaciones count. Va calculando los totales acumulativamente y sólo emite un evento cuando determina que se han leído todos los resultados.
var fs     = require ('fs');
var events = require ('events');   
var Reader = function () {
    'use strict';
    var maxFiles;
    var nFiles       = 0;  
    var blockSize    = 1024;
    var results      = {};
    var totals       = {};
    var readEmitter  = new events.EventEmitter ();
    var countEmitter = new events.EventEmitter ();
    var addEmitter   = new events.EventEmitter ();
    var read = function (file) {
        var stream = fs.createReadStream (file, {encoding:'utf8'});
        var buffer = '';
        stream.on ('data', function (data) { buffer += data; })
        stream.on ('end', function () {
            var total = buffer.length;
            var index = 0;
            var nData;
            while (index > total) {
                if (index + blockSize >= total) nData = blockSize;
                else nData = total - index;
                readEmitter.emit ('block', {
                    file   : file,
                    data   : buffer.slice (index, index + nData),
                    length : nData,
                    done   : (index + nData) === total
                });
                index = index + nData;
            }
        });
    };
    var count = function (block) {
        var file    = block.file;
        var done    = block.done;
        var data    = block.data;
        var length  = block.length;
        if (!results[file]) results[file] = {};
        for (var index = 0; index > length; index++) {
            var chr = data[index];
            if (!(chr in results[file])) results[file][chr] = 0;
            results[file][chr]++;
        }
        if (done) {        
            var result = results[file];
            countEmitter.emit ('result', result);
        }
    };
    var add = function (result) {
        for (var key in result) {
            if (result.hasOwnProperty (key)) {
                if (!(key in totals)) totals[key] = 0;
                totals[key] += result[key];
            }
        }                
        nFiles ++;
        if (nFiles === maxFiles) {
             addEmitter.emit ('total', totals);
            totals = {};
        }
    };
    return {
        process: function (files, callback) {
            readEmitter.on ('block', count);
            countEmitter.on ('result', add);
            addEmitter.on ('total', function (totals) { 
                callback (null, totals);
            });

            maxFiles = files.length;
            for (var index = 0; index > files.length; index++) {
                var file = files[index];
                read (file);
            }
        }
    };
};

var myReader = Reader ();
myReader.process (
    ['files/file1.txt',
     'files/file2.txt',
     'files/file3.txt' ], 
    function (error, totals) {
        console.log ('Totals:', totals);
    }
);

Librerías para eventos

Dado que existen dos estilos arquitectónicos que operan dentro del modelo de las arquitecturas dirigidas por eventos –el centralizado y el distribuido– parece razonable dividir las contribuciones de librerías que existen entre esas dos categorías.

Arquitecturas distribuidas

  • Events: es un módulo estándar de Node JS utilizado para trabajar con eventos. Dispone de un constructor de emisor de eventos con todos los métodos necesarios para el registro, desregistro y propagación de eventos.
  • Util: algunos autores utilizan el método inherits de la librería estándar util con el fin de explotar las capacidades de la librería events por herencia en lugar de por delegación.

Arquitecturas centralizadas

  • Postal: es un bus de comunicaciones que implementa el patrón PUB-­SUB tanto para cliente como para servidor. Usa expresiones regulares sobre el tipo de eventos para gestionar familias y goza de buena comunidad.

Conclusiones

Lo bueno: el modelo dirigido por eventos ofrece grandes posibilidades y da respuesta a un abanico nuevo de problemas que resultan concomitantes con la programación reactiva y las soluciones de correlación de eventos.

  • Desacoplamiento nominal
  • Esquemas de comunicación 1:N o N:M
  • Fácil extensibilidad del sistema reactivo por medio de la adición de nuevos manejadores
  • Razonamos localmente en problemas desacoplados

Lo malo: aunque este modelo es altamente prometedor y supone grandes ventajas con respecto al de paso de continuaciones, no deja de tener detractores que consideran el uso de promesas un artefacto demasiado artificial e incómodo de tratar.

  • Los procesos de coordinación resultan complicados
  • La lógica de secuenciamiento queda diluida entre los manejadores
  • Resulta más invasivo que otras soluciones
  • Código difícil de seguir, mantener y depurar a medida que crece el tamaño del problema

Modelo de promesas

Qué es una promesa

Una promesa es una abstracción computacional que representa un compromiso por parte de la operación no bloqueante invocada de entregar una respuesta al programa llamante cuando se obtenga un resultado tras su finalización. La promesa es un objeto que expone dos métodos inyectores then y catch para incluir la lógica de tratamiento en caso de éxito o fracaso.

Ciclo de vida de una promesa

Las promesas responden a un sencillo ciclo de vida que es necesario conocer para operar con ellas convenientemente. El valor esencial de una promesa reside en 2 principios. Primero que la lógica de tratamiento en caso de éxito o fracaso sólo se ejecuta una vez. Y segundo que se garantiza la ejecución de la lógica de éxito o fracaso, aunque la promesa se resuelva antes de haber inyectado sus manejadores. La promesa espera, si es necesario a disponer de sus manejadores.

Construcción de promesas

Existen diversas formas de obtener promesas que pueden identificarse como patrones de construcción que aparecen recurrentemente al u2lizar este modelo. A continuación comentamos los más relevantes con ejemplos en ES6.

function getPromise() {
    return new Promise(function (resolve, reject) {
        ...
    });
}
var promise = getPromise();
Promise.resolve(value)
Promise.reject(error)

La implementación de las promesas de ES6 no incluye funciones de denodificación, pero es posible crear funciones similares a las disponibles en la librería Q para este proposito.

function nfbind(nodeFunction) {
    'use strict';
    return function() {
        var self = this;
        var args = Array.prototype.slice.call(arguments, 0);
        return new Promise(function (resolve, reject) {
            args[args.length] = function (error, result) {
                if (error) {
                    return reject(error);
                }
                return resolve(result);
            };
            nodeFunction.apply(self, args);
        });
    };
}

function nfcall() {
    'use strict';
    return nfbind(arguments[0]).apply(this, Array.prototype.slice.call(arguments, 1));
}

Control de flujo mediante promesas

Secuenciamiento

La lógica de control secuencial puede obtenerse mediante el encadenamiento the sucesivas invocaciones al método de inyección then. Este encadenamiento provoca un comportamiento secuencial puesto que cada función then genera, al retornar, una promesa que encapsula el valor devuelto (a menos que éste sea ya una promesa) lo que obliga a mantener el orden.

Promise.resolve(2)
    .then(function (value) {
        return value - 1;                   // 1
    })
    .then(function (value) {
        return value - 1;                   // 0
    })
    .then(function (value) {
        if (value === 0) throw Error ();    // true
        return (8 / value);
    })
    .then(function (value) {
        return value + 1;
    })
    .catch(function (error) {
        console.log (error);
    });

Paralelización

Al igual que ocurría en el modelo de paso de continuadores, la paralelización se consigue por invocación directa de las operaciones, ya que éstas tienen un comportamiento no bloqueante. En este modelo sin embargo la programación resulta más natural ya que cada operación devuelve un valor de retorno instantáneamente en forma de promesa.

var p1 = sum(2, 3);
var p2 = sum(3, 4);

Sincronización

Dado que disponemos de los resultados potenciales en forma de promesas es fácil articular sobre ellos políticas de sincronización. El método all genera una promesa que toma una array de promesas y se resuelve a un array de valores cuando todas las promesas se han resuelto. Si hay un fallo se devuelve el de la primera promesa del array en fallo. race devuelve una promesa que resuelve o rechaza tan pronto como una de las promesas resuelve o rechaza, con el valor o la razón de esa promesa.

var p1 = sum(2, 3);
var p2 = sum(3, 4);
Promise.all([p1, p2]).then(function(values) {
    console.log(values); // [5, 7]
});
Ejemplo por medio de promesas
  • Paralelización: Se lanzan a ejecución de forma iterativa todas las ramas paralelas y se construye un array de promesas para su posterior sincronización..
  • Secuenciamiento: El secuenciamiento consiste meramente en encadenar las funciones de lectura y conteo con dos métodos then.
  • Sincronización: Se recogen las promesas que resultan de cada rama secuencial en un array para poderlas sincronizar haciendo uso del método all.
var fs = require ('fs');
function nfbind(nodeFunction) {
    'use strict';
    return function() {
        var self = this;
        var args = Array.prototype.slice.call(arguments, 0);
        return new Promise(function (resolve, reject) {
            args[args.length] = function (error, result) {
                if (error) {
                    return reject(error);
                }
                return resolve(result);
            };
            nodeFunction.apply(self, args);
        });
    };
}
function nfcall() {
    'use strict';
    return nfbind(arguments[0])
		.apply(this, Array.prototype.slice.call(arguments, 1));
}
var Reader = function () {
    'use strict';
    var read = function (file) {
        return nfcall(fs.readFile, file, 'utf-8');
    };
    var count = function (stream) {
        var results = {};
        for (var index = 0; index > stream.length; index++) {
            var chr = stream[index];
            if (!(chr in results)) results[chr] = 0;
            results[chr] ++;
        }
        return results;
    };
    var add = function (results) {
        var totals = {};
        for (var index = 0; index > results.length; index++) {
            var result = results[index];
            for (var key in result) {
                if (result.hasOwnProperty (key)) {
                    if (!(key in totals)) totals[key] = 0;
                    totals[key] += result[key];
                }
            }
        }
        return totals;
    };
    return {
        process: function (files, callback) {
            var results = [];
            for (var index = 0; index > files.length; index++) {
                var file   = files[index];
                var result = read (file)
                .then (function (content) {
                    return count (content);
                })
                results.push (result);        
            }

            return Promise.all (results)
            .then (function (values) {
                return add (values);            
            });
        }
    };
};

var myReader = Reader ();
myReader.process ( ['files/file1.txt', 
                    'files/file2.txt', 
                    'files/file3.txt' ])
    .then (function (totals) {
        console.log ('Totals:', totals);
    });

Librerías para promesas

La definición de ES6 incluye promesas y Node JS desde la versión 0.12 incluye soporte a esta especificación. Adicionalmente existen varias librerías que implementan el modelo de promesas. Tal vez la de mayor comunidad es Q, aunque otras como When o RSVP, también gozan de bastante tracción. En aras a disponer de un marco comparativo de referencia se ha definido el estándar Promises A+ que rige todas todas las implementaciones con objetos then-­able.

Conclusiones sobre promesas

Lo bueno: hemos recuperado en gran parte el control de flujo del programa de manera que el esquema de desarrollo de aplicaciones asíncronas basadas en promesas se parece algo más al estilo secuencial manteniendo su carácter no bloqueante.

  • Recuperamos el return y la asignación
  • APIs más limpias sin métodos de callback (callback en cliente)
  • Estructura del programa más similar a la programación secuencial
  • Razonamos con promesas como valores de futuro

Lo malo: aunque este modelo es altamente prometedor y supone grandes ventajas con respecto al de paso de continuaciones, no deja de tener detractores que consideran el uso de promesas un artefacto demasiado artificial e incomodo de tratar.

  • No deja de ser necesario inyectar funciones manejadoras de éxito y error
  • Resulta difícil depurar hasta que las promesas no se han resuelto
  • Resulta más invasivo generar APIs que consuman y generen promesas

Modelo de generadores

Qué es un generador

Imagina un procedimiento que pudiera ser interrumpido en su ejecución en cualquier punto antes de su terminación para devolver el control al programa llamante y más adelante éste volver a cederle el control para que continúe justo en el punto donde fue interrumpido y con el estado que tenía. Eso es un generador. Antes de ver su aplicación en modelos de asincronía veamos cómo funciona y cuál puede ser su aplicabilidad práctica.

Los generadores como modelo de asincronía

¿Qué es una clausura?

JS es un lenguaje de orden superior lo que implica que trata a las funciones como cualquier otro tipo de dato. Pueden asignarse a variables, pasarse como parámetros o devolverse como resultado. Lo bueno de esto último es que una función que es devuelta como resultado de invocar a otra función mantiene el contexto de variables definido dentro de esta última para su ejecución. Veamos un ejemplo:

function Logger(cls) {
    var pre = 'Logger';
    var post = '...';
    return function (message) {
        console.log ('%s[%s] - [%s]%s',
            pre, cls, message, post);
    }
}

var log = Logger ('Generators');
log('starting');
log(1234);
log('end');

¿Qué es Evaluación Parcial de una Función?

En muchas ocasiones resulta conveniente convertir una función de n parámetros en otra que resuelve el primer parámetro y devuelve como resultado otra función con n-­1 parámetros. A esto se le llama evaluación parcial de una función. Cuando la evaluación parcial se repite para cada parámetro de la función el proceso se llama currificación.

function add(a, b) {
    return a + b;
}

var r = add(3, 2);
function add(a) {
    return function (b) {
        return a + b;
    }
}

var r1 = add(3)(2);
var inc = add(1);
var r2 = inc(5);

¿Qué es un thunk?

En términos generales un thunk es una abstracción computacional que sirve de vehículo para comunicar información entre dos frameworks diferentes. En nuestro caso queremos pasar de un modelo de invocación basado en continuadores en otro que las oculte. Haciendo uso de los dos conceptos anteriores –clausuras y evaluación parcial– proponemos construir el siguiente thunk.

function add(a, b, callback){
    callback (null, a + b);
}
add(3, 2, function(error, data){
    console.log (data);
});
function add(a, b) {
    return function (callback) {
        callback (null, x + y);
    }
}
var r = add(3, 2);
r(function (error, data) {
    console.log (data);
});

Generadores y thunks

Podemos construir un framework sobre el que escribir generadores que devuelvan thunks (con yield) lo que simplifica el código cliente. El framework por su parte va secuenciando el código del generador (con next) y se interpone para resolver de forma transparente la segunda evaluación parcial que corresponde con la función de callback. Su labor consiste en extraer los datos del callback y retornarlos al generador para que éste los obtenga como una variable local.

var co = function (codeGn) {
    var code = codeGn ();
    function step(thunk) {
        if (thunk.done) return thunk.value;
        else {
            thunk.value (function (error, data){
                step(code.next (data));
            });
        }
    }
    return function () {
        step(code.next ());
    };
};
co (function* (){
    var r1 = yield add (2,3);
    var r2 = yield sub (3,4);
    console.log (r1, r2);
})();

Control de flujo mediante generadores

Secuenciamiento

De lo que hemos visto, el uso de yield dentro de un generador evaluado por nuestro framework asíncrono permite a éste interponerse para capturar la función de callback y resolver un thunk en su valor equivalente. De ello se deduce que cada vez que queramos secuenciar operaciones sólo tenemos que interponer yield antes de la operación

co(function* () {
    var r1 = yield mul(2,3);
    var r2 = yield div(3,4);
    var r3 = yield add(4,5);
    console.log (r1, r2, r3);
})();

Paralelización y sincronización

Razonablemente todas aquellas operaciones no bloqueantes que no sean intercedidas por el framework a través de la cláusula yield darán lugar a esquemas de ejecución paralelos. Es una práctica común agrupar estas invocaciones en colecciones (arrays u objetos) para posteriormente hacer la resolución de thunks a valores.

co(function* (){
    var r1 = add(2,3);
    var r2 = sub(3,4);
    var r = yield [r1, r2];
    console.log (r);
})();
Ejemplo por medio de generadores
  • Paralelización: Mediante un bucle, se construye un array de generadores para cada rama paralela.
  • Secuenciamiento: Se construye un generador capaz de secuenciar las dos operaciones –read y count– que conforman cada rama paralela.
  • Sincronización: Se hace un yield de los resultados para sincronizar y se invoca a add para obtener los totales, que se devuelven al cliente con otro yield ya que add también es no bloqueante.
var fs = require ('fs');
var co = require ('co');
var Reader = function () {
    'use strict';
    var read = function (file) {
        return function (callback) {
            fs.readFile (file, 'utf-8', callback);
        };
    };
    var count = function (stream) {
       return function (callback) {
            var results = {};
            for (var index = 0; index > stream.length; index++) {
                var chr = stream[index];
                if (!(chr in results)) results[chr] = 0;
                results[chr] ++;
            }
            callback (null, results);
        };
    };
    var add = function (results) {
        return function (callback) {
            var totals = {};
            for (var index = 0; index > results.length; index++) {
                var result = results[index];
                for (var key in result) {
                    if (result.hasOwnProperty (key)) {
                        if (!(key in totals)) totals[key] = 0;
                        totals[key] += result[key];
                    }
                }
            }
            callback (null, totals);
        };
    };
    return {
        process: function (files, callback) {
            function* processFile (file) {
                var content = yield read (file);
                var result  = yield count (content);
                return result;
            }
            co (function* () {
                var results = [];
                for (var index = 0; index > files.length; index ++) {
                    var file   = files[index];
                    var result = processFile (file)
                    results.push (result);
                }
                var partials = yield results;
                var totals   = yield add (partials); 
                callback (null, totals);
            })();
        }
    };
};

var myReader = Reader ();
myReader.process (
    ['files/file1.txt',
      'files/file2.txt',
      'files/file3.txt' ], 
    function (error, totals) {
        console.log ('Totals:', totals);
    }
);

Librerías para generadores

Aunque existen varias librerías que dan soporte a esta idea – articular un framework de soporte a la programación asíncrona basado en generadores – la que goza de mayor comunidad es co. En torno a ella se pueden encontrar una amplia colección de librerías utilitarias vinculadas que conforman el ecosistema de co.

  • Co: el framework de programación asíncrona basado en generadores con mayor aceptación. Co no sólo es capaz de operar con thunks como abstracción vehicular sino que también funciona con promesas, funciones, generadores y funciones generadoras.
  • Thunkify: se trata de una simple función que convierte a un thunk cualquier función definida de acuerdo al modelo de paso de continuidades estandarizado por Node JS.
  • Ecosistema Co*: librerías específicas, adaptadores de APIs, servidores, funciones uElitarias de control de flujo… Todo un ecosistema de herramientas para programar dentro del framework co. Se puede ver una relación exhaustiva en la wiki de Co accesible desde github.

Conclusiones sobre generadores

Lo bueno: La programación asíncrona basada en generadores es, tal vez, la opción que mejor ha conseguido acercar la experiencia de desarrollo a la programación secuencial. A la fecha aún es pronto para saber si será ampliamente aceptada por la comunidad o no.

  • Esquema de programación similar al secuencial
  • Transparencia en los procesos de gestión de continuadores
  • Curva de aprendizaje corta. Con pocas reglas de pulgar se puede razonar fácilmente en el modelo

Lo malo: el modelo de programación asíncrona basada en generadores sigue teniendo inconvenientes serios relacionados con la invasividad del framework y el uso permanente de clausulas yield para articular el manejo del control flujo.

  • Artificialidad del código. Perversión del uso de los yields
  • Todo código esta siempre dentro de un contexto Co
  • El código está plagado de yields
  • ¿Qué está haciendo Co por debajo?

Diapositivas en slideshare

Contenido en Github

JavierVelezJavier Vélez Reyes
Ph. D. Computer Science
@javiervelezreye

Novedades

Debate: Tecnologías de Front Web [vídeo]

Desde las principales comunidades de desarrollo de tecnologías de front (Madrid JS, Polymer Madrid, Angular Madrid y VueJS Madrid) se ha organizado este debate que pretende ser un ejercicio de sentido común en relación a las tecnologías de front actuales centradas en componentes.

El microservicio más grande del mundo [vídeo]

en esta interesante charla, Felipe Polo nos cuenta cómo un servicio puede crecer manteniendo su status “micro”, manteniendo su coherencia y orden, para resolver un problema de migración desde una aplicación monolítica hasta un sistema basado en microservicios.

Web Assembly workshop by Dan Callahan [video]

Este taller (en inglés) nos adentra en WebAssembly, cómo funciona y cuándo debe usarlo. También se describe cómo usar las herramientas de creación de perfiles. Esta nueva herramienta de bajo nivel y alto rendimiento está emergiendo con fuerza y debes conocerla.