El modelo de concurrencia de Dart: Event Loop y Call Stack

Dart opera bajo un modelo de ejecución de un solo hilo por cada Isolate. Esto significa que, en tu programa principal, solo existe una Call Stack (pila de llamadas) que ejecuta una sola instrucción a la vez. Para gestionar la asincronía sin necesidad de lidiar con los problemas clásicos del multihilo (como los bloqueos de memoria o deadlocks), Dart emplea un Event Loop (bucle de eventos).

El Event Loop es un ciclo infinito que vigila dos colas de tareas: la Microtask Queue (de alta prioridad) y la Event Queue (de baja prioridad). El funcionamiento es estricto: primero se vacía la Call Stack mediante la ejecución de todo el código síncrono; una vez la pila está vacía, el bucle revisa la Microtask Queue y ejecuta todas sus tareas hasta que esta queda totalmente limpia. Solo entonces, el bucle toma la primera tarea de la Event Queue (como un evento de I/O, un Timer o un Future) y la coloca en la pila de llamadas para su ejecución. Si esa tarea genera nuevas microtareas, estas se ejecutarán inmediatamente después de terminar la tarea actual, antes de pasar al siguiente elemento de la Event Queue.

Este modelo es la razón por la cual async/await [disponible desde Dart 2.0] no crea hilos nuevos. Cuando usas await, la función actual se pausa y su continuación (lo que sigue después del await) se registra como una microtarea para ser ejecutada más adelante, permitiendo que el Event Loop siga procesando otros eventos mientras se espera la respuesta. Usamos este modelo para mantener la fluidez en operaciones de I/O, pero si realizas un cálculo matemático extremadamente pesado de forma síncrona, bloquearás el bucle y nada más podrá ejecutarse. Si necesitas paralelismo real para tareas de CPU intensivas, debes delegar ese trabajo a un Isolate distinto.

import 'dart:async';

void main() {
  print('1. Inicio: Ejecución síncrona en el Call Stack');

  // Se encola en la Event Queue (baja prioridad)
  Future(() {
    print('5. Event Queue: El Future se ejecuta tras vaciar microtareas');
  });

  // Se encola en la Event Queue como un evento de timer
  Timer(Duration.zero, () {
    print('6. Event Queue: El Timer se ejecuta después del Future');
  });

  // Se encola en la Microtask Queue (alta prioridad)
  Future.microtask(() {
    print('3. Microtask Queue: Se ejecuta antes de cualquier evento de la Event Queue');
  });

  // Llamada a una función asíncrona
  ejecutarAsync();

  print('2. Fin de la función main (Call Stack vacía)');
}

Future<void> ejecutarAsync() async {
  print('4. Inicio de ejecutarAsync (Parte síncrona)');
  
  // El 'await' pausa la función y programa su continuación en la Microtask Queue
  await Future.value(); 
  
  print('7. Continuación de ejecutarAsync (Viene de la Microtask Queue)');
}

Análisis del flujo de ejecución

El comportamiento de este código no sigue el orden en que las funciones se declaran, sino el orden de prioridad de las colas:

  1. El programa comienza en main. Se imprimen los mensajes 1 y 4 (parte síncrona de ejecutarAsync). En este punto, el Future, el Timer y la continuación del await en ejecutarAsync ya están encolados, pero aún no se ejecutan porque la Call Stack todavía tiene tareas (print('2...')).
  2. Cuando main termina su ejecución síncrona, la Call Stack queda vacía. El Event Loop detecta que la pila está limpia y salta a la Microtask Queue.
  3. En la Microtask Queue encontramos dos tareas: la función anónima de Future.microtask y la continuación de ejecutarAsync que quedó pendiente tras el await. Se ejecutan en orden de llegada, imprimiendo los mensajes 3 y 7.
  4. Con la Microtask Queue totalmente vacía, el bucle pasa a la Event Queue. Allí encuentra el Future y el Timer. El bucle toma el primer evento, ejecuta el mensaje 5 y, tras finalizar, toma el siguiente, ejecutando el mensaje 6.

El await es la clave de la eficiencia: en lugar de bloquear el hilo mientras se espera el Future.value(), la función “se retira” de la pila, permitiendo que el programa imprima el mensaje 2 y procese otras tareas antes de retomar su ejecución.

El error frecuente

Un error común es realizar cálculos pesados de CPU dentro de una función async esperando que el await los haga “no bloqueantes”.

// ESTO ES UN ERROR
Future<void> procesarDatosPesados() async {
  print('Comenzando proceso pesado...');
  
  // Este bucle bloquea la Call Stack y el Event Loop por completo.
  // No importa que la función sea 'async', el bucle es síncrono.
  for (int i = 0; i < 10_000_000_000; i++) {
    // Cálculo intensivo
  }
  
  print('Proceso pesado terminado');
}

Si ejecutas esto, el Event Loop se queda atrapado dentro del bucle for. Aunque tengas un Timer configurado para dispararse en 1 milisegundo, el mensaje del Timer nunca se imprimirá hasta que el bucle termine, porque el bucle de eventos no puede avanzar a la Event Queue mientras la Call Stack esté ocupada con el cálculo. En este escenario, has bloqueado toda la capacidad de respuesta del programa.

64

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio