Transferencia de memoria eficiente entre Isolates con TransferableTypedData
En Dart, los Isolates no comparten memoria; cada uno tiene su propio heap (montículo) privado. Cuando envías un objeto —como una List<int> o un Uint8List— de un isolate a otro a través de un SendPort, el motor de Dart realiza por defecto una copia profunda (deep copy) de todos los datos. Si estás intentando mover un buffer de 500MB, el sistema duplicará esos 500MB en el segundo isolate, duplicando el uso de memoria y penalizando la CPU con una operación $O(n)$.
Para evitar esto, existe TransferableTypedData [disponible desde Dart 2.17]. Este mecanismo implementa la semántica de move semantics (propiedad de movimiento) en lugar de la copia. En lugar de duplicar los bytes, el runtime simplemente transfiere la propiedad de la región de memoria del buffer de un isolate a otro. El proceso es casi instantáneo, sin importar el tamaño del buffer, porque solo se mueve el puntero de memoria, no el contenido.
Usarías esto exclusivamente cuando trabajes con volúmenes masivos de datos binarios, como el procesamiento de frames de video, el parsing de archivos pesados o buffers de señales de audio, donde el costo de la copia comprometa la latencia de tu aplicación. Sin embargo, debes tener cuidado: al transferir la propiedad, el objeto original en el isolate de origen queda invalidado. Si intentas acceder a la lista original después de enviarla, el programa fallará o el acceso será ilegal, ya que ese isolate ya no es “dueño” de esa dirección de memoria.
Este diseño evita la necesidad de usar SharedArrayBuffer (como en JavaScript), el cual permite memoria compartida pero introduce el riesgo de condiciones de carrera (race conditions) y requiere mecanismos complejos de sincronización como mutexes. Dart prefiere la seguridad del modelo de actores: la memoria es privada, pero si es muy grande, simplemente se “mueve” de un actor a otro.
import 'dart:isolate';
import 'dart/typed_data';
void main() async {
final receivePort = ReceivePort();
// Lanzamos el worker que generará los datos pesados
await Isolate.spawn(_producer, receivePort.sendPort);
// Escuchamos el mensaje que viene del isolate hijo
// El mensaje será un TransferableTypedData
final TransferableTypedData transferable = await receivePort.first;
// Reclamamos la propiedad de la memoria en el isolate principal
// materializing() nos devuelve un ByteData con el contenido
final ByteData receivedData = transferable.materialize();
print('Main: Datos recibidos correctamente.');
print('Main: Tamaño: ${receivedData.length} bytes');
print('Main: Primer byte: ${receivedData.getUint8(0)}');
}
void _producer(SendPort mainSendPort) async {
// Creamos un buffer grande (10MB) para el ejemplo
// En producción, esto podrían ser buffers de cientos de megabytes
final Uint8List largeBuffer = Uint8List.fromList(
List.generate(10_000_000, (i) => i % 256),
);
print('Worker: Buffer creado. Tamaño: ${largeBuffer.length} bytes');
// Envolvemos el TypedData en un objeto transferible
final transferable = TransferableTypedData(largeBuffer);
// Al enviar 'transferable', la memoria de 'largeBuffer' se mueve
// al isolate principal. 'largeBuffer' deja de ser válido aquí.
mainSendPort.send(transferable);
// Intentar acceder a largeBuffer[0] después de aquí
// resultaría en un error de acceso a memoria.
}
En el ejemplo anterior, el flujo comienza en main, que crea un ReceivePort para recibir los datos. El isolate _producer genera un Uint8List de 10 millones de bytes. En lugar de enviar el Uint8List directamente, lo envolvemos en TransferableTypedData(largeBuffer).
Cuando ejecutamos mainSendPort.send(transferable), ocurre la magia: el puntero de memoria que apunta a esos 10MB de datos se transfiere al ReceivePort del isolate principal. El isolate _producer pierde el derecho de lectura y escritura sobre esa memoria. En el isolate principal, el objeto recibido no es el Uint8List original, sino el envoltorio TransferableTypedData. Para poder manipular los datos de forma convencional, llamamos a transferable.materialize(), que reconstruye la vista de los datos en el nuevo heap, devolviéndonos un ByteData.
El error frecuente
Un error común al trabajar con concurrencia de alto rendimiento es intentar reutilizar el buffer original inmediatamente después de la transferencia.
// ERROR COMÚN final buffer = Uint8List.fromList([1, 2, 3]); final transferable = TransferableTypedData(buffer); sendPort.send(transferable); // ESTO FALLARÁ O DARÁ RESULTADOS IMPREDECIBLES print(buffer[0]);
Una vez que el objeto es enviado, el Uint8List original en el isolate de origen queda en un estado inválido. El sistema de tipos de Dart te permite mantener la referencia al objeto buffer, pero el contenido de la memoria física ya no pertenece a ese isolate. Intentar acceder a él es un error de lógica de propiedad (ownership) que puede causar crashes en el runtime si la VM detecta la violación de la seguridad de memoria.
N° 74