05 · Isolates¶
Async basics explained that Dart's event
loop runs Future/async code on a single thread — great for I/O,
useless for CPU-heavy work, which would block that one thread and freeze
everything else (including a Flutter UI). Isolates are Dart's answer:
independent workers with their own memory and event loop, communicating only
by passing messages.
Spawning an isolate with a SendPort¶
Isolate.spawn starts a new isolate running a top-level or static function,
handing it a SendPort to talk back to the caller.
import 'dart:isolate';
void _worker(SendPort sendPort) {
int sum = 0;
for (int i = 1; i <= 1000000; i++) {
sum += i;
}
sendPort.send(sum);
}
Future<void> main() async {
final receivePort = ReceivePort();
await Isolate.spawn(_worker, receivePort.sendPort);
final result = await receivePort.first;
print('Sum from isolate: $result');
}
// Sum from isolate: 500000500000
receivePort.first grabs the first message and is convenient for a
one-shot result; a long-lived worker would listen() on the port instead
and needs to explicitly close it (receivePort.close()) to avoid keeping
the program alive forever waiting for more messages.
The simpler API: Isolate.run¶
For "run this function on another isolate and give me the result," Isolate
.run (added in Dart 2.19) hides the port plumbing entirely — spawn, run,
collect the result, and shut the isolate down, all in one await.
import 'dart:isolate';
int _fib(int n) => n < 2 ? n : _fib(n - 1) + _fib(n - 2);
Future<void> main() async {
final sw = Stopwatch()..start();
final result = await Isolate.run(() => _fib(32));
print('fib(32) = $result (took ${sw.elapsedMilliseconds}ms wall, off the main isolate)');
}
// fib(32) = 2178309 (took 36ms wall, off the main isolate)
Reach for Isolate.run by default — it covers the "offload this expensive
computation" case that motivates isolates in the first place, without the
manual ReceivePort bookkeeping.
The trap: no shared memory, not even globals¶
The single biggest mental shift coming from thread-based concurrency in other languages: Dart isolates share nothing by default, not even top-level/static variables. Each isolate gets its own copy of global state at spawn time.
import 'dart:isolate';
int _counter = 0;
void _bumpCounter() {
_counter++; // mutates the SPAWNED isolate's own copy, not the caller's
print('inside isolate, counter is: $_counter');
}
Future<void> main() async {
print('global counter before: $_counter');
await Isolate.run(_bumpCounter);
print('global counter after Isolate.run: $_counter'); // unchanged!
}
// global counter before: 0
// inside isolate, counter is: 1
// global counter after Isolate.run: 0
_counter in the spawned isolate is a completely separate piece of memory
from _counter in main's isolate — incrementing one has zero effect on
the other. This is by design (it's what makes isolates safe without locks),
but it means "just use a global to share state across isolates" silently
does nothing useful. The only way data crosses an isolate boundary is
through messages sent over a port (or the return value of Isolate.run).
What can and can't be sent between isolates¶
Messages are deep-copied, not shared by reference — and not everything can
be copied. Primitives, String, List/Map/Set of sendable types, and a
few special types (SendPort, TransferableTypedData) work. Instances of
arbitrary custom classes, open Socket/File handles, and closures that
capture non-sendable state do not survive the trip.
Trying to send() a plain custom object (without it being a "sendable"
type Dart recognizes) throws an Invalid argument(s) error at the send
call — a common surprise when refactoring code that used to pass Dart
objects around freely on a single isolate into isolate-based code.
Cheat sheet¶
| Concept | Meaning |
|---|---|
| Isolate | Independent worker: own memory, own event loop, no shared state |
Isolate.spawn(fn, sendPort) |
Manual: spawn a worker, communicate via ports |
Isolate.run(fn) |
Simple: spawn, run, return result, auto-shutdown |
ReceivePort / SendPort |
The only channel between isolates — messages are copied |
| Global/static variables | NOT shared — each isolate gets its own independent copy |
| Sendable data | Primitives, String, collections of sendable types, SendPort |
| Not sendable | Arbitrary custom objects, open file/socket handles, most closures |
How It Actually Works¶
Spawning an isolate is genuinely expensive compared to scheduling a
Future on the same isolate — Isolate.spawn asks the Dart VM to allocate
an entirely new heap, a new stack, and a new event loop, then load and
initialize the target function's code into that fresh environment. This is
why isolates are reached for to offload real CPU-bound work (a large sort, a
sieve, image processing) rather than every asynchronous operation — the
spawn cost (typically single-digit milliseconds, more on constrained
devices) only pays off if the work you're offloading is substantial enough
to be worth escaping the main isolate's event loop for.
Messages sent via SendPort.send() are not references into the sender's
heap — the VM performs a deep copy of the message graph into a format
that can be reconstructed independently in the receiving isolate's own heap,
because the two isolates' garbage collectors run independently and neither
can safely hold a live pointer into the other's memory (that's precisely
the "no shared memory" guarantee that lets each isolate's GC pause
independently without needing to coordinate stop-the-world pauses across
isolates). The VM has a fixed table of types it knows how to copy this way —
primitives, strings, and recursively copyable collections of those, plus a
few special zero-copy-eligible types like TransferableTypedData (which
transfers ownership of a byte buffer rather than copying it) and
SendPort itself (which is copyable because it's really just an opaque
routing handle, not a reference to memory). An arbitrary custom object has
no VM-level rule for how to reconstruct it in another isolate's heap — its
class's constructor and state aren't things the copying mechanism can
introspect — so send() rejects it outright rather than silently doing
something wrong.
Isolate.run builds on top of Isolate.spawn plus a matched
ReceivePort/SendPort pair and automatic isolate shutdown once the result
is sent and received — it's pure convenience code around the primitives
above, not a different concurrency mechanism.
Exercise¶
Write a function Future<List<int>> primesUpTo(int n) that runs on a
separate isolate via Isolate.run and returns all primes below n using a
simple trial-division sieve. Time how long primesUpTo(200000) takes with a
Stopwatch, then run the same sieve logic directly on the main isolate
(no Isolate.run) while printing a counter to standard output in a loop
alongside it with Timer.periodic — observe that the main-isolate version
blocks the periodic timer from firing until it finishes, while the
isolate-based version doesn't.