Comunicación entre Isolates con SendPort y ReceivePort

Comunicación entre Isolates: SendPort y ReceivePort

En Dart, la concurrencia no se gestiona mediante hilos que comparten memoria, sino mediante Isolates. Un isolate es un ejecutable independiente con su propio heap (montículo) de memoria y su propio Event Loop. Esta arquitectura de “memoria compartida cero” elimina la necesidad de mutex o bloqueos complejos, pero impone una regla estricta: para hablar con otros procesos, debemos usar mensajería.

Para lograrlo, utilizamos ReceivePort, que actúa como un buzón de entrada (un Stream de mensajes) y SendPort, que es la dirección a la que podemos enviar mensajes. Cuando ejecutas Isolate.spawn, el runtime crea un nuevo contexto de ejecución. Para que este nuevo isolate sepa cómo comunicarse contigo, debes pasarle un SendPort como mensaje inicial. Si necesitas una comunicación bidireccional, el isolate hijo debe crear su propio ReceivePort y enviarte su respectiva SendPort como respuesta.

Este modelo es ideal cuando tienes tareas intensivas de CPU (procesamiento de imágenes, cálculos matemáticos pesados, parsing de JSON masivos) que bloquearían el loop principal. Si intentas usar un isolate para algo que solo espera I/O, estarías desperdiciando recursos, ya que Dart ya maneja I/O de forma asíncrona de forma eficiente. El mayor riesgo ocurre cuando intentas enviar objetos complejos que no son sendable (como funciones con clausuras o tipos que mantengan referencias a memoria del isolate padre) o cuando olvidas cerrar un ReceivePort, lo que provoca fugas de memoria en la VM.

import 'dart:isolate';

// El punto de entrada debe ser una función top-level o estática.
// No puede ser un closure porque el nuevo isolate no tiene acceso al 
// scope léxico del padre.
void workerEntryPoint(SendPort mainSendPort) {
  // 1. Creamos un ReceivePort para recibir mensajes del padre.
  final workerReceivePort = ReceivePort();

  // 2. Enviamos nuestro SendPort al padre para establecer el canal de vuelta.
  // Este es el "handshake" para comunicación bidireccional.
  mainSendPort.send(workerReceivePort.sendPort);

  // 3. Escuchamos los mensajes que lleguen a nuestro puerto.
  workerReceivePort.listen((message) {
    if (message is String) {
      print('[Worker] Mensaje recibido: $message');
    } else if (message is int) {
      // Simulamos un cálculo pesado.
      final result = heavyCalculation(message);
      
      // Usamos Isolate.exit para terminar el isolate y enviar el resultado.
      // A diferencia de sendPort.send, Isolate.exit transfiere la propiedad
      // del objeto al padre (ownership transfer), lo que es mucho más 
      // eficiente para objetos grandes ya que evita la copia de memoria.
      Isolate.exit(mainSendPort, result);
    }
  });
}

int heavyCalculation(int input) {
  // Simulación de carga de CPU.
  int sum = 0;
  for (int i = 0; i < input; i++) {
    sum += i;
  }
  return sum;
}

void main() async {
  final mainReceivePort = ReceivePort();
  final mainSendPort = mainReceivePort.sendPort;

  print('[Main] Spawning worker...');

  // Creamos el isolate. El segundo argumento es el mensaje inicial.
  await Isolate.spawn(workerEntryPoint, mainSendPort);

  // Esperamos la respuesta del "handshake" (el SendPort del worker).
  final dynamic handshake = await mainReceivePort.stream.first;
  
  if (handshake is SendPort) {
    print('[Main] Handshake completado. Enviando tarea...');
    handshake.send('Comienza el trabajo');
    handshake.send(1000000); // Enviamos el dato para el cálculo.
  }

  // Recibimos el resultado final enviado por Isolate.exit.
  final result = await mainReceivePort.stream.first;
  print('[Main] Resultado final recibido: $result');

  // Limpieza manual de los puertos en el main.
  mainReceivePort.close();
}

Desglose del flujo

En el ejemplo, la comunicación no es un simple envío de datos, sino un protocolo.

  1. El Handshake: En main, creamos un ReceivePort. Al ejecutar Isolate.spawn, pasamos el SendPort del padre al worker. El worker recibe esto en workerEntryPoint. Para poder responder, el worker crea su propio ReceivePort y utiliza mainSendPort.send(workerReceivePort.sendPort) para decirle al padre: “Si quieres hablar conmigo, usa esta dirección”.
  2. Comunicación bidireccional: Una vez establecido el canal, el padre usa el SendPort que recibió del hijo para enviarle órdenes (handshake.send(...)).
  3. Optimización con Isolate.exit: Fíjate en la llamada a Isolate.exit(mainSendPort, result). En Dart, si usaras mainSendPort.send(result), la VM tendría que serializar/deserializar el objeto para copiarlo de un heap al otro. Sin embargo, Isolate.exit le dice a la VM: “Este isolate ha terminado y este objeto ya no le pertenece a él, pásaselo directamente al padre”. Esto es fundamental cuando trabajas con buffers grandes o estructuras de datos complejas.
  4. Tipos transferibles: Aunque en este ejemplo enviamos enteros y strings (primitivos), a partir de Dart 3 puedes aprovechar objetos que permiten una transferencia de memoria más directa, minimizando la sobrecarga de la comunicación entre procesos.

El error frecuente

Un error clásico es intentar pasar una función con captura de variables (closure) al Isolate.spawn.

// ESTO DARÁ ERROR DE COMPILACIÓN O RUNTIME
void main() {
  int factor = 2;
  // Error: La función lambda captura 'factor', que vive en el heap del main.
  // El nuevo isolate no puede acceder al heap del main.
  Isolate.spawn((SendPort sp) {
    print(factor * 10); 
  }, ReceivePort().sendPort);
}

Si necesitas que el isolate tenga acceso a datos iniciales, debes empaquetarlos en un objeto (una clase o un Map) y pasarlo como el segundo argumento de Isolate.spawn, o pasarle los datos a través de un SendPort una vez que el isolate haya arrancado.

72

Dejar un comentario

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

Scroll al inicio