Isolate & Concurrency #
In the world of modern app development, the ability to leverage the multi-core processing power of today’s hardware is crucial for keeping the user interface smooth. If you let heavy computation block the main thread, users will immediately experience UI jank or even App Freeze. Dart answers this challenge with a very unique architectural approach called Isolates. Unlike traditional multi-threading systems that share memory space, Isolates completely separate memory, eliminating race conditions at the architectural level. We’ll break down Dart’s concurrency model, distinguish Concurrency from Parallelism, and master one-way and two-way Isolate communication techniques for production scenarios.
Concurrency vs Parallelism #
Often, the terms concurrency and parallelism are mistaken for the same thing. In reality, they have fundamental differences in how the processor handles instructions:
- Concurrency: The ability to handle several tasks alternately on one main thread. Dart implements this using the Event Loop mechanism along with
async/awaitandFutureinstructions. Here, non-blocking tasks (like waiting for server responses) are released to the operating system, and when data returns, the task is scheduled back into the queue to execute alternately. - Parallelism: The ability to execute several tasks at exactly the same time by leveraging multiple physical CPU cores (multi-core CPU). In Dart, this true parallelism can only be achieved by creating new Isolates.
Let’s consider when to use regular async instruments and when to involve Isolates:
- Use
async/awaitfor I/O-bound tasks (tasks that spend a lot of time waiting for external hardware like network requests, reading local databases, or writing files to storage). - Use
Isolatesfor CPU-bound tasks (tasks that intensively consume processor computing power, like processing image files, decrypting large data, or running complex mathematical algorithms).
Isolate Architecture: Why No Memory Sharing? #
In traditional operating systems, Threads within the same process share the same heap memory space (Shared-Memory Threading). This approach has a serious weakness: if two threads try to change the same variable simultaneously, data corruption occurs (race condition). To handle this, developers must write complicated memory locks (mutex / locks), which are prone to triggering app deadlocks and burdening processor performance.
Dart solves this problem by designing Isolates. True to their name, each Isolate is a completely self-contained execution container.
- Each Isolate has its own heap memory and runs its own Event Loop.
- Isolate A is physically not allowed to access Isolate B’s memory, and vice versa.
- Because memory isn’t shared (Shared-Nothing Architecture), Dart avoids the need for any mutex or memory locks. This makes code execution 100% guaranteed safe from race condition issues.
Besides safety, another advantage of this architecture is that the Garbage Collector (GC) can run independently in each Isolate without needing to temporarily stop other Isolates. This minimizes visual rendering pauses on the Flutter app screen.
This architecture comparison can be visualized through the following diagram:
flowchart TD
subgraph Tradisional["Traditional Thread Model (Shared Memory)"]
direction TB
T1["Thread 1"] --> SharedMem["Shared Heap Memory"]
T2["Thread 2"] --> SharedMem
SharedMem -.->|"Prone to Race Conditions & Deadlocks"| Err["Needs Complex Mutex / Locks"]
end
subgraph DartIsolate["Dart Isolate Model (Shared Nothing)"]
direction TB
I1["Main Isolate (Heap A)"] -->|"Message Passing"| Port["Port Channel"]
I2["Worker Isolate (Heap B)"] -->|"Message Passing"| Port
Port -.->|"Safe & Isolated"| Ok["Race Condition Free"]
endThe only way for two Isolates to exchange information is by sending data copies through special communication port channels (Message Passing).
The Easiest Way: Isolate.run() and compute() #
Since Dart 3.0, you’ve been given a very practical way to move heavy tasks to a background Isolate without writing manual port boilerplate using the Isolate.run() function.
The Isolate.run() function automatically does three things under the hood:
- Dynamically creates (spawns) a new worker Isolate.
- Runs the computation function you send on that worker Isolate.
- Returns the calculation result to the main Isolate and immediately kills the worker Isolate to save memory.
Isolate.run() Implementation Example #
Let’s compare large JSON parsing execution that could trigger jank with the correct async Isolate solution:
// ANTI-PATTERN: Running large JSON processing on the UI Thread (Triggers Jank!)
Future<List<User>> loadAndParseUsersSync() async {
final jsonString = await http.read(Uri.parse('https://api.example.com/large-users'));
// This jsonDecode and object mapping runs on the UI Thread.
// If there are 10,000 items, this operation could take 200ms, freezing the screen instantly!
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => User.fromJson(json)).toList();
}
// ====================================================================
// CORRECT: Moving the large JSON decoding process to a worker Isolate
Future<List<User>> loadAndParseUsersAsync() async {
final jsonString = await http.read(Uri.parse('https://api.example.com/large-users'));
// Running the heavy function on a background Isolate. The UI Thread stays smooth at 120 FPS!
return await Isolate.run(() {
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => User.fromJson(json)).toList();
});
}
Isolate.run() vs compute() #
In the Flutter ecosystem, you may also often see a function called compute().
compute()is a thin wrapper function belonging to the Flutter framework, sitting on top ofIsolate.run().- Main Difference: The
compute()function only accepts static or top-level external functions and requires sending parameters separately. Meanwhile,Isolate.run()is much more flexible because it can accept anonymous functions (closures), so you can access local variables directly inside its code block.
Port Communication: SendPort and ReceivePort #
For scenarios where you need more complex communication (like monitoring the progress percentage of a running task), you must manage port channels manually using ReceivePort and SendPort.
ReceivePort: Acts as the message receiving door in your Isolate. This port produces aStreamobject listening for incoming data.SendPort: Acts as the message sending door. You send messages to another Isolate’sSendPortaddress so the data reaches its pairedReceivePort.
One-Way Communication Flow #
Here’s a sequence diagram of the basic one-way communication cycle between the Main Isolate and the Worker Isolate:
sequenceDiagram
participant Main as Main Isolate
participant Worker as Worker Isolate
Main->>Main: Create ReceivePort (mainPort)
Main->>Worker: Isolate.spawn(entryPoint, mainPort.sendPort)
Note over Worker: Run Heavy Computation
Worker->>Main: mainPort.sendPort.send(result)
Main->>Main: Receive Result & Close Port
Note over Worker: Worker Isolate Stops / DiesLet’s translate the diagram above into concrete Dart code:
import 'dart:isolate';
// Worker Isolate entry point function (must be a top-level or static function)
void workerEntryPoint(SendPort mainSendPort) {
// Performing heavy mathematical computation
int sum = 0;
for (int i = 1; i <= 5000000; i++) {
sum += i;
}
// Sending the result back to the Main Isolate
mainSendPort.send(sum);
}
void startOneWayCommunication() async {
// 1. Creating a receiving door in the Main Isolate
final mainReceivePort = ReceivePort();
// 2. Creating the Worker Isolate and giving it the main sending address (SendPort)
await Isolate.spawn(workerEntryPoint, mainReceivePort.sendPort);
// 3. Listening for the result message sent by the worker
mainReceivePort.listen((message) {
print('Computation result received: $message');
// 4. Always close the ReceivePort when done to avoid memory leaks
mainReceivePort.close();
});
}
Long-Lived Worker: Persistent Two-Way Communication #
Creating (spawning) a new Isolate has a startup overhead cost of about 5-10 milliseconds to allocate new heap memory. If you keep creating and killing Isolates for small, frequent tasks, it can actually decrease app efficiency.
The best solution for this problem is creating a Long-Lived Worker Isolate — a worker Isolate created once when the app first runs, staying alive in the background to listen for new work commands, and sending responses multiple times.
To achieve this, you need two-way communication where the Main Isolate and Worker Isolate each hold each other’s SendPort.
Long-Lived JsonWorker Implementation Example #
Let’s design a persistent worker class for repeatedly processing JSON parsing:
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
class JsonWorker {
Isolate? _isolate;
SendPort? _workerSendPort;
final _readyCompleter = Completer<void>();
// Starting the worker Isolate initialization
Future<void> start() async {
final mainReceivePort = ReceivePort();
// Listening for the worker's first response (containing its SendPort)
mainReceivePort.listen((message) {
if (message is SendPort) {
_workerSendPort = message;
_readyCompleter.complete(); // Worker is ready to accept tasks
} else {
_handleWorkerResponse(message);
}
});
// Spawning the worker isolate
_isolate = await Isolate.spawn(_workerEntryPoint, mainReceivePort.sendPort);
await _readyCompleter.future;
}
// Sending a new task to the worker
void parseJson(String jsonStr) {
if (_workerSendPort == null) throw StateError('Worker is not ready.');
_workerSendPort!.send(jsonStr);
}
void _handleWorkerResponse(dynamic response) {
print('Response from Worker: $response');
}
// Closing the worker when no longer needed
void dispose() {
_isolate?.kill(priority: Isolate.beforeNextEvent);
_isolate = null;
}
// Internal entry point of the continuously running Worker Isolate
static void _workerEntryPoint(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
// Initial Step: Send the worker's SendPort back to the Main Isolate
mainSendPort.send(workerReceivePort.sendPort);
// Listening for repeated work instructions from the Main Isolate
workerReceivePort.listen((message) {
if (message is String) {
// Performing the parsing process in the background
try {
final decoded = jsonDecode(message);
mainSendPort.send({'status': 'success', 'data': decoded});
} catch (e) {
mainSendPort.send({'status': 'error', 'message': e.toString()});
}
}
});
}
}
TransferableTypedData — Transfer Without Copying (Zero-Copy) #
By default, when you send a message containing objects (like List or Map) between Isolates, Dart performs a deep data duplication process (deep copy). This copying process aims to guarantee heap memory isolation is maintained.
However, if you send very large raw binary data (e.g., a 20MB high-resolution image file, audio recordings, or PDF files), the copying process will take a long computation time and waste RAM memory capacity.
To efficiently handle these large byte manipulation cases, Dart provides the TransferableTypedData class. This feature moves binary data ownership rights between Isolate heaps instantly without a memory copying process (Zero-Copy Transfer).
import 'dart:isolate';
import 'dart:typed_data';
void workerProcessBytes(SendPort mainSendPort) {
// Creating a binary data buffer of 20 Megabytes
final Uint8List rawBytes = Uint8List(20 * 1024 * 1024);
// Filling the data...
rawBytes[0] = 255;
// Wrapping the buffer into TransferableTypedData
final transferable = TransferableTypedData.fromList([rawBytes]);
// Sending the data to the main Isolate with ownership transfer (Zero-copy)
mainSendPort.send(transferable);
// IMPORTANT: After the transferable is sent, the rawBytes variable inside this Isolate
// is now invalidated and must not be accessed anymore because its memory has been moved!
}
Isolate Limitations: What Can and Cannot Be Sent? #
Because of strict memory isolation, not all Dart objects can be passed through the SendPort.send() parameter.
Here’s a reference matrix for determining which objects are safe to send:
| Object Type | Compatibility Status | Description |
|---|---|---|
Primitive data types (null, bool, int, double, String) | CAN | Sent directly. |
Basic collections (List, Map, Set) | CAN | Provided all elements inside are also compatible data types. |
SendPort | CAN | Very important for initiating two-way communication paths. |
| Your own custom class objects | CAN | Regular Dart objects without native dependencies. |
TransferableTypedData | CAN | Sent without copying (zero-copy). |
| Anonymous Functions (Closures / Lambdas) | CANNOT | Because closures carry a memory scope (context) reference of where they were created. |
ReceivePort | CANNOT | Receiving ports cannot move between Isolates. |
Native Objects (Socket, open file handles) | CANNOT | Have direct operating system dependencies that cannot be isolated. |
Summary #
- Isolate Architecture: Dart uses Isolate containers that don’t share heap memory (Shared-Nothing), eliminating race condition risks and the need for memory locks.
- Concurrency vs Parallelism: Use regular
async/awaitfor network-waiting tasks (I/O-bound) and useIsolatesfor CPU-intensive calculation tasks (CPU-bound).- Isolate.run(): The modern practical way to automatically move heavy computation to the background without writing manual communication ports.
- SendPort & ReceivePort: The basic building blocks of inter-Isolate communication. Always close
ReceivePortwhen done to prevent memory leaks.- Long-Lived Worker: The best design pattern for keeping one background Isolate alive to serve repeated tasks without new Isolate creation overhead.
- TransferableTypedData: Optimizes large binary byte data transfer between Isolates instantly without a memory duplication process (zero-copy).