Conceptos básicos de la programación asíncrona

La parte central de un ordenador, o sea, la parte que lleva a cabo los pasos individuales que componen nuestros programas, se llama procesador. Los programas que hemos visto hasta ahora son cosas que mantendrán ocupado al procesador hasta que hayan terminado su trabajo. La velocidad a la que se puede ejecutar algo como un bucle que manipula los números depende en gran medida de la velocidad del procesador.

Pero muchos programas interactúan con cosas externas al procesador. Por ejemplo, pueden comunicarse a través de una red de computadoras o solicitar datos del disco duro, lo cual es mucho más lento que obtenerlos de la memoria.

Cuando algo así está sucediendo, sería una lástima dejar al procesador inactivo, ya que podría haber algún otro trabajo que hacer mientras tanto. En parte, esto es manejado por tu sistema operativo, que cambiará el procesador entre múltiples programas en ejecución. Pero eso no ayuda cuando queremos que un solo programa pueda progresar mientras espera una petición de red.

La asincronía

En un modelo de programación sincrónica, las cosas suceden una a la vez. Cuando se llama a una función que realiza una acción de larga duración, sólo vuelve cuando la acción ha finalizado y puede devolver el resultado. Esto detiene el programa durante el tiempo que dure la acción.

Un modelo asincrónico permite que sucedan múltiples cosas al mismo tiempo. Al iniciar una acción, el programa continúa ejecutándose. Cuando la acción termina, el programa es informado y tiene acceso al resultado (por ejemplo, los datos leídos desde el disco).

Podemos comparar la programación síncrona y asíncrona usando un pequeño ejemplo: un programa que obtiene dos recursos de la red y luego combina resultados.

En un entorno sincrónico, donde la función de petición sólo vuelve después de haber hecho su trabajo, la forma más fácil de realizar esta tarea es hacer las peticiones una tras otra. Esto tiene el inconveniente de que la segunda solicitud se iniciará sólo cuando la primera haya terminado. El tiempo total necesario será como mínimo la suma de los dos tiempos de respuesta.

La solución a este problema, en un sistema síncrono, es poner en marcha hilos de control adicionales. Un hilo es otro programa en ejecución cuya ejecución puede ser entrelazada con otros programas por el sistema operativo; ya que la mayoría de los ordenadores modernos contienen múltiples procesadores, múltiples hilos pueden incluso ejecutarse al mismo tiempo, en diferentes procesadores. Un segundo hilo podría iniciar la segunda petición, y luego ambos hilos esperan a que sus resultados vuelvan, después de lo cual se re-sincronizan para combinar sus resultados.

En el modelo sincrónico, el tiempo que toma la red es parte de la línea de tiempo para un hilo de control dado. En el modelo asíncrono, el inicio de una acción de red causa conceptualmente una división en la línea de tiempo. El programa que inició la acción continúa ejecutándose, y la acción ocurre junto a él, notificando al programa cuando finaliza.

Otra forma de describir la diferencia es que esperar a que las acciones terminen está implícito en el modelo síncrono, mientras que es explícito, bajo nuestro control, en el asíncrono.

La asincronía corta en ambos sentidos. Facilita la expresión de programas que no encajan en el modelo de control de línea recta, pero también puede hacer que los programas de expresión que siguen una línea recta sean más incómodos. Veremos algunas maneras de abordar esta incomodidad más adelante en este capítulo.

Las dos importantes plataformas de programación JavaScript (navegadores y Node.js) realizan operaciones que pueden tardar un tiempo en ser asincrónicas, en lugar de depender de sub-procesos. Dado que la programación con hilos es notoriamente difícil (entender lo que hace un programa es mucho más difícil cuando está haciendo múltiples cosas a la vez), esto generalmente se considera algo bueno.

Tecnología Crow

La mayoría de la gente es consciente del hecho de que los cuervos son aves muy inteligentes. Pueden usar herramientas, planear con anticipación, recordar cosas e incluso comunicarlas entre ellos.

Lo que la mayoría de la gente no sabe es que son capaces de muchas cosas que mantienen bien ocultas de nosotros. Un reputado (aunque un tanto excéntrico) experto en córvidos me ha dicho que la tecnología de los cuervos no está muy por debajo de la tecnología humana, y que se están poniendo al día.

Por ejemplo, muchas culturas de cuervos tienen la capacidad de construir dispositivos informáticos. Estos no son electrónicos, como lo son los dispositivos informáticos humanos, sino que operan a través de las acciones de pequeños insectos, una especie estrechamente relacionada con la termita, que ha desarrollado una relación simbiótica con los cuervos. Las aves les proporcionan alimento, y a cambio los insectos construyen y operan sus complejas colonias que, con la ayuda de los seres vivos que hay en su interior, realizan cálculos.

Estas colonias suelen estar ubicadas en nidos grandes y longevos. Las aves y los insectos trabajan juntos para construir una red de estructuras de arcilla bulbosa, escondidas entre las ramitas del nido, en las que los insectos viven y trabajan.

Para comunicarse con otros dispositivos, estas máquinas utilizan señales luminosas. Los cuervos incrustan piezas de material reflectante en tallos especiales de comunicación, y los insectos las dirigen para reflejar la luz en otro nido, codificando los datos como una secuencia de destellos rápidos. Esto significa que sólo los nidos que tienen una conexión visual ininterrumpida pueden comunicarse.

Nuestro amigo el experto en córvidos ha cartografiado la red de nidos de cuervo en el pueblo de Hières-sur-Amby, a orillas del río Ródano.

En un asombroso ejemplo de evolución convergente, las computadoras de cuervo ejecutan JavaScript. En este capítulo escribiremos algunas funciones de red básicas para ellos.

Callbacks

Un enfoque de la programación asíncrona es hacer que las funciones que realizan una acción lenta tomen un argumento extra, una función de callback. La acción se inicia, y cuando finaliza, se llama a la función de callback con el resultado.

Por ejemplo, la función setTimeout, disponible tanto en Node.js como en los navegadores, espera un número determinado de milisegundos (un segundo es mil milisegundos) y luego llama a una función.

setTimeout(() => console.log("Tick"), 500);

Por lo general, la espera no es un tipo de trabajo muy importante, pero puede ser útil cuando se hace algo como actualizar una animación o comprobar si algo está llevando más tiempo que una cantidad de tiempo determinada.

Realizar varias acciones asíncronas en una fila utilizando la función de callback significa que tiene que seguir pasando nuevas funciones para manejar la continuación del cálculo después de las acciones.

La mayoría de las computadoras de nido de cuervo tienen una bombilla de almacenamiento de datos a largo plazo, donde las piezas de información se graban en ramitas para que puedan ser recuperadas más tarde. El grabado, o la búsqueda de un dato, lleva un momento, por lo que la interfaz para el almacenamiento a largo plazo es asíncrona y utiliza funciones de callback.

Las bombillas de almacenamiento almacenan los datos codificados por JSON bajo nombres. Un cuervo puede almacenar información sobre los lugares donde se esconde la comida bajo el nombre de «cachés de comida», que puede contener una serie de nombres que apuntan a otras piezas de datos, describiendo la caché real. Para buscar un caché de comida en los bulbos de almacenamiento del nido de roble grande, un cuervo podría ejecutar un código como este:

import {bigOak} from "./crow-tech";

bigOak.readStorage("food caches", caches => {
let firstCache = caches[0];
bigOak.readStorage(firstCache, info => {
console.log(info);
});
});

(Todos los nombres y cadenas de caracteres han sido traducidos del idioma crow al inglés.

Este estilo de programación es factible, pero el nivel de indentación aumenta con cada acción asíncrona porque se termina en otra función. Hacer cosas más complicadas, como ejecutar varias acciones al mismo tiempo, puede resultar un poco incómodo.

Las computadoras de nido de cuervo están construidas para comunicarse usando pares de solicitud-respuesta. Esto significa que un nido envía un mensaje a otro nido, el cual inmediatamente envía un mensaje de vuelta, confirmando la recepción y posiblemente incluyendo una respuesta a una pregunta formulada en el mensaje.

Cada mensaje se etiqueta con un tipo, que determina cómo se maneja. Nuestro código puede definir manejadores para tipos específicos de peticiones, y cuando se recibe una solicitud de este tipo, el manejador es llamado para producir una respuesta.

La interfaz exportada por el módulo «./crow-tech» proporciona funciones basadas en la callback para la comunicación. Los nidos tienen un método de envío que envía una petición. Espera el nombre del nido de destino, el tipo de solicitud y el contenido de la solicitud como sus tres primeros argumentos, y espera que una función llame cuando se reciba una respuesta como su cuarto y último argumento.

bigOak.send("Cow Pasture", "note", "Let's caw loudly at 7PM",
() => console.log("Note delivered."));

Pero para hacer nidos capaces de recibir esa petición, primero tenemos que definir un tipo de petición llamado «nota». El código que maneja las peticiones tiene que ejecutarse no sólo en este nido-computadora sino en todos los nidos que puedan recibir mensajes de este tipo. Asumiremos que un cuervo vuela e instala nuestro código en todos los nidos.

import {defineRequestType} from "./crow-tech";

defineRequestType("note", (nest, content, source, done) => {
console.log(`${nest.name} received note: ${content}`);
done();
});

La función defineRequestType define un nuevo tipo de solicitud. El ejemplo añade soporte para peticiones de «nota», que simplemente envía una nota a un nido dado. Nuestra implementación llama a console.log para que podamos verificar que la solicitud llegó. Los nidos tienen una propiedad con un nombre que contiene su nombre.

El cuarto argumento dado al handler, done, es una función de callback a la que debe llamar cuando termina la petición. Si hubiéramos utilizado el valor de retorno del controlador como valor de respuesta, esto significaría que un controlador de peticiones no puede realizar acciones asíncronas. Una función que realiza trabajo asíncrono normalmente vuelve antes de que el trabajo esté terminado, habiendo dispuesto que se llame a una callback cuando se complete. Así que necesitamos algún mecanismo asíncrono (en este caso, otra función de callback) para señalar cuando hay una respuesta disponible.

En cierto modo, la asincronía es contagiosa. Cualquier función que llame a una función que trabaje asincrónicamente debe ser asincrónica, utilizando un mecanismo de callback o similar para entregar su resultado. Llamar a una callback es algo más complicado y propenso a errores que simplemente devolver un valor, por lo que no es bueno tener que estructurar grandes partes de su programa de esa manera.

Promesas

Trabajar con conceptos abstractos es a menudo más fácil cuando esos conceptos pueden ser representados por valores. En el caso de acciones asincrónicas, en lugar de organizar la llamada de una función en algún momento futuro, se puede devolver un objeto que represente este evento futuro.

Para esto es la clase estándar de Promesa. Una promesa es una acción asincrónica que puede completarse en algún momento y producir un valor. Es capaz de notificar a cualquier persona que esté interesada cuando su valor esté disponible.

La manera más fácil de crear una promesa es llamando a Promise.resolve. Esta función asegura que el valor que se le da esté envuelto en una promesa. Si ya es una promesa, simplemente se devuelve; de lo contrario, se obtiene una nueva promesa que inmediatamente termina con su valor como resultado.

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

Para obtener el resultado de una promesa, puedes usar su método de entonces. Esto registra una función de callback para ser llamada cuando la promesa se resuelve y produce un valor. Puede agregar varias callbacks a una sola promesa, y serán llamadas, incluso si las agrega después de que la promesa ya se haya resuelto (terminado).

Pero eso no es todo lo que el método hace entonces. Devuelve otra promesa, que resuelve el valor que la función del controlador devuelve o, si eso devuelve una promesa, espera esa promesa y luego resuelve su resultado.

Es útil pensar en las promesas como un dispositivo para trasladar los valores a una realidad asincrónica. Un valor normal está simplemente ahí. Un valor prometido es un valor que puede que ya exista o que pueda aparecer en algún momento en el futuro. Los cálculos definidos en términos de promesas actúan sobre estos valores envueltos y se ejecutan asincrónicamente a medida que los valores están disponibles.

Para crear una promesa, puede utilizar Promise como constructor. Tiene una interfaz un tanto extraña: el constructor espera una función como argumento, que llama inmediatamente, pasándole una función que puede usar para resolver la promesa. Funciona de esta manera, en lugar de, por ejemplo, con un método de resolución, de modo que sólo el código que creó la promesa puede resolverla.

Así es como se crea una interfaz basada en promesas para la función readStorage:

function storage(nest, name) {
return new Promise(resolve => {
nest.readStorage(name, result => resolve(result));
});
}

storage(bigOak, "enemies")
.then(value => console.log("Got", value));

Esta función asíncrona devuelve un valor significativo. Esta es la principal ventaja de las promesas: simplifican el uso de funciones asíncronas. En lugar de tener que pasar callback, las funciones basadas en promesas se ven similares a las normales: toman los datos como argumentos y devuelven sus resultados. La única diferencia es que la salida puede no estar disponible todavía.

Fracaso

Los cálculos regulares de JavaScript pueden fallar si se hace una excepción. Los cálculos asíncronos a menudo necesitan algo así. Una solicitud de red puede fallar, o algún código que forme parte del cálculo asíncrono puede ser una excepción.

Uno de los problemas más apremiantes con el estilo de callback de la programación asíncrona es que hace que sea extremadamente difícil asegurarse de que las fallas se informen adecuadamente a las callbacks.

Una convención ampliamente utilizada es que el primer argumento de la callback se utiliza para indicar que la acción falló, y el segundo contiene el valor producido por la acción cuando fue exitosa. Estas funciones de callback siempre deben comprobar si recibieron una excepción y asegurarse de que cualquier problema que causen, incluidas las excepciones lanzadas por las funciones a las que llaman, sean detectadas y asignadas a la función correcta.

Las promesas lo hacen más fácil. Pueden ser resueltos (la acción terminó con éxito) o rechazados (falló). Los manejadores de Resolve (como se registraron en ese entonces) son llamados sólo cuando la acción es exitosa, y los rechazos son propagados automáticamente a la nueva promesa que es devuelta para entonces. Y cuando un handler lanza una excepción, automáticamente hace que la promesa producida por su llamada sea rechazada. Así que si cualquier elemento en una cadena de acciones asíncronas falla, el resultado de toda la cadena se marca como rechazado, y no se llama a ningún manejador de éxito más allá del punto en el que falló.

De la misma manera que resolver una promesa proporciona un valor, rechazar una también proporciona uno, usualmente llamado la razón del rechazo. Cuando una excepción en una función de controlador causa el rechazo, se utiliza el valor de excepción como motivo. De manera similar, cuando un manejador devuelve una promesa que es rechazada, ese rechazo fluye hacia la siguiente promesa. Hay una función Promise.reject que crea una nueva promesa, inmediatamente rechazada.

Para manejar explícitamente tales rechazos, las promesas tienen un método de captura que registra a un manejador para que sea llamado cuando la promesa es rechazada, de manera similar a como los manejadores manejan la resolución normal. También es muy parecido a eso en el sentido de que devuelve una nueva promesa, que se resuelve al valor de la promesa original si se resuelve normalmente y al resultado del manipulador de la captura en caso contrario. Si un gestor de capturas tira un error, también se rechaza la nueva promesa.

Como abreviatura, también acepta un manejador de rechazo como segundo argumento, por lo que puede instalar ambos tipos de manejadores en una sola llamada a un método.

Una función pasada al constructor de la Promesa recibe un segundo argumento, junto con la función de resolución, que puede utilizar para rechazar la nueva promesa.

Las cadenas de valores de promesa creadas por las llamadas a ese momento y la captura pueden ser vistas como una tubería a través de la cual se mueven valores asincrónicos o fallas. Puesto que estas cadenas se crean registrando a los manejadores, cada eslabón tiene un manejador de éxito o un manejador de rechazo (o ambos) asociados con él. Los manejadores que no coinciden con el tipo de resultado (éxito o fracaso) son ignorados. Pero los que sí coinciden son llamados, y su resultado determina qué tipo de valor viene después: el éxito cuando devuelve un valor sin promesa, el rechazo cuando lanza una excepción, y el resultado de una promesa cuando devuelve uno de esos.

new Promise((_, reject) => reject(new Error("Fail")))
.then(value => console.log("Handler 1"))
.catch(reason => {
console.log("Caught failure " + reason);
return "nothing";
})
.then(value => console.log("Handler 2", value));
// → Caught failure Error: Fail
// → Handler 2 nothing

Al igual que una excepción no capturada es manejada por el entorno, los entornos JavaScript pueden detectar cuando no se maneja un rechazo de promesa y lo reportarán como un error.