Saltar al contenido principal

Concurrencia e isolates

Multithreading en Flutter usando Dart isolates.

Todo el código Dart se ejecuta en isolates, que son similares a los hilos, pero difieren en que los isolates tienen su propia memoria aislada. No comparten el estado de ninguna manera, y solo pueden comunicarse mediante mensajes. Por defecto, las aplicaciones Flutter realizan todo su trabajo en un solo isolate: el isolate principal. En la mayoría de los casos, este modelo permite una programación más sencilla y es lo suficientemente rápido como para que la interfaz de usuario de la aplicación no deje de responder.

A veces, sin embargo, las aplicaciones necesitan realizar cálculos excepcionalmente grandes que pueden causar "UI jank" (movimiento a tirones). Si tu aplicación está experimentando tirones por esta razón, puedes mover estos cálculos a un isolate auxiliar. Esto permite que el entorno de tiempo de ejecución subyacente ejecute el cálculo de forma concurrente con el trabajo del isolate principal de la interfaz de usuario y aproveche los dispositivos multinúcleo.

Cada isolate tiene su propia memoria y su propio bucle de eventos (event loop). El bucle de eventos procesa los eventos en el orden en que se agregan a una cola de eventos. En el isolate principal, estos eventos pueden ser cualquier cosa, desde manejar un toque del usuario en la interfaz de usuario, hasta ejecutar una función, o pintar un fotograma en la pantalla. La siguiente figura muestra una cola de eventos de ejemplo con 3 eventos en espera de ser procesados.

Diagrama del isolate principal

Para un renderizado fluido, Flutter agrega un evento "paint frame" a la cola de eventos 60 veces por segundo (para un dispositivo de 60Hz). Si estos eventos no se procesan a tiempo, la aplicación experimenta UI jank, o peor aún, se vuelve completamente insensible.

Diagrama de tirones de eventos (event jank)

Cada vez que un proceso no se pueda completar en el espacio entre frames, el tiempo entre dos frames, es una buena idea delegar el trabajo a otro isolate para asegurar que el isolate principal pueda producir 60 frames por segundo. Cuando creas un isolate en Dart, este puede procesar el trabajo de forma concurrente con el isolate principal, sin bloquearlo.

Puedes leer más sobre cómo funcionan los isolates y el event loop en Dart en la página de concurrencia de la documentación de Dart.

Ver en YouTube en una nueva pestaña: "Isolates and the event loop | Flutter in Focus"

Casos de uso comunes para isolates

#

Solo hay una regla estricta sobre cuándo deberías usar isolates, y es cuando cálculos grandes están causando que tu aplicación de Flutter experimente UI jank. Este jank ocurre cuando hay cualquier cálculo que tome más tiempo que el espacio entre frames de Flutter.

Diagrama de tirones de eventos (event jank)

Cualquier proceso podría tardar más en completarse, dependiendo de la implementación y de los datos de entrada, haciendo imposible crear una lista exhaustiva de cuándo necesitas considerar el uso de isolates.

Dicho esto, los isolates se utilizan comúnmente para lo siguiente:

  • Leer datos de una base de datos local
  • Enviar notificaciones push
  • Analizar y decodificar archivos de datos grandes
  • Procesar o comprimir fotos, archivos de audio y archivos de video
  • Convertir archivos de audio y video
  • Cuando necesitas soporte asíncrono mientras usas FFI
  • Aplicar filtrado a listas complejas o sistemas de archivos

Paso de mensajes entre isolates

#

Los isolates de Dart son una implementación del modelo Actor. Solo pueden comunicarse entre sí mediante el paso de mensajes, lo cual se realiza con objetos Port. Cuando los mensajes se "pasan" entre sí, generalmente se copian del isolate emisor al isolate receptor. Esto significa que cualquier valor pasado a un isolate, incluso si se muta en ese isolate, no cambia el valor en el isolate original.

Los únicos objetos que no se copian cuando se pasan a un isolate son objetos inmutables que de todos modos no se pueden cambiar, tales como un String o un byte no modificable. Cuando pasas un objeto inmutable entre isolates, se envía una referencia a ese objeto a través del port, en lugar de copiar el objeto, para un mejor rendimiento. Debido a que los objetos inmutables no se pueden actualizar, esto conserva efectivamente el comportamiento del modelo actor.

Una excepción a esta regla es cuando un isolate finaliza al enviar un mensaje usando el método Isolate.exit. Debido a que el isolate emisor no existirá después de enviar el mensaje, puede transferir la propiedad del mensaje de un isolate al otro, asegurando que solo un isolate pueda acceder al mensaje.

Las dos primitivas de más bajo nivel que envían mensajes son SendPort.send, que hace una copia de un mensaje mutable al enviarlo, e Isolate.exit, que envía la referencia al mensaje. Tanto Isolate.run como compute usan Isolate.exit por debajo.

Isolates de vida corta

#

La forma más fácil de mover un proceso a un isolate en Flutter es con el método Isolate.run. Este método crea un isolate, pasa un callback al isolate creado para iniciar algún cálculo, retorna un valor del cálculo, y luego cierra el isolate cuando el cálculo se completa. Todo esto sucede de forma concurrente con el isolate principal, y no lo bloquea.

Diagrama de isolates

El método Isolate.run requiere un único argumento, una función callback, que se ejecuta en el nuevo isolate. La firma de función de este callback debe tener exactamente un argumento requerido y sin nombre. Cuando el cálculo se completa, devuelve el valor del callback de vuelta al isolate principal, y finaliza el isolate creado.

Por ejemplo, considera este código que carga un JSON grande desde un archivo, y convierte ese JSON en objetos personalizados de Dart. Si el proceso de decodificación de JSON no se hubiera delegado a un nuevo isolate, este método haría que la UI deje de responder durante varios segundos.

dart
// Produces a list of 211,640 photo objects.
// (The JSON file is ~20MB.)
Future<List<Photo>> getPhotos() async {
  final String jsonString = await rootBundle.loadString('assets/photos.json');
  final List<Photo> photos = await Isolate.run<List<Photo>>(() {
    final List<Object?> photoData = jsonDecode(jsonString) as List<Object?>;
    return photoData.cast<Map<String, Object?>>().map(Photo.fromJson).toList();
  });
  return photos;
}

Para ver un recorrido completo sobre el uso de Isolates para analizar JSON en segundo plano, consulta esta receta de la guía (cookbook).

Isolates con estado y de vida más larga

#

Los isolates de vida corta son convenientes de usar, pero existe una sobrecarga de rendimiento requerida para generar (spawn) nuevos isolates y para copiar objetos de un isolate a otro. Si estás realizando el mismo cálculo usando Isolate.run repetidamente, podrías obtener un mejor rendimiento creando isolates que no salgan de inmediato.

Para hacer esto, puedes usar un puñado de API relacionadas con isolates de nivel más bajo que Isolate.run abstrae:

Cuando utilizas el método Isolate.run, el nuevo isolate se cierra inmediatamente después de que devuelve un único mensaje al isolate principal. A veces, necesitarás isolates que sean de vida larga, y que puedan pasarse múltiples mensajes entre sí a lo largo del tiempo. En Dart, puedes lograr esto con la API de Isolate y los Puertos (Ports). Estos isolates de larga vida se conocen coloquialmente como background workers.

Los isolates de larga vida son útiles cuando tienes un proceso específico que necesita ejecutarse repetidamente durante el ciclo de vida de tu aplicación, o si tienes un proceso que se ejecuta durante un período de tiempo y necesita devolver múltiples valores de retorno al isolate principal.

O bien, podrías usar worker_manager para administrar isolates de larga vida.

ReceivePorts y SendPorts

#

Configura la comunicación de larga vida entre isolates con dos clases (además de Isolate): ReceivePort y SendPort. Estos puertos son la única forma en que los isolates pueden comunicarse entre sí.

Los Ports se comportan de manera similar a los Streams, donde el StreamController o el Sink se crean en un isolate, y el oyente (listener) se configura en el otro isolate. En esta analogía, el StreamController se llama SendPort, y puedes "agregar" mensajes con el método send(). Los ReceivePorts son los oyentes, y cuando estos oyentes reciben un nuevo mensaje, llaman a una devolución de llamada (callback) proporcionada con el mensaje como argumento.

Para obtener una explicación detallada sobre la configuración de la comunicación bidireccional entre el isolate principal y un isolate de trabajo (worker), sigue los ejemplos en la documentación de Dart.

Usar plugins de plataforma en isolates

#

Puedes utilizar plugins de plataforma en isolates en segundo plano. Esto permite a los plugins descargar cálculos pesados y dependientes de la plataforma a un isolate que no bloqueará tu interfaz de usuario. Por ejemplo, imagina que estás encriptando datos usando una API nativa del host (como una API de Android en Android, una API de iOS en iOS, etc.). Anteriormente, organizar los datos (marshaling data) hacia la plataforma del host podía desperdiciar tiempo del hilo de la interfaz de usuario, y ahora se puede hacer en un isolate en segundo plano.

Los isolates de canales de plataforma utilizan la API BackgroundIsolateBinaryMessenger. El siguiente fragmento muestra un ejemplo del uso del paquete shared_preferences en un isolate en segundo plano.

dart
import 'dart:isolate';

import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  // Identify the root isolate to pass to the background isolate.
  RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
  Isolate.spawn(_isolateMain, rootIsolateToken);
}

Future<void> _isolateMain(RootIsolateToken rootIsolateToken) async {
  // Register the background isolate with the root isolate.
  BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

  // You can now use the shared_preferences plugin.
  SharedPreferences sharedPreferences = await SharedPreferences.getInstance();

  print(sharedPreferences.getBool('isDebug'));
}

Limitaciones de los Isolates

#

Si vienes a Dart desde un lenguaje con multihilo (multithreading), es razonable esperar que los isolates se comporten como hilos, pero ese no es el caso. Los isolates tienen sus propios campos globales, y solo pueden comunicarse mediante el paso de mensajes, lo que garantiza que los objetos mutables en un isolate solo sean accesibles en un único isolate. Por lo tanto, los isolates están limitados por su acceso a su propia memoria. Por ejemplo, si tienes una aplicación con una variable global mutable llamada configuration, esta se copia como un nuevo campo global en un isolate generado. Si modificas esa variable en el isolate generado, permanece intacta en el isolate principal. Esto es cierto incluso si pasas el objeto configuration como un mensaje al nuevo isolate. Así es como se supone que deben funcionar los isolates, y es importante tenerlo en cuenta al considerar el uso de isolates.

Plataformas web y compute

#

Las plataformas web de Dart, incluyendo Flutter web, no admiten isolates. Si te diriges a la web con tu aplicación Flutter, puedes usar el método compute para garantizar que tu código se compile. El método compute() ejecuta el cálculo en el hilo principal en la web, pero genera (spawns) un nuevo hilo en dispositivos móviles. En plataformas móviles y de escritorio, await compute(fun, message) es equivalente a await Isolate.run(() => fun(message)).

Para obtener más información sobre la concurrencia en la web, consulta la documentación de concurrencia en dart.dev.

Sin acceso a rootBundle o métodos dart:ui

#

Todas las tareas de la interfaz de usuario y Flutter en sí están vinculados al isolate principal. Por lo tanto, no puedes acceder a los activos utilizando rootBundle en isolates generados, ni puedes realizar ningún trabajo de widget o interfaz de usuario en isolates generados.

Mensajes de plugin limitados desde la plataforma anfitriona a Flutter

#

Con los canales de plataforma de isolates en segundo plano, puedes usar canales de plataforma en isolates para enviar mensajes a la plataforma anfitriona (por ejemplo, Android o iOS) y recibir respuestas a esos mensajes. Sin embargo, no puedes recibir mensajes no solicitados de la plataforma anfitriona.

Como ejemplo, no puedes configurar un oyente (listener) de Firestore de larga duración en un isolate en segundo plano, porque Firestore utiliza canales de plataforma para enviar actualizaciones a Flutter, las cuales son no solicitadas. Sin embargo, puedes consultar a Firestore para obtener una respuesta en segundo plano.

Más información

#

Para obtener más información sobre los isolates, consulta los siguientes recursos:

  • Si estás utilizando muchos isolates, considera la clase IsolateNameServer en Flutter, o el paquete pub que clona la funcionalidad para aplicaciones Dart que no utilizan Flutter.
  • Los Isolates de Dart son una implementación del modelo Actor.
  • isolate_agents es un paquete que abstrae los Puertos (Ports) y facilita la creación de isolates de larga vida.
  • Lee más sobre el anuncio de la API BackgroundIsolateBinaryMessenger.