Introduction
Picture this: You're building a flutter application that needs to process large datasets, handle UI interactions, and maintain smooth animations - all simultaneously. If you're coming from JavaScript, you might be thinking, "I'll just use Web Workers!" But what if I told you Dart has an even more elegant solution?
Like JavaScript, Dart is fundamentally a single-threaded language, yes. This means that at its core, it executes one piece of code at a time in a single thread of execution, dubbed the "main isolate." However, where JavaScript uses Web Workers for parallel processing, Dart introduces us to the concept of Isolates - and this is where things get interesting.
> p.s we will be building a small project in the end
Let's first understand "how isolates are different compared to threads"?
Traditional threads are like multiple cooks sharing one kitchen. They're all working in the same space, using the same tools, and accessing the same ingredients. Sure, you can get more done, but:
- Someone might grab the knife you were using (resource contention)
- Two cooks might reach for the same ingredient (race conditions)
- Everyone has to constantly check what others are doing (synchronization overhead)
- The kitchen becomes chaos without strict rules (mutex/locks)
Isolates
Dart looked at this chaos and said, "What if each cook had their own kitchen?" That's essentially what isolates are. Instead of sharing memory and fighting over resources, each isolate gets its own memory heap. It's like having multiple copies of your app running independently.
Isolates provide:
- Complete Memory Isolation:
- Each isolate has its own memory heap
- No shared memory between isolates
- Memory safety by design
- Message-Passing Communication (We will dive deep into this)
- Since Isolates have separate memories, we communicate between them by passing messages using the receiver port and sender port.
- Resource Efficiency
- Lighter weight than OS threads.
- More efficient memory usage.
- Better suited for Dart's garbage collection.
The messages between the isolates are like orders going between kitchens (Memory Space)
The dart event loop is the waiter taking orders and delivering dishes
The event loops ensures that:
- Messages are delivered in the right order
- No isolate gets overwhelmed
- Everything stays responsive
To understand dart event loop in detail, you can look up to this official blog by dart.
Dart event loop has two queues (like having two types of order tickets in our restaurant):
-
Microtask Queue (VIP Orders)
- Handles high-priority tasks
- Always processed first
- Perfect for quick, internal operations
-
Event Queue (Regular Orders)
- Handles regular async operations
- I/O operations, timers, isolate messages
- Processed after all microtasks are done
Understanding the Main Isolate
The main isolate in Flutter isn't just another isolate - it's the VIP of your app.
void main() {
// You're already in the main isolate when this runs!
runApp(MyApp());
}
- UI Updates happen only in main isolates.
- Only the main isolate can communicate with platform-specific code.
Event loop example
void main() {
print('🏃 Starting the show'); // 1: Regular synchronous code
Future(() {
print('🎭 Event queue'); // 3: Gets queued in event queue
});
Future.microtask(() {
print('⚡ Microtask queue'); // 2: Gets queued but runs first
});
print('🏁 Synchronous end'); // 1: Regular synchronous code
}
// Output:
// 🏃 Starting the show
// 🏁 Synchronous end
// ⚡ Microtask queue
// 🎭 Event queue
The real magic happens when isolates and event loops work together. Each isolate has its own event loop, but they can communicate through message passing.
Working with isolates
void main() async {
// Create a ReceivePort for incoming messages
final receivePort = ReceivePort();
// Spawn a new isolate
final isolate = await Isolate.spawn(
simpleWorker, // Function to run in isolate
receivePort.sendPort // Port for sending messages back
);
// Listen for messages
receivePort.listen((message) {
print('Received: $message');
// Clean up when done
receivePort.close();
isolate.kill();
});
}
// Worker function that runs in the isolate
void simpleWorker(SendPort sendPort) {
sendPort.send('Hello from isolate!');
}
Understanding Ports
- ReceivePort
- Acts as the inbox for messages
- Created in the isolate where you want to receive messages
- Provides a SendPort that others can use to send messages to this isolate
- Can be listened to using listen() or used in an async for loop
- SendPort
- Acts as the outbox for sending messages
- Obtained from a ReceivePort
- Used to send messages to the corresponding ReceivePort
- Can be passed between isolates
Passing complex messages
import 'dart:isolate';
class WorkMessage {
final String command;
final Map<String, dynamic> data;
WorkMessage(this.command, this.data);
}
void main() async {
final receivePort = ReceivePort();
// Spawn isolate with initial message
final isolate = await Isolate.spawn(
dataProcessor,
[
receivePort.sendPort,
WorkMessage('process', {'items': [1, 2, 3]})
],
);
// Handle responses
await for (final message in receivePort) {
if (message is String) {
print('Received: $message');
receivePort.close(); // Close after processing
break; // Exit loop when done
}
}
isolate.kill();
}
void dataProcessor(List<dynamic> initialMessage) {
final SendPort sendPort = initialMessage[0];
final WorkMessage workMessage = initialMessage[1];
final result = processData(workMessage);
// Send back result
sendPort.send('Processed: $result');
}
String processData(WorkMessage message) {
final items = message.data['items'] as List<int>;
final processedItems = items.map((item) => item * 2).toList();
return processedItems.toString();
}
// Output
// Received: Processed: [2, 4, 6]
Key Points to Remember:
- Always close ReceivePorts when they're no longer needed to prevent memory leaks
- Only send data that can be serialized (basic types, lists, maps, etc.)
- Consider using typed ports or message enums for better code organization
- Handle errors appropriately as they don't automatically propagate across isolates
Image processing using Isolates vs Without Isolates.
In this example code, We will apply filter over an image by vertically breaking the image into 4 equal chunks, applying blur filter over them using isolates. We will Process each chunk in parallel using isolates, and compare it to normal case
With Isolates
import 'dart:io';
import 'dart:isolate';
import 'package:image/image.dart' as img;
void main() async {
print('Image processing using isolates...');
final stopwatch = Stopwatch()..start();
final inputFile = File('bin/input_image.jpg');
// Decode the image from file bytes
final image = img.decodeImage(inputFile.readAsBytesSync());
final width = image!.width;
final height = image.height;
final numberOfIsolates = 4;
final chunkHeight = height ~/ numberOfIsolates;
// Divide image into chunks
final chunks = List.generate(
numberOfIsolates,
(i) => img.copyCrop(image, x: 0, y: i * chunkHeight, width: width, height: chunkHeight),
);
// Process each chunk in parallel using isolates
final results = await Future.wait(
List.generate(numberOfIsolates, (i) => processInIsolate(chunks[i])),
);
// Create an empty image to hold the final result
final resultImage = img.Image(width: width, height: height);
// Manually copy pixels from processed chunks into the result image
for (int i = 0; i < numberOfIsolates; i++) {
final chunk = results[i];
for (int y = 0; y < chunk.height; y++) {
for (int x = 0; x < chunk.width; x++) {
// Set pixel from chunk to the result image
resultImage.setPixel(x, y + (i * chunkHeight), chunk.getPixel(x, y));
}
}
}
// Save the resulting image
final outputFile = File('output_image.jpg');
outputFile.writeAsBytesSync(img.encodeJpg(resultImage));
stopwatch.stop();
print('Image processed in ${stopwatch.elapsedMilliseconds} ms');
}
Future<img.Image> processInIsolate(img.Image chunk) async {
final receivePort = ReceivePort();
await Isolate.spawn(applyBlur, [chunk, receivePort.sendPort]);
return await receivePort.first;
}
void applyBlur(List<dynamic> args) {
final img.Image chunk = args[0];
final SendPort sendPort = args[1];
// Apply Gaussian blur to the image chunk
final blurred = img.gaussianBlur(chunk, radius: 10);
// Send the processed image back to the main isolate
sendPort.send(blurred);
}
Without Isolates
import 'dart:io';
import 'package:image/image.dart' as img;
void main() {
final stopwatch = Stopwatch()..start();
final inputFile = File('bin/input_image.jpg');
final image = img.decodeImage(inputFile.readAsBytesSync())!;
final blurred = img.gaussianBlur(image, radius: 10);
File('output_no_isolates.jpg').writeAsBytesSync(img.encodeJpg(blurred));
stopwatch.stop();
print('Processing time without isolates: ${stopwatch.elapsedMilliseconds} ms');
}
Comparing the results
- With Isolates
Image processing using isolates...
Image processed in 269 ms
- Without Isolates
Processing time without isolates: 812 ms
Messaging between Isolates
- Basic One-way Messaging The simplest form of isolate communication - when you just need to send data and forget about it.
void main() async {
// Create a receive port for incoming messages
final receivePort = ReceivePort();
// Spawn an isolate and pass the SendPort
await Isolate.spawn(
simpleWorker,
receivePort.sendPort,
);
// Listen for any messages
receivePort.listen((message) {
print('Main received: $message');
});
}
void simpleWorker(SendPort sendPort) {
sendPort.send('Task completed!');
sendPort.send('More results...');
}
Output:
Main received: Task completed!
Main received: More results...
You saw that we didn't kill the port here, hence the code will run forever
- Two-way Communication Pattern When your isolates need to have a conversation - useful for ongoing tasks or complex data processing.
import 'dart:isolate';
void main() async {
// Set up the initial communication channel
final mainReceivePort = ReceivePort();
// Spawn the isolate
final isolate = await Isolate.spawn(
twoWayWorker,
mainReceivePort.sendPort,
);
// Store worker's SendPort when received
late SendPort workerSendPort;
// Set up the two-way communication
mainReceivePort.listen((message) {
if (message is SendPort) {
// First message is the worker's SendPort
workerSendPort = message;
// Now we can start sending messages to worker
workerSendPort.send('Start processing!');
} else {
print('Main received: $message');
}
});
}
void twoWayWorker(SendPort mainSendPort) {
// Create a receiver for the worker
final workerReceivePort = ReceivePort();
// Send the worker's SendPort back to main
mainSendPort.send(workerReceivePort.sendPort);
// Listen for messages from main
workerReceivePort.listen((message) {
print('Worker received: $message');
// Process and send back results
mainSendPort.send('Processed: $message');
});
}
Thanks for reading :p
Connect with me on twitter at https:x.com/twtutkarsh
Top comments (0)