I have always wondered what set of code to run in the background to make my app powerful and responsive, but I don’t really know how. Some time ago, I got to know about isolates and tried implementing them. And I should tell you, it was painful. But I recently discovered how easy it has become. So here it is.
You might have heard about isolates but never really understood them. Or maybe you might have implemented isolates, but the code was always messy and tedious to write. In any case, this blog will guide you through the ups and downs of the Isolate history and the current and better implementation that you should follow. You might want to use the latest method, or you might want to use the old method after all, it’s all up to you.
This is how the
An isolated Dart execution context.
Did you understand anything? At least I did not. So let’s begin understanding Isolates and then we’ll write our own definition. So what do we need for Isolates Recipe?
We’ll start with the bigger picture of Isolates and see what it really means and go deep down and piece all the parts together to see how each part of it works together so that we can understand what Isolates really do and why we need them.
To really understand isolates, first we need to go further back and make sure we know the answer to these two questions:
What is the difference between Processor Cores and Threads?
Core is a physical hardware component whereas thread is the virtual component that manages the tasks of the core. Cores enable completion of more work at a time, while threads enhance computational speed and throughput. Cores use content switching but threads use multiple processors for executing different processes.
What is the difference between concurrent and parallel processing?
Concurrency is when two or more tasks can start, run, and complete in overlapping time periods. It doesn’t necessarily mean they’ll ever both be running at the same instant. For example, multitasking on a single-core machine. Parallelism is when tasks literally run at the same time, e.g., on a multicore processor.
Dart uses the Isolate model for concurrency. Isolate is nothing but a wrapper around the thread. But threads, by definition, can share memory which might be easy for the developer but makes code prone to race conditions and locks. Isolates, on the other hand, cannot share memory and instead rely on message-passing mechanisms to talk with each other. If anything is difficult to comprehend, keep reading. I am sure, you’ll get it.
Using isolates, Dart code can perform multiple independent tasks at once, using additional cores if they’re available. Each Isolate has its own memory and a single thread running an event loop. We’ll get to the event loop in a minute.
Before we get into more detail, we first need to understand how async-await really works.
void main() async {
// Read some data.
final fileData = await _readFileAsync();
final jsonData = jsonDecode(fileData);
// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
Future<String> _readFileAsync() async {
final file = File(filename);
final contents = await file.readAsString();
return contents.trim();
}
We want to read some data from a file, decode that JSON, and print the JSON Keys length. We don’t need to go into the implementation details here but can take the help of the image below to understand how it works.
When we click on this button Place Bid, it sends a request to _readFileAsync, all of which is dart code that we wrote. But this function _readFileAsync, executes code using Dart Virtual Machine/OS to perform the I/O operation which in itself is a different thread, the I/O thread. This means, the code in the main function runs inside the main isolate. When the code reaches the _readFileAsync, it transfers the code execution to I/O thread and the Main Isolate waits until the code is completely executed or an error occurs. This is what await keyword does.
Now, once the contents of the files are read, the control returns back to the main isolate and we start parsing the String data as JSON and print the number of keys. This is pretty straight forward. But let’s suppose, the JSON parsing was a very big operation, considering a very huge JSON and we start manipulating the data to conform to our needs. Then this work is happening on the Main Isolate. At this point of time, the UI could hang, making our users fustrated.
As we discussed, Isolate is a wrapper around thread and each Isolate has an event loop executing events. These events are nothing but what happens when we use the application. These events are added in a queue which then the Event loop takes in and processes. These events are processed in the first-in-first-out fashion. The image below is just an example.
Let’s use this code again for understanding event handlers. We already know what is happening in this block.
void main() async {
// Read some data.
final fileData = await _readFileAsync();
final jsonData = jsonDecode(fileData);
// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
Future<String> _readFileAsync() async {
final file = File(filename);
final contents = await file.readAsString();
return contents.trim();
}
Our apps start and it draws the UI (Paint Event) is pushed on to the queue. We click on the Place Bid button and and file handling code starts. So the Tap Event is pushed in the queue. After it is complete, let’s suppose the UI is updated, so again the Paint Event is pushed in the queue.
Now because the our logic for handling the file and JSON was very small, the UI doesn’t stutter or jank. But let’s, for a while, imagine again that out code for handling file was huge and it takes a lot of time. Now the event queue and the event loop looks similar to this image below.
Now that the main isolate takes a lot of time to actually process that event, our animation or UI might hang and irritate your users, causing huge dropoffs. This is where spawning a new isolate or a worker Isolate comes in.
All our dart code in the flutter app runs in isolate. Whether it is a main isolate or a worker isolate is up to you. The main isolate is created for you and you don’t have to do anything else here. The main function starts on the Main Isolate. Once we have our main function running, we can start spawning new isolates.
So there’s 2 ways of implementing Isolates, the short and new method or the long and old method. We can use either, depending on the use case.
Let’s start with the already existing method.
As we had already discussed, Isolates, unlike threads, don’t share memory. This is done so as to prevent race conditions and locks. But the communication between Isolates is done using message passing. These messages are primitives and you can check out the whole list of objects that can be passed between isolates
To pass messages, Dart provides us with Ports. SendPort and ReceivePort.
Since we’re discussing the old method for spawning Isolates, we need to know that isolate methods need to be top-level or static functions.
Here is the
Future<String> startDownloadUsingOldIsolateMethod() async {
const String imageDownloadLink = 'this is a link';
// create the port to receive data from
final resultPort = ReceivePort();
// spawn a new isolate and pass down a function that will be used in a new isolate
// and pass down the result port that will send back the result.
// you can send any number of arguments.
await Isolate.spawn(
_readAndParseJson,
[resultPort.sendPort, imageDownloadLink],
);
return await (resultPort.first) as String;
}
What does this code do:
This is the _readAndParseJson function that receives the argument and runs the worker isolate code. This is a dummy function that does nothing but delays the control for 2 seconds and then exits. The exit function terminates the current isolate synchronously. Certain checks are performed before sending the data back to the calling isolate and the data is sent back using the SendPort.
// we create a top-level function that specifically uses the args
// which contain the send port. This send port will actually be used to
// communicate the result back to the main isolate
// This function should have been isolate-agnostic
Future<void> _readAndParseJson(List<dynamic> args) async {
SendPort resultPort = args[0];
String fileLink = args[1];
String newImageData = fileLink;
await Future.delayed(const Duration(seconds: 2));
Isolate.exit(resultPort, newImageData);
}
Although this functions correctly but we have not handled any errors which can be thrown from the worker isolate or any error which can occur while spawning a new isolate.
// Error Handling
Future<String> startDownloadUsingOldIsolateMethodWithErrorHandling() async {
const String imageDownloadLink = 'this is a link';
// create the port to receive data from
final resultPort = ReceivePort();
// Adding errorsAreFatal makes sure that the main isolates receives a message
// that something has gone wrong
try {
await Isolate.spawn(
_readAndParseJson,
[resultPort.sendPort, imageDownloadLink],
errorsAreFatal: true,
onExit: resultPort.sendPort,
onError: resultPort.sendPort,
);
} on Object {
// check if sending the entrypoint to the new isolate failed.
// If it did, the result port won’t get any message, and needs to be closed
resultPort.close();
}
final response = await resultPort.first;
if (response == null) {
// this means the isolate exited without sending any results
// TODO throw error
return 'No message';
} else if (response is List) {
// if the response is a list, this means an uncaught error occurred
final errorAsString = response[0];
final stackTraceAsString = response[1];
// TODO throw error
return 'Uncaught Error';
} else {
return response as String;
}
}
Everything is pretty same here, we just have added error handling here.
What this code does is:
This does seem like an overkill if we wanted to do just one-off message passing. One message and close the Isolate. Everytime you wanted to spawn a new isolate you’ll have to write the same code again. Since the Isolate logic is pretty custom. Every time you might want to pass in some different arguments and it would be very tedious. This is why a new method was devised for one-off transactions.
The new method: Isolate.run
// Isolates with run function
Future<String> startDownloadUsingRunMethod() async {
final imageData = await Isolate.run(_readAndParseJsonWithoutIsolateLogic);
return imageData;
}
Future<String> _readAndParseJsonWithoutIsolateLogic() async {
await Future.delayed(const Duration(seconds: 2));
return 'this is downloaded data';
}
This is all there is for the new method.
We spawn a new Isolate using the run method which abstracts out all the granular details and the error handling and saving you a lot of time. This helps in spawning, error handling, message passing and terminating the Isolate all using these few little lines of code.
One thing to note here is that the function _readAndParseJsonWithoutIsolateLogic, does not contain any custom logic for the Isolate. No ports, no arguments.
These examples above shows message passing that happens only 1 time. So run method should be used. It greatly reduces the code lines and test cases.
But if you want to create something that needs multiple messages to be passed between the Isolates, we need to use the old Isolate.spawn() method. An example of this could be when you start downloading a file on a worker isolate and want to show the progress of the download on the UI. This means the progress count needs to be passed again and again.
With this, we need to implement the whole SendPort and ReceivePort for message passing and the custom logic for receiving the arguments and sending the progress back to the main Isolate.
So, we already know how Isolates passes messages to each other. But let’s assume, the message we are passing is a huge JSON. Before Dart 2.15, this huge object passing, could involve stutter in UI. This is because, we already know that Isolate has some memory, and when one Isolate passes an object to the other, that object had to be deep copied. This meant, a lot of time for copying the object to the main Isolate which can cause a jank.
To avoid this circumstance, Isolates were reworked and Isolate Groups were invented. Isolate Groups, meaning a group of isolates, which share some common internal data structures representing the running application. This means each time a new Isolate is spawned, new internal data structures don’t need to be constructed again. Because they share them together.
Don’t confuse these internal data structures with the mutable objects. The Isolates still can’t share this memory with each other. Message passing is still needed. But, because Isolates in the same Isolate group, share the same heap, this means spawning a new Isolate is 100 times faster and consume 10–100 times less memory.
An example is a worker isolate that makes a network call to get data, parses that data into a large JSON object graph, and then returns that JSON graph to the main isolate. Before Dart 2.15, that result needed to be deep-copied, which could itself cause UI jank if the copy took longer than the frame budget. This means that the main Isolate can receive this JSON in almost constant time. And sending messages is now approximately 8 times faster.
The good news is that, if you’re using Flutter version greater than 2.8, you don’t need to do anything to use these advancements.
Hope you liked the understanding of Isolates. If you have any doubts, please comment.
Reference for this code:
Also published here.