Programación asincrónica en Dart: Isolates y event loop

Dart, a pesar de ser un lenguaje single-threaded, ofrece soporte para futures, streams, trabajo en segundo plano y todas las demás cosas que necesitas para…

Dart, a pesar de ser un lenguaje de un solo hilo, ofrece soporte para futures, streams, trabajo en segundo plano, y todas las demás cosas que necesitas para escribir de forma moderna, asíncrona, y (en el caso de Flutter) reactiva. Este artículo cubre los fundamentos del soporte de Dart para el trabajo en segundo plano: isolates y event loops.

Si prefieres aprender viendo o escuchando, todo lo que se trata en este artículo está cubierto en el siguiente video, que es parte de la serie de videos Flutter in Focus Asynchronous Programming in Dart:

Ver en YouTube en una nueva pestaña: "Isolates and Event Loops - Flutter in Focus"

¿Todavía aquí? Hablemos sobre los isolates.

Isolates

#

Un isolate es donde se ejecuta todo el código de Dart. Es como un pequeño espacio en la máquina con su propia porción privada de memoria y un único hilo ejecutando un event loop.

Tarjeta de un único isolate que contiene una rejilla de memoria sobre flechas circulares de event loop.
Un isolate tiene su propia memoria y un único hilo de ejecución que ejecuta un event loop.

En muchos otros lenguajes como C++, puedes tener múltiples hilos compartiendo la misma memoria y ejecutando el código que quieras. En Dart, sin embargo, cada hilo está en su propio isolate con su propia memoria, y el hilo simplemente procesa eventos (más sobre eso en un minuto).

Muchas apps de Dart ejecutan todo su código en un único isolate, pero puedes tener más de uno si lo necesitas. Si tienes un cálculo que realizar tan enorme que podría causarte pérdida de frames si se ejecutara en el isolate principal, entonces puedes usar Isolate.spawn() o la función compute() de Flutter. Ambas funciones crean un isolate separado para hacer el trabajo pesado de cálculo, dejando tu isolate principal libre para — digamos — reconstruir y renderizar el árbol de widgets.

Dos tarjetas de isolate separadas, cada una con su propia rejilla de memoria y flechas de event loop.
Dos isolates, cada uno con su propia memoria e hilo de ejecución.

El nuevo isolate obtiene su propio event loop y su propia memoria, a la cual el isolate original — aunque sea el padre de este nuevo — no tiene permitido acceder. De ahí viene el nombre isolate: estos pequeños espacios se mantienen aislados unos de otros.

De hecho, la única forma en que los isolates pueden colaborar es pasándose mensajes mutuamente. Un isolate envía un mensaje al otro, y el isolate receptor procesa ese mensaje usando su event loop.

Dos tarjetas de isolate con un marcador de mensaje entrando en el event loop del segundo isolate.
Los isolates pueden enviar mensajes a otros isolates.

Esta falta de memoria compartida puede sonar algo estricta, especialmente si vienes de un lenguaje como Java o C++, pero tiene algunos beneficios clave para los programadores de Dart.

Por ejemplo, la asignación de memoria y la recolección de basura en un isolate no requieren bloqueos. Solo hay un hilo, así que si no está ocupado, sabes que la memoria no está siendo modificada. Esto funciona bien para las apps de Flutter, que a veces necesitan construir y destruir un montón de widgets rápidamente.

Event loops

#

Ahora que has tenido una introducción básica a los isolates, vamos a profundizar en lo que realmente hace posible el código asíncrono: el event loop.

Imagina la vida de una app extendida en una línea de tiempo. La app arranca, la app se detiene, y en medio ocurren un montón de eventos — I/O del disco, toques del usuario… todo tipo de cosas.

Tu app no puede predecir cuándo ocurrirán estos eventos ni en qué orden, y tiene que manejarlos todos con un único hilo que nunca se bloquea. Así que la app ejecuta un event loop. Toma el evento más antiguo de su cola de eventos, lo procesa, vuelve por el siguiente, lo procesa, y así sucesivamente, hasta que la cola de eventos está vacía.

Durante todo el tiempo que la app está en ejecución — estás tocando la pantalla, se están descargando cosas, suena un timer — ese event loop simplemente sigue dando vueltas y vueltas, procesando esos eventos uno a la vez.

Eventos en cola moviéndose uno a la vez hacia un engranaje con flechas circulares de event loop.
El event loop procesa un evento a la vez.

Cuando hay una pausa en la acción, el hilo simplemente se queda esperando al siguiente evento. Puede disparar el recolector de basura, tomar un café, lo que sea.

Todas las APIs de alto nivel y las características del lenguaje que Dart tiene para programación asíncrona — futures, streams, async y await — están construidas sobre y alrededor de este simple loop.

Por ejemplo, supongamos que tienes un botón que inicia una petición de red, como este:

dart
RaisedButton(
  child: Text('Click me'),
  onPressed: () {
    final myFuture = http.get('https://example.com');
    myFuture.then((response) {
      if (response.statusCode == 200) {
        print('Success!');
      }
    });
  },
)

Cuando ejecutas tu app, Flutter construye el botón y lo pone en pantalla. Luego tu app espera.

El event loop de tu app más bien está inactivo, esperando el siguiente evento. Podrían llegar eventos no relacionados con el botón y ser manejados, mientras el botón se queda ahí esperando a que el usuario lo toque. Eventualmente lo hace, y un evento de toque entra en la cola.

Ese evento es recogido para su procesamiento. Flutter lo examina, y el sistema de renderizado dice: “Esas coordenadas coinciden con el botón elevado,” así que Flutter ejecuta la función onPressed. Ese código inicia una petición de red (que devuelve un future) y registra un manejador de finalización para el future usando el método then().

Eso es todo. El loop ha terminado de procesar ese evento de toque, y se descarta.

Ahora, onPressed es una propiedad de RaisedButton, y el evento de red usa un callback para un future, pero ambas técnicas están haciendo básicamente lo mismo. Ambas son una forma de decirle a Flutter: “Oye, más adelante, podrías ver llegar un tipo particular de evento. Cuando lo veas, por favor ejecuta este fragmento de código.”

Así que onPressed está esperando un toque, y el future está esperando datos de red, pero desde la perspectiva de Dart, ambos son solo eventos en la cola.

Y así es como funciona la programación asíncrona en Dart. Futures, streams, async y await — estas APIs son todas simplemente formas de decirle al event loop de Dart: “Aquí hay algo de código, por favor ejecútalo más tarde.”

Si miramos atrás al ejemplo de código, ahora puedes ver exactamente cómo está dividido en bloques para eventos particulares. Está la construcción inicial (1), el evento de toque (2), y el evento de respuesta de red (3).

dart
RaisedButton( // (1)
  child: Text('Click me'),
  onPressed: () { // (2)
    final myFuture = http.get('https://example.com');
    myFuture.then((response) { // (3)
      if (response.statusCode == 200) {
        print('Success!');
      }
    });
  },
)

Una vez que te acostumbres a trabajar con código asíncrono, empezarás a reconocer estos patrones por todas partes. Entender el event loop te va a ayudar a medida que avances hacia las APIs de más alto nivel.

Resumen

#

Hemos echado un vistazo rápido a los isolates, el event loop, y los fundamentos de la programación asíncrona en Dart. Si buscas más detalles como cómo funciona la cola de microtareas, consulta el artículo desactualizado, archivado pero aún querido The Event Loop and Dart.

Para aprender más sobre la asincronía en Dart, consulta el siguiente artículo de esta serie, Programación asíncrona en Dart: Futures.

Muchas gracias a Andrew Brogdon, quien creó el video en el que se basa este artículo.

Más de Dart