Mejor gestión de isolates con Isolate.run()

Dart 2.19 introduce una nueva función que hace que implementar concurrencia sea tan simple como una sola línea de código.

Un test de benchmark que muestra la velocidad de la nueva función Isolate.run().
Un test de benchmark que muestra la velocidad de la nueva función Isolate.run()

Todo el código Dart se ejecuta en isolates. Implementar múltiples isolates para habilitar concurrencia en tus programas Dart depende de ti. Cómo puedes implementar múltiples isolates depende de nosotros, el equipo de Dart, y en Dart 2.19 hemos hecho una gran mejora en el proceso que nos emociona compartir. Conoce Isolate.run()!

run() toma toda la complejidad de configurar y gestionar isolates y la abstrae completamente en una sola llamada a función. Ha sido posible usar isolates con un puñado de primitivas desde hace tiempo. Pero, incluso con todas las recientes mejoras de rendimiento para isolates, el proceso era, en el mejor de los casos, tedioso, y en el peor, propenso a errores.

Para apreciar la magnitud de la mejora que supone run(), este artículo desglosa el método anterior de construir la funcionalidad pieza a pieza a partir de primitivas de bajo nivel. Luego, contrastaremos eso con el uso de Isolate.run(), y te mostraremos cómo funciona por dentro. Incluso si nunca has usado isolates antes, estamos seguros de que run() te emocionará para probarlos!

Isolates

#

Los isolates son un concepto bastante sencillo. Un isolate es esencialmente un único hilo de ejecución en Dart. Te permiten ejecutar partes de tu código en paralelo. Puedes iniciar nuevas ejecuciones paralelas (tantas como quieras) y decirles qué hacer, directamente desde main (el hilo principal, o isolate principal). Los isolates no comparten memoria; en su lugar, se pasan mensajes entre sí para comunicarse. Así que no tienes que preocuparte por los problemas típicos del multihilo, como las condiciones de carrera, o los mutexes y locks.

Un diagrama que ilustra cómo los isolates se comunican mediante paso de mensajes sin compartir memoria.

¡Suena genial! Pero ¿cómo se usan? Antes de Isolate.run(), ahí era donde las cosas se complicaban.

La API de isolates está compuesta por primitivas de bajo nivel que proporcionan una amplia gama de funcionalidades. Esa granularidad es genial cuando necesitas personalizar la funcionalidad de tus isolates. La granularidad no es tan genial, sin embargo, cuando es necesario usar isolates en absoluto. Especialmente porque casi todos los casos de uso de isolates requieren las mismas configuraciones básicas para su setup y gestión. Esto básicamente significaba exponer cada detalle de implementación para que vosotros, los usuarios de Dart, los manejarais por vuestra cuenta.

Echemos un vistazo a una configuración típica de isolate para entender mejor el tedioso proceso que Isolate.run() resuelve.

Uso de isolates (antes)

#

Puedes pensar en Isolate.spawn() como el antiguo punto de partida para los isolates. La función compute de Flutter se construyó sobre spawn(). Toma un método a invocar como su argumento entrypoint, más cualquier argumento para ese método, y otras configuraciones para el propio isolate. En el pasado, este entrypoint solo podía ser un método de nivel superior o estático.

Isolate.spawn(_readAndParseJson, filename);

Isolate creado, ¡listo! Es broma. Ni cerca.

Llamar a spawn no devuelve nada con lo que realmente puedas trabajar. Solo devuelve un objeto isolate, que simplemente confirma que el isolate ha iniciado.

El isolate principal y el isolate generado (el isolate creado por spawn()) no pueden comunicarse directamente, más allá de los argumentos iniciales pasados al crearlo. En la práctica, incluso si no necesitas recibir resultados de cómputo del isolate generado, seguirías queriendo algún tipo de verificación de que el cómputo tuvo éxito, así que siempre vas a querer un mensaje de vuelta.

Para habilitar la comunicación, tienes que configurar puertos. Creas un objeto ReceivePort antes de llamar a spawn. El sendPort del objeto ReceivePort va al isolate generado como otro argumento para spawn.

dart
void main() async {
 final resultPort = ReceivePort();
 final jsonData = await Isolate.spawn(_readAndParseJson, [resultPort.sendPort, filename]);
}

Esto significa que la función que pasas a spawn tiene que estar configurada específicamente para hacer algo con ese sendPort. En otras palabras, no puedes simplemente reutilizar funciones existentes con isolates. Así que en lugar de pasar a spawn una función regular que simplemente lee y parsea un archivo JSON, crearías algo como:

dart
Future<void> _readAndParseJson(List<dynamic> args) async {
  SendPort responsePort = args[0];
  String fileName = args[1];

  final fileData = await File(filename).readAsString();
  final result = jsonDecode(fileData);
// ...
}

Tu función especial, compatible con isolates, para decodificar JSON puede simplemente “return result” y ya estaría, ¿verdad? No del todo. El resultado necesita ser enviado a través de responsePort. Así es como los isolates se comunican con puertos. Puedes usar otra primitiva, Isolate.exit() para devolver el resultado eficientemente y cerrar el isolate generado al mismo tiempo:

Isolate.exit(responsePort, result);

La función exit() transfiere la memoria que contiene el mensaje en el isolate generado al isolate principal (en lugar de copiarla) y cierra el isolate de forma segura.

Pongamos todo esto junto. Como result en este ejemplo es JSON parseado, probablemente querrías desestructurarlo un poco para usarlo realmente. Por limpieza de código, en lugar de poner esas tres líneas que crean el receivePort y el isolate, y esperan su respuesta, directamente en main(), deberíamos ponerlas en su propia función.

dart
void main() async {
  final jsonData = await _spawnAndReceive(filename);
  print('Received JSON with ${jsonData.length} keys');
}

// Spawns an isolate and waits for the first message
Future<Map<String, dynamic>> _spawnAndReceive(String filename) async {
  final resultPort = ReceivePort();
  await Isolate.spawn(_readAndParseJson, [resultPort.sendPort, filename]);

  return (await resultPort.first) as Map<String, dynamic>;
}

// The entrypoint that runs on the spawned isolate.
void _readAndParseJson(List<dynamic> args) async {
  SendPort responsePort = args[0];
  String fileName = args[1];

  final fileData = await File(filename).readAsString();
  final result = jsonDecode(fileData) as Map<String, dynamic>;

  Isolate.exit(responsePort, result);
}

Lo que se logra es relativamente sencillo. Es la exposición de los detalles de implementación lo que hace que parezca complicado, como los puertos para el paso de mensajes, y necesitar una función dedicada para manejar puertos que por lo demás es agnóstica a los isolates.

Manejo de errores

#

El ejemplo hasta ahora todavía no es realmente una implementación “completa” y lista para producción. Sería un perjuicio omitir cualquier tipo de manejo de errores, pero comúnmente se olvida como un paso adicional en un proceso ya de por sí sustancial. Sin ningún manejo de errores, si un error asincrónico no capturado hiciera crash tu isolate, por ejemplo, no sabrías qué ocurrió para causar el error, ni siquiera serías alertado de que algo hubiera ocurrido en absoluto.

Cubrir todas las posibilidades de manejo de errores para isolates sería extenso, pero en general implicaría algunas adiciones más al código.

Como mínimo, podrías añadir los parámetros errorsAreFatal, onExit y onError a la llamada a spawn:

dart
  await Isolate.spawn(_readAndParseJson, [resultPort.sendPort, filename],
          onError: resultPort.sendPort,
          onExit: resultPort.sendPort,
          errorsAreFatal: true
  );

Esto asegura que resultPort reciba un mensaje incluso si el isolate generado termina sin enviar un resultado, o si tiene errores no capturados. Hacer que los errores sean fatales significa que los errores no capturados salen del isolate como medida de seguridad, para asegurar que termine.

El argumento onExit hace que el isolate envíe null al puerto al salir. El argumento onError hace que un error no capturado envíe una lista de dos strings al puerto (el toString tanto del error como del stack trace).

Reutilizar el puerto de resultados te permite evitar crear más puertos, por lo que solo tienes que buscar mensajes en un solo lugar. Pero también significa que necesitas distinguir los mensajes de onExit y onError del valor del resultado. Aquí asumimos que el JSON debe ser un Map, por lo que no puede ser una lista ni null. De lo contrario también tendrías que envolver el resultado en algo reconocible. Tendrías que crear un protocolo de mensajes (trivial) sobre los mensajes del puerto.

Más allá de ese mínimo, podrías entonces comprobar response en busca de errores específicos. Un caso sería comprobar si resultPort es null, lo que significa que el isolate terminó sin enviar resultados:

dart
  final response = await resultPort.first;
  if (response == null) {
    // Isolate exited. Throw some error, for example:
    throw RemoteError("Isolate terminated without result");
  }

Otra sería comprobar si el resultado es una lista, lo que significa que ocurrió un error no capturado:

dart
  if (response is List<dynamic>) {
    // Uncaught error, list of two strings.
    var remoteError = RemoteError(response[0], response[1]);
    await Future.error(remoteError); // Rethrow.
  }

Luego, finalmente, manejar un resultado real:

dart
// Otherwise it's a result value, which is a map.
return message as Map<String, dynamic>;

En cualquier caso, querrías poner spawn en un bloque try para comprobar si el envío del entrypoint al nuevo isolate falló. Si lo hizo, el puerto de resultados no recibirá ningún mensaje, y necesita ser cerrado:

dart
  try {
    await Isolate.spawn( ... );
  } on Object {
    resultPort.close();
    rethrow;
  }

Proporcionar un mínimo de manejo de errores asegura que el puerto de resultados siempre esté cerrado, y que _spawnAndReceive siempre complete, sin importar cómo salga el isolate generado. También podrías hacer las cosas mejores, por ejemplo, capturando y enviando errores y stack traces de vuelta como objetos reales, no solo strings como el handler onError.

El manejo de errores obviamente introduce mucha variación, y con ello la carga mental de decidir cómo abordarlo y qué tener en cuenta. Es comprensible que comúnmente se omita en la configuración básica de isolates.

Uso de isolates (después)

#

Isolate.run() configura todas las partes de la implementación de isolates con las primitivas que anteriormente tenías que usar tú mismo, en una sola llamada a función:

dart
void main() async {
  final jsonData = await Isolate.run(_readAndParseJson);
  print('Received JSON with ${jsonData.length} keys');
}

Sin puertos, sin spawning separado, sin exiting ni manejo de errores, y sin estructura de retorno especial. Quizás la mejor parte, el entrypoint que pasas a run puede ser cualquier función existente:

dart
Future<Map<String, dynamic>> _readAndParseJson() async {
  final fileData = await File(filename).readAsString();
  return jsonDecode(fileData);
}

Este ejemplo muestra una función asincrónica, pero run podría igualmente ejecutar una función sincrónica. La función run en sí misma siempre retorna de forma asincrónica, que es lo único que importa.

El entrypoint también puede ser una expresión de función, escrita directamente inline dondequiera que llames a run. Los isolates, y cualquier API de más alto nivel escrita sobre ellos, ya no están limitados a ejecutar solo funciones estáticas o de nivel superior.

dart
  final jsonData = await Isolate.run(() async {
    final fileData = await File(filename).readAsString();
    final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
    return jsonData;
  });

El parámetro de mensaje adicional ya no es necesario, y puedes evitar empaquetar y desempaquetar argumentos en estructuras de datos como listas.

No tienes que pensar mucho en el manejo de errores en absoluto. La función run combina la captura de errores locales y remotos, el manejo y la comunicación entre isolates, y expone el resultado como un único error normal (asincrónico) que puedes capturar en un try/catch estándar. Puedes olvidarte de los isolates y tratarlo como una función normal.

dart
  try {
    await Isolate.run(_readAndParseJson);
  } on FormatException catch (e, s) {
    print(e.message);
  }

Isolate.run() permite un código mucho más limpio y ergonómico. ¡La función compute de Flutter incluso cambió a usar run en lugar de spawn!

Dentro de Isolate.run()

#

Echa un vistazo a la [implementación de run](https://api.dart.dev/dev/3.0.0-125.0.dev/dart-isolate/Isolate/run.html#implementation:~:text=isolate%20for%20debugging.-,Implementation,-%40Since(%222.19) en sí misma. Profundiza en todas las APIs de isolates de bajo nivel (que antes habría sido tu trabajo implementar) para construir una configuración de isolate “perfecta” y completa. Toma el método a ejecutar, computation, y configura todos los puertos y sus retornos para tener en cuenta el paso eficiente de mensajes entre isolates.

dart
static Future<R> run<R>(FutureOr<R> computation(), {String? debugName}) {
    var result = Completer<R>();
    var resultPort = RawReceivePort();
    // ...
    result.complete;    // or result.completeError for error handling blocks
    // ...

Hay un manejo de errores exhaustivo para cada caso potencial. La función run comprueba si un isolate muere antes de terminar el cómputo. Si el cómputo lanza una excepción, el isolate termina y lanza el mismo error al isolate principal.

dart
// ...
    resultPort.handler = (response) {
      resultPort.close();
      if (response == null) {
        // onExit handler message, isolate terminated without sending result.
        result.completeError(
            RemoteError("Computation ended without result", ""),
            StackTrace.empty);
        return;
      }
// ...

Si ocurre un error asincrónico no capturado, el isolate termina y reporta el error asincrónicamente al isolate principal. Si el isolate principal termina primero, el isolate generado termina y trata la situación como un error asincrónico no capturado.

dart
// ...
void _run() async {
    R result;
    try {
      var potentiallyAsyncResult = computation();
      if (potentiallyAsyncResult is Future<R>) {
        result = await potentiallyAsyncResult;
      } else {
        result = potentiallyAsyncResult;
      }
    } catch (e, s) {
      // If sending fails, the error becomes an uncaught error.
      Isolate.exit(resultPort, _list2(e, s));
    }
    Isolate.exit(resultPort, _list1(result));
  }

Por último, run siempre usa exit para cerrar de forma segura. Eso significa que los datos se transfieren eficientemente de isolate a isolate sin copiarlos realmente.

Resumen

#

La función run es ideal para iniciar un cómputo y esperar los resultados. Las primitivas siguen ahí si quieres construir tu propia configuración de isolate para algo que run no cubre, como un isolate de larga duración que pueda enviar y recibir mensajes múltiples veces. Para la mayoría de los casos, sin embargo, reemplazar spawn y todas sus configuraciones de soporte con una sola sentencia run debería usarse sobre cualquier otra configuración.

Si nunca habías probado la gestión de isolates antes de run, ¡sería difícil creer que toda esta funcionalidad previamente tenía que ser implementada por el usuario! Isolate.run() — disponible en Dart 2.19 y Flutter 3.7 — hace el código mucho más ergonómico, y hace que los isolates sean mucho más fáciles de usar. ¿Qué harás con todo el tiempo que run te ahorra?

Más de Dart