Nonsense

¿Por qué hay tantos mecanismos de asincronía?

Pregunta: ¿Por qué tiene el lenguaje tantos mecanismos de asincronía (callbacks, promesas, async/await)? Y ya puestos ¿qué diferencias hay entre ellos o ventajas que tiene cada uno?

(Sí, estoy haciendo un poco de trampa con esta pregunta. Nadie me ha preguntado esto directamente aunque sí que he recibido preguntas sobre algunas de las partes involucradas. Creo que es buena idea verlo todo junto, aunque haciéndolo así sé que, seguramente, saldrá un escrito bastante extenso.)

La naturaleza de JavaScript

Si intentamos leer por ahí explicaciones sobre la naturaleza de JavaScript como lenguaje, lo más probable es que encontremos diferentes descripciones, generalmente teñidas en algún grado por los intereses que quiera presentar el escrito. Así encontramos gente que habla de la naturaleza funcional del lenguaje, o de su naturaleza orientada a objetos, o de su naturaleza dinámica, o de si es interpretado o compilado, de cómo su naturaleza es ser un lenguaje “sencillo”, “fácil de aprender” o cómo está pensado para fallar silenciosamente. Si acudimos a algún libro, quizá encontremos mencionados unos cuantos o quizá incluso todos estos aspectos.

Sin embargo, hay un aspecto que marca al lenguaje de una forma bastante fundamental y que no suele ser mencionada tan frecuentemente. 1)

Es clave, para entender JavaScript, y para entender los temas de asincronía, que es un lenguaje diseñado para vivir dentro de un modelo de ejecución muy específico. Es un lenguaje que se diseña para vivir dentro de un documento.

Y aunque posteriormente se extrajera de ahí y se le proporcionara la posibilidad de ejecutarse como lenguaje de script en general o con unas librerías de entrada/salida más habituales como haría NodeJS, esa idea original de vivir dentro de un documento siempre ha marcado uno de los aspectos más fundamentales de su naturaleza: Es un lenguaje con un modelo de ejecución dirigido por eventos.

Esto no sólo marcará su diseño y evolución en aspectos técnicos como, por ejemplo, ser mono-hilo, sino que define de forma crítica la filosofía de uso del lenguaje y el diseño de aplicaciones.

Eventos => Asincronía

Así, de un modo inevitable, derivado de ese aspecto intrínseco al diseño del lenguaje, la asincronía resulta ser un aspecto clave y definitorio de la naturaleza de JavaScript. Porque, lógicamente, al ser dirigido por eventos, todo el modelo de ejecución se diseña para ser asíncrono.

Hay buenas explicaciones en algún lugar de la red, del modelo de ejecución de JavaScript, la cola de tareas/mensajes, el bucle de eventos, la influencia de HyperTalk y demás. No tiene mucho interés hablar de ello aquí 2) pero solo porque ya hay mucho escrito y suficientemente bueno. Así que si alguien quiere o necesita más información sobre ello, que no dude en buscarlo; es un tema interesante.

Sea como sea, la conclusión es clara: La asincronía es un aspecto fundamental en JavaScript y, por tanto, es de esperar que proporcione buenos mecanismos para utilizarla correctamente. Y de ahí la primera conclusión: Hay varios mecanismos porque es algo importante.

I want you back

Lo cierto es que el concepto de callbacks no está necesariamente ligado al concepto de asincronía. Podemos utilizar callbacks de un modo totalmente síncrono, y en muchos casos así se hace 3).

Siendo precisos, realmente lo que proporciona la asincronía no son los callbacks en sí o el paso funciones como valores, sino que esto es sólo el mecanismo básico que lo facilita. Lo que originalmente nos permite utilizar la asincronía en JavaScript es un modelo de Eventos y Observadores. Es decir, lo que vemos y en lo que finalmente se fijó la gente, es en el mecanismo simple de funciones como valores que pueden, entonces, ser pasadas como parámetros a otras funciones. Pero como decía esto no implica en sí mismo, la asincronía.

function doThings(first, second, third) {
    let r = first();
    if (Math.random()>0.3) {
        second(r);
    }
    return third(r);
}

doThings(() => 9, console.log, x => 2*x);

someArray.sort( (a,b) => 2*a - b );

Lo que realmente nos da la asincronía son las APIs de gestión de eventos del DOM y de creación de eventos temporales 4). Y lo que ocurre es que estas APIs están basadas en observadores y utilizan como mecanismo básico el paso de funciones (que serán los listeners/observadores). Y hacen esto no sólo porque el paso de funciones como valores es un mecanismo fundamental del lenguaje sino también porque, en ese diseño inicial es el único mecanismo disponible.

someElement.addEventListener('click', someHandlerFunction);
// Si nos remontamos a un pasado más lejano podríamos pensar en...
someOtherElement.onclick = someHandlerFunction;
// ...pero la idea es similar.

Alguna de las APIs, como es el caso de XMLHTTPRequest, aplican un patrón más parecido a una Estrategia, pero en cualquier caso lo que se ve es el paso de funciones.

El problema que existe con esta aproximación es que, en general la mayoría de las personas están acostumbradas a razonar sobre procesos de una forma lineal.

La ruptura del flujo lineal

En narrativa existen muchas herramientas a disposición del autor, pero este debe saber usarlas si quiere comunicar correctamente con su audiencia. Algunas de estas herramientas, como por ejemplo las escenas retrospectivas (flashbacks), rompen la linealidad temporal de la narrativa. William Gibson, por poner un ejemplo, usa en un buen número de sus novelas una estructura de líneas temporales que discurren en paralelo y según avanza la historia algunas de ellas confluyen. En alguna ocasión, da un giro diferente y aunque la narrativa confluya, algunas líneas resultan no ser paralelas en absoluto. Creo que a Gibson esto le funciona porque tiene ya establecida una audiencia que ha ido asumiendo esas estructuras novela tras novela.

Es conocido que Jorge Luis Borges, tras ver Cuidadano Kane, criticó duramente a Orson Welles precisamente por pretender de la audiencia que hiciera el esfuerzo de reordenar y combinar todos los fragmentos de la vida de Kane para darle una forma. En general, cuando una obra narrativa opta por una ruptura más o menos fuerte del flujo lineal, incluso cuando lo hace muy bien, supone siempre un esfuerzo notable de comprensión y atención para la audiencia. Si esto ya es problemático cuando la narrativa es no-lineal, el problema es muchísimo peor cuando la propia historia en sí es no-lineal. Por esto mismo las historias sobre viajes en el tiempo, incluso cuando están muy bien construidas, se hacen muy complicadas de seguir para mucha gente.

Cuando contamos historias a veces puede ser una opción muy válida y a veces puede incluso producir un gran resultado. Cuando estamos hablando de comunicar un proceso, que es mayormente lo que hacemos al programar, las cosas son algo más difíciles.

La programación dirigida por eventos rompe por completo el flujo lineal de un proceso en actividades u ocurrencias más pequeñas, separadas unas de otras. Incluso dejando a un lado la asincronía, el paso de funciones también rompe la linealidad de ese flujo. Leemos un código y sabemos que la ejecución temporal de ese código no coincide con el orden en que están escritas las líneas del código.

Love is a battlefield

Las cosas podrían haber evolucionado de muy diferentes formas. En una realidad alternativa, el modelo dirigido por eventos podría haber triunfado. El modelo es bastante apropiado para la creación de interfaces gráficas 5). Pero uniendo su parte de complejidad a otras complejidades añadidas de la plataforma, finalmente los desarrolladores intentaron buscar otras opciones 6).

Hay que entender eso sí, que la aparición de las Promesas ni supone un nuevo modelo de asincronía ni es eso lo que se pretende. Lo único que intentan hacer las promesas es intentar re-invertir la inversión del control del flujo de ejecución que se produce con los callbacks. En esta clásica discusión 7) lo llamaban “imitar el modelo de control de flujo síncrono de los lenguajes imperativos”. Más claro es difícil.

¿Qué quiere decir esto?

Cuando usamos callbacks, lo que estamos haciendo, en esencia, es delegar el control de la ejecución de una parte del código cliente a la función a la que llamamos. Para entendernos, llamo código cliente a “nuestro” código, el código del proceso que estamos escribiendo. Y en un momento dado ese código necesita llamar a una función (probablemente asíncrona, aunque no necesariamente). En esa llamada, además de los propios datos de la llamada, pasamos nuestro callback. Pues bien, al pasar ese callback lo que estamos haciendo en realidad es encapsular la continuación de nuestro proceso y pasársela a la función asíncrona 8). Y será esta la que, cuando determine oportuno, ejecutará nuestra continuación. Es decir, hemos delegado en esa función la responsabilidad 9) de continuar ejecutando nuestro proceso.

Es exactamente esa delegación de control la que intentan re-invertir las promesas.

Y esto, ¿cómo ocurre?

El problema básico de la asincronía es que queremos obtener un cierto valor para continuar con nuestro proceso pero hay que “esperar” para obtener ese valor y no sabemos cuánto. Por eso, la idea de pasar la continuación a la función que obtiene ese valor y que sea ella quien nos continúe cuando tenga el valor.

En lugar de esto, lo que propone el uso de promesas es que la función asíncrona devuelva algo, lo que sea, algún tipo de entidad, aunque no tenga aún el valor buscado y que lo haga de forma síncrona. Esto, inicialmente puede sonar algo desconcertante, pero es en realidad bastante simple. De lo que se trata es de separar la operación asíncrona. Esta queda encolada y se resolverá cuando sea. Pero por otro lado, de forma “inmediata” o síncrona, la función a la que hemos llamado nos devuelve, por ejemplo, un objeto.

Sabemos que -lógicamente- en el código cliente no tenemos el valor aún, pero sin embargo, lo que sí hemos recuperado es el control de la ejecución. Y así hemos conseguido re-invertir ese control.

Claro, si no hay nada más, esto no funciona en absoluto. Porque tenemos el control, sí, pero no tenemos el valor que queríamos y no sabemos cómo tenerlo. Pero, por suerte, si que hay algo más. El objeto que nos devuelve la función asíncrona es un contenedor. Ahora mismo no contiene el valor deseado, pero la función asíncrona conserva una referencia a ese objeto y cuando tenga el valor no sólo puede introducirlo en el objeto, sino que además puede lanzar algún tipo de evento o notificación sobre ese objeto para que el código cliente sepa que ya está disponible. En el fondo, lo que hemos hecho es introducir entre el código cliente y la función asíncrona, un intermediario, un sujeto observable sobre el que ambos pueden actuar para comunicarse, pero que claramente extrae la responsabilidad de continuar fuera de la función asíncrona. Ya no es necesario pasar un callback y delegar esa responsabilidad sobre la función asíncrona. Ahora lo hacemos sobre el objeto intermediario, la Promesa.

A victory of love

Las promesas consiguen realizar esa recuperación del control de ejecución, y durante un cierto tiempo, el populacho se recrea en festejos y celebraciones. Sin embargo, pronto se dan cuenta de que no todo está “solucionado” aún. La disposición del código sigue reflejando su naturaleza asíncrona y sigue, en consecuencia, mostrando diferencias entre el orden temporal de ejecución y el orden de lectura del código 10).

Pero esa inversión de la responsabilidad es, en realidad, muy significativa. Gracias a ella, el último paso de los secuencialistas se convierte en una transformación del código totalmente local. Es decir, el paso desde la recepción y escucha de la promesa al await es una transformación que únicamente afecta al código cliente, no a la función llamada. Es haber dado el paso anterior, el de las promesas, lo que permite dar ahora el paso a await.

En cuanto al motivo, este es claro: Ahora se trata de recuperar la secuencialidad lineal de la narrativa del código cliente. Es decir, await se trata de hacer que el código asíncrono 11) se lea como si fuera secuencial. 12)

The end of the world

Para terminar, a modo de resumen, podríamos decir que estos son los porqués de la asincronía 13):

  • ¿Por qué asincronía? Porque es fundamental para un modelo de ejecución dirigido por eventos e insertado en un documento.
  • ¿Por qué callbacks? Eso es sólo un mecanismo básico, pasar funciones. La pregunta “correcta” es ¿por qué observers? y la respuesta entonces es porque es el patrón más inmediato en un sistema dirigido por eventos.
  • ¿Por qué promesas? Porque pasar funciones “accidentalmente” invierte la responsabilidad y el control sobre el flujo de ejecución. Las promesas introducen un intermediario para re-invertir ese control de nuevo.
  • ¿Por qué async/await? Porque aunque se haya re-invertido el control sobre el flujo de ejecución, con las promesas la lectura del código sigue siendo no-secuencial y la mayoría de la gente 14) prefiere una presentación narrativa secuencial.

En el fondo todo es una lucha de tendencias. Ahora los secuencialistas parecen haber impuesto su visión de forma mayoritaria, pero como ya sabemos el diseño de software va y viene y evoluciona y vuelve y recupera y vuelve a olvidar. Quizá otras aproximaciones, como los sistemas de actores, por decir algo, tengan un día más relevancia y las narrativas no-secuenciales se vuelvan populares. ¿Quién sabe? 15)

1)
Quizá porque unos no se lo plantean y otros la dan por supuesta.
2)
To do: No buscar algunos enlaces a la MDN, al libro de Rauschma, al blog de Mr.Aleph, a otros libros… y luego no ponerlos aquí
4)
setTimeout, setInterval
5)
Y de hecho se ha usado en muchos sistemas de interfaces gráficas.
6)
No promises, no demands… Love is a battlefield - Pat Benatar
7)
Que después provocaría el nacimiento de Fantasy Land, así que no todo es malo
8)
probablemente asíncrona
9)
y el control
10)
Waiting for a change in the weather… A victory of love - Alphaville
11)
Probablemente asíncrono
12)
El porqué de async es más banal. No tiene una motivación intrínseca; es solo un sacrificio técnico necesario para marcar que una función va a necesitar la transformación de await.
14)
*sigh*
15)
Lamentablemente yo no cuento con llegar a ver ese día xD