Dart — Isolates, Actor Model & Zero-Copy Transfer
The Actor Model — No Shared Memory
dart
// Dart has NO shared-memory threads. Isolates are independent workers:
// - Each isolate has its own memory heap (no shared mutable state).
// - Communication is via message passing (ports).
// - No data races — no locks, no mutexes, no atomics.
//
// This is the Erlang/Akka actor model, not the Java/C++ threading model.
// ── Isolate.run: one-shot computation in a separate isolate ──
// Spawns an isolate, runs the function, sends the result back, kills the isolate.
// The closure and its captured values are DEEP-COPIED to the new isolate.
Future<int> heavySum() async {
return Isolate.run(() {
// This runs on a separate OS thread with its own heap.
// No access to the main isolate's memory — everything is copied.
var sum = 0;
for (var i = 0; i < 1000000000; i++) sum += i;
return sum; // result is deep-copied back to the main isolate
});
}
// Capturing values — they're COPIED (deep copy), not shared:
Future<void> processFile(String path) async {
final result = await Isolate.run(() {
// `path` was copied — modifying it here doesn't affect the original.
final content = File(path).readAsStringSync();
return content.toUpperCase();
});
print(result);
}
Isolate.run vs Isolate.spawn
dart
import 'dart:isolate';
// ── Isolate.run: one-shot, fire-and-forget, returns a Future. ──
// Best for: one-off CPU-heavy work (JSON parsing, compression, sorting).
// The isolate is automatically killed after the function returns.
Future<Map<String, dynamic>> parseHugeJson(String json) async {
return Isolate.run(() => jsonDecode(json) as Map<String, dynamic>);
}
// ── Isolate.spawn: long-running, bidirectional communication. ──
// Best for: worker pools, event-driven workers, persistent background tasks.
// You manage the lifecycle (spawn, communicate, kill).
void workerEntryPoint(SendPort mainSendPort) {
// Set up a receive port for messages from the main isolate.
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort); // send our port back
receivePort.listen((message) {
// Process messages from the main isolate.
final result = _process(message);
mainSendPort.send(result); // send result back
});
}
Future<void> main() async {
final mainReceivePort = ReceivePort();
await Isolate.spawn(workerEntryPoint, mainReceivePort.sendPort);
// Get the worker's send port (first message from the worker).
final workerSendPort = await mainReceivePort.first as SendPort;
// Now we can send messages to the worker:
final responsePort = ReceivePort();
workerSendPort.send('task-1');
// ... manage communication
}
Bidirectional Communication — Production Pattern
dart
import 'dart:isolate';
import 'dart:async';
// A reusable worker that accepts tasks and returns results.
class IsolateWorker {
late final Isolate _isolate;
late final SendPort _sendPort;
final _receivePort = ReceivePort();
final _pending = <int, Completer>{};
int _taskId = 0;
Future<void> start() async {
final ready = ReceivePort();
_isolate = await Isolate.spawn(
_entry,
(ready.sendPort, _receivePort.sendPort),
debugName: 'isolate-worker',
);
_sendPort = await ready.first as SendPort;
// Route responses to the correct completer.
_receivePort.listen((message) {
if (message is _Response) {
final completer = _pending.remove(message.taskId);
if (message.error != null) {
completer?.completeError(message.error!);
} else {
completer?.complete(message.result);
}
}
});
}
Future<T> execute<T>(String task, dynamic payload) {
final id = _taskId++;
final completer = Completer<T>();
_pending[id] = completer;
_sendPort.send(_Request(id, task, payload));
return completer.future;
}
void dispose() {
_isolate.kill(priority: Isolate.immediate);
_receivePort.close();
}
static void _entry((SendPort, SendPort) ports) {
final (readyPort, mainPort) = ports;
final receivePort = ReceivePort();
readyPort.send(receivePort.sendPort);
receivePort.listen((message) {
if (message is _Request) {
try {
final result = _dispatchTask(message.task, message.payload);
mainPort.send(_Response(message.taskId, result, null));
} catch (e) {
mainPort.send(_Response(message.taskId, null, e.toString()));
}
}
});
}
static dynamic _dispatchTask(String task, dynamic payload) {
return switch (task) {
'sort' => (payload as List).cast<int>()..sort(),
'parse' => jsonDecode(payload as String),
'hash' => payload.hashCode, // placeholder for real work
_ => throw UnimplementedError('Unknown task: $task'),
};
}
}
class _Request {
final int taskId;
final String task;
final dynamic payload;
const _Request(this.taskId, this.task, this.payload);
}
class _Response {
final int taskId;
final dynamic result;
final String? error;
const _Response(this.taskId, this.result, this.error);
}
TransferableTypedData — Zero-Copy Transfer
dart
import 'dart:isolate';
import 'dart:typed_data';
// By default, messages between isolates are DEEP-COPIED (full serialization).
// For large byte buffers (Uint8List), this is O(n) in memory and time.
// TransferableTypedData transfers ownership — zero copy, O(1).
// ❌ Anti-pattern: sending a large Uint8List directly — it's deep-copied.
Future<Uint8List> processImageBad(Uint8List pixels) async {
return Isolate.run(() {
// `pixels` was deep-copied to this isolate — 2x memory usage.
return _transformPixels(pixels);
});
}
// ✓ Correct: TransferableTypedData — zero-copy transfer.
Future<Uint8List> processImageGood(Uint8List pixels) async {
// Wrap for transfer to the isolate:
final transferable = TransferableTypedData.fromList([pixels]);
final result = await Isolate.run(() {
// Materialize in the worker — the data is moved, not copied.
final data = transferable.materialize().asUint8List();
return _transformPixels(data);
});
// The result is copied back (small result, acceptable).
// For large results, wrap in TransferableTypedData again.
return result;
}
Uint8List _transformPixels(Uint8List pixels) {
// ... image processing
return pixels;
}
Worker Pool — Reusing Isolates
dart
import 'dart:isolate';
import 'dart:async';
// Spawning an isolate costs ~50ms. For many small tasks, spawn once and reuse.
class WorkerPool {
final int _size;
final List<_Worker> _workers = [];
final _idle = <_Worker>[];
final _queue = <_Task>[];
WorkerPool(this._size);
Future<void> start() async {
for (var i = 0; i < _size; i++) {
final worker = await _Worker.spawn('worker-$i');
_workers.add(worker);
_idle.add(worker);
}
_processQueue();
}
Future<T> submit<T>(dynamic Function() task) {
final completer = Completer<T>();
_queue.add(_Task(task, completer));
_processQueue();
return completer.future;
}
void _processQueue() {
while (_idle.isNotEmpty && _queue.isNotEmpty) {
final worker = _idle.removeLast();
final task = _queue.removeAt(0);
worker.execute(task).then((_) => _idle.add(worker));
}
}
void dispose() {
for (var w in _workers) w.dispose();
}
}
class _Task {
final dynamic Function() fn;
final Completer completer;
_Task(this.fn, this.completer);
}
class _Worker {
final Isolate isolate;
final SendPort sendPort;
final ReceivePort receivePort;
_Worker(this.isolate, this.sendPort, this.receivePort);
static Future<_Worker> spawn(String debugName) async {
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(
_entry,
receivePort.sendPort,
debugName: debugName,
);
final sendPort = await receivePort.first as SendPort;
return _Worker(isolate, sendPort, receivePort);
}
Future<dynamic> execute(_Task task) async {
sendPort.send(task.fn);
final result = await receivePort.first;
task.completer.complete(result);
return result;
}
void dispose() {
isolate.kill();
receivePort.close();
}
static void _entry(SendPort mainPort) {
final receivePort = ReceivePort();
mainPort.send(receivePort.sendPort);
receivePort.listen((fn) {
final result = (fn as dynamic Function())();
mainPort.send(result);
});
}
}
When to Use Isolates (and When Not To)
dart
// ── USE isolates for: CPU-heavy work that blocks the event loop ──
// If a computation takes >16ms (one frame at 60fps), it drops frames.
// Parsing large JSON, compression, image processing, sorting large datasets.
// ✓ CPU-heavy: parse a 10MB JSON string.
final data = await Isolate.run(() => jsonDecode(hugeJsonString));
// ✓ CPU-heavy: compress an image.
final compressed = await Isolate.run(() => compressImage(rawPixels));
// ── DON'T use isolates for: I/O-bound work ──
// I/O (file, network, timers) is async — the event loop handles concurrency.
// Offloading I/O to an isolate adds ~50ms spawn overhead + copy overhead.
// ❌ I/O-bound: reading a file (async, event loop handles it).
final content = await File(path).readAsString(); // ✓ on main isolate
// ❌ I/O-bound: HTTP request (async, event loop handles it).
final response = await http.get(Uri.parse(url)); // ✓ on main isolate
// ── Rule of thumb: ──
// - If it's CPU-bound and takes >16ms → Isolate.run.
// - If it's I/O-bound → await on the main isolate (event loop handles it).
// - If it's many small CPU tasks → worker pool (reuse isolates).
💡 Tips & Tricks
- Performance:
TransferableTypedDatafor large byte buffers between isolates — zero-copy transfer (O(1)) instead of deep copy (O(n)). Use for images, audio buffers, large arrays. The original buffer is invalidated after transfer. - Idiom:
Isolate.runfor one-shot CPU-heavy work —await Isolate.run(() => heavyTask())offloads to a separate isolate. Simpler thanIsolate.spawnfor fire-and-forget. The isolate is auto-killed after the function returns. - Idiom: worker pool for many small tasks — spawning an isolate costs ~50ms. For many tasks, spawn N isolates once and reuse (send messages). Use
package:worker_manageror build a pool. - Idiom: keep isolate entry functions top-level or static — closures sent to isolates must be sendable (copied). Top-level functions and static methods are safely sendable. Avoid capturing non-sendable objects.
- Idiom:
debugNamefor isolates —Isolate.spawn(entry, msg, debugName: 'image-worker')names the isolate for DevTools. Helps identify isolates in the profiler/debugger.
⚠️ Edge Cases & Gotchas
- Spawn overhead (~50ms): for small tasks, the overhead exceeds the benefit. Use a worker pool (spawn once, reuse) for many small tasks.
Isolate.runis for one-shot heavy work. - Messages are deep-copied: sending a large object to an isolate copies it (memory + time). Use
TransferableTypedDatafor large byte buffers (zero-copy). After transfer, the original is invalidated. - Isolates don't share memory: no shared mutable state. All communication is message passing. This is a feature (no races) but requires a different design (pass data, not share).
- Web doesn't support isolates:
Isolate.runthrows or runs on the main thread on web. The web is single-threaded. Use Web Workers (via JS interop) for parallelism on web. - Closures must capture sendable values: a function sent to an isolate can capture variables, but they must be sendable (copied). Capturing a non-sendable object (e.g., a file handle) fails.
- Errors propagate from
Isolate.run: a thrown error inIsolate.runis caught by theawaiter. ForIsolate.spawn, handle errors viaonErrorport orerrorsAreFatal. ReceivePortmust be closed: aReceivePortkeeps the isolate alive. Close it (receivePort.close()) when done, or the isolate won't exit (resource leak).Isolate.kill()doesn't run finalizers: forcibly kills the isolate. Use for cleanup, but prefer graceful shutdown (send a "stop" message, close ports) for clean resource release.Isolate.runcopies the closure and captures: the function and its captured variables are deep-copied to the new isolate. Mutations in the isolate don't affect the original.- No
dart:mirrorsin isolates (AOT): runtime reflection is unsupported in AOT-compiled isolates. Use code generation instead.
🧠 Spot the Bug
A developer offloads file reading to an isolate, but it's slower than reading on the main thread:
dart
Future<String> readFile(String path) async {
return Isolate.run(() => File(path).readAsStringSync());
}
Why is this slower?
Answer
File reading is I/O-bound, not CPU-bound. File.readAsStringSync() blocks the thread waiting for the disk — but File.readAsString() (async) yields to the event loop while waiting, allowing other work to proceed.
Offloading I/O to an isolate adds:
- ~50ms spawn overhead (creating the isolate).
- Message copy overhead (the result string is deep-copied back to the main isolate).
- No concurrency benefit — the main isolate could have awaited the I/O directly.
The fix — do async I/O on the main isolate:
Future<String> readFile(String path) async {
return File(path).readAsString(); // async, event loop handles concurrency
}
Use isolates for CPU-heavy work (parsing, compression, image processing) that blocks the event loop. For I/O, await on the main isolate — the event loop handles concurrency while the I/O is in progress.
// ✓ Correct isolate use — CPU-heavy JSON parsing:
final data = await Isolate.run(() => jsonDecode(hugeJsonString));