14 IsolatesWritten by Jonathan Sande¶
Most of the time, running your code synchronously is fine, and for long-running I/O tasks, you can use Dart libraries that return futures or streams. But sometimes, you might discover your code is too computationally expensive and degrades your app’s performance. That’s when you should offload that code to a separate thread so it can run in parallel.
As you recall from Chapter 11, “Concurrency”, the way to achieve parallelism in Dart is to create a new isolate. Isolates are so named because their memory and code are isolated from the outside world. An isolate’s memory isn’t accessible from another isolate, and each isolate has its own thread for running Dart code in an event loop. The only way to communicate from one isolate to another is through message passing. Thus, when a worker isolate finishes a task, it passes the results back to the main isolate as a message.
The description above is fine from the developer’s perspective. That’s all you need to know. The internal implementation, though, is somewhat more complex. When you create a new isolate, Dart adds it to an isolate group. The isolate group shares resources between the isolates, so creating a new isolate is fast and memory efficient. This includes sharing the available memory, also called a heap. Isolates still can’t modify the mutable objects in other isolates, but they can share references to the same immutable objects. In addition to sharing the heap, isolate groups have helper threads to work with all the isolates. This is more efficient than performing these tasks separately for each isolate. An example of this is garbage collection.
Note
Dart manages memory with a process known as garbage collection. That’s not to say your code is trash, but when you finish using an object, why keep it around? It’s like all those pictures you drew when you were 5. Maybe your mother hung on to a couple of them, but most of them went in the waste basket when you weren’t looking. Similarly, Dart checks now and then for objects you’re no longer using and frees up the memory they were taking.
Unresponsive Applications¶
Doing too much work on the main isolate will make your app appear janky at best and completely unresponsive at worst. This can happen with both synchronous and asynchronous code.
App-Stopping Synchronous Code¶
First, look at some synchronous code that puts a heavy load on the CPU.
Add the following code as a top-level function to your project:
String playHideAndSeekTheLongVersion() {
var counting = 0;
for (var i = 1; i <= 10000000000; i++) {
counting = i;
}
return '$counting! Ready or not, here I come!';
}
Counting to 10 billion takes a while — even for a computer. If you run that function in a Flutter app, your app’s UI will freeze until the function finishes.
Run the function now from the body of main
like so:
print("OK, I'm counting...");
print(playHideAndSeekTheLongVersion());
Unless you have a reeeeally nice computer, you’ll notice a significant pause until the counting finishes. That was the CPU doing a lot of work.
App-Stopping Asynchronous Code¶
If you’ve finished Chapter 11, “Concurrency”, and Chapter 12, “Futures”, you should know that making the function asynchronous doesn’t fix the problem.
Replace playHideAndSeekTheLongVersion
with the following asynchronous implementation:
Future<String> playHideAndSeekTheLongVersion() async {
var counting = 0;
await Future(() {
for (var i = 1; i <= 10000000000; i++) {
counting = i;
}
});
return '$counting! Ready or not, here I come!';
}
Run that using await
:
Future<void> main() async {
print("OK, I'm counting...");
print(await playHideAndSeekTheLongVersion());
}
Another long wait. That would be a quick uninstall followed by a one-star rating if it happened on your phone app.
Adding the computationally intensive loop as an anonymous function in a Future
constructor makes it a future. However, think about what’s going on here. Dart simply puts that anonymous function at the end of the event queue. True, all the events before it will go first, but once the 10-billion-counter-loop gets to the end of the queue, it’ll start running synchronously and block the app until it finishes. Using a future only delays the eventual block.
One-Way Isolate Communication¶
When you’re accustomed to using futures from the Dart I/O libraries, it’s easy to get lulled into thinking that futures always run in the background, but that’s not the case. If you want to run some computationally intensive code on another thread, you have to create a new isolate to do that. The term for creating an isolate in Dart is called spawning.
Since for all practical purposes isolates don’t share any memory, they can only communicate by sending messages. To send a message, you need a send port and a receive port. Picture a receive port like an audio speaker that listens for messages and plays them when they come. Every receive port has a send port, which you can picture as a microphone connected by a long cord back to the receive port. Message communication happens only in one direction. You send messages with the send port and listen to them with the receive port. There’s no way to use the receive port to send messages to the send port.
Normally, before you spawn a new isolate, you first create a ReceivePort
object. Then, when you spawn the isolate, you pass it a reference to the SendPort
property of your receive port. That way, the new isolate can send messages over the send port back to the main isolate’s receive port.
This type of one-way communication is useful for one-off tasks. You give an isolate some work to do, and when it’s finished, it returns the result over the send port.
Here are some examples where one-way communication is fine:
- Decoding JSON.
- Performing a scientific calculation.
- Processing an image.
In the next section, you’ll move your hide-and-seek function over to an isolate. This will demonstrate the steps you need to take when spawning an isolate and setting up the communication ports.
Using a Send Port to Return Results¶
Before you create a new isolate, you need to write the first function the isolate will run. This function is called the entry point. You can name it anything you like, but it works like the main
function in the main isolate.
You’ll modify playHideAndSeekTheLongVersion
to run as the entry-point function on the new isolate. You must pass any result that this function computes back over a send port. You can’t just return it directly from the function. That means you need to pass in the send port as an argument.
SendPort
is part of the dart:isolate
library, so import that first:
import 'dart:isolate';
Replace your previous implementation of playHideAndSeekTheLongVersion
with the following:
// 1
void playHideAndSeekTheLongVersion(SendPort sendPort) {
var counting = 0;
for (var i = 1; i <= 1000000000; i++) {
counting = i;
}
final message = '$counting! Ready or not, here I come!';
// 2
Isolate.exit(sendPort, message);
}
Here are a couple of comments:
- This time, you have a
void
function with aSendPort
parameter. - Calling
Isolate.exit
sends your message over the send port and then shuts the isolate down.
Spawning the Isolate and Listening for Messages¶
You’ve finished preparing the code that your new isolate will run. Now, you have to create the isolate itself.
Replace main
with the following code:
Future<void> main() async {
// 1
final receivePort = ReceivePort();
// 2
await Isolate.spawn<SendPort>(
// 3
playHideAndSeekTheLongVersion,
// 4
receivePort.sendPort,
);
// 5
final message = await receivePort.first as String;
print(message);
}
Here’s what you did:
- You created a receive port to listen for messages from the new isolate.
- Next, you spawned a new isolate and gave it two arguments. Specifying
SendPort
as the generic type tells Dart the type of the entry-point function parameter. - The first argument of
Isolate.spawn
is the entry-point function. That function must be a top-level or static function. It must also take a single argument. - The second argument of
Isolate.spawn
is the argument for the entry-point function. In this case, it’s aSendPort
object. ReceivePort
implements theStream
interface, so you can treat it like a stream. Callingawait receivePort.first
waits for the first message coming in the stream and then cancels the stream subscription.playHideAndSeekTheLongVersion
only sends a single message; that’s all you need to wait for.
Run the code above, and after a pause, you’ll see the following output:
1000000000! Ready or not, here I come!
You counted to only a billion this time, so the pause was shorter. Because this was done on another isolate, though, even 10 billion wouldn’t freeze your app’s UI.
Note
Flutter has a highly simplified way of starting a new isolate, performing some work and then returning the result using a function called compute
. Rather than passing the function a send port, you just pass it any needed values. In this case, you could just pass it the number to count to:
await compute(playHideAndSeekTheLongVersion, 1000000000);
Sending Multiple Messages¶
The previous example showed how to send a single message from the worker isolate to the parent isolate. You can modify that example to send multiple messages.
Replace playHideAndSeekTheLongVersion
with the following code:
void playHideAndSeekTheLongVersion(SendPort sendPort) {
sendPort.send("OK, I'm counting...");
var counting = 0;
for (var i = 1; i <= 1000000000; i++) {
counting = i;
}
sendPort.send('$counting! Ready or not, here I come!');
sendPort.send(null);
}
Note the following points:
SendPort.send
is the way to send a message over the send port. Calling it three times means you send three messages.- This time, you don’t shut down the isolate here. Instead, you send
null
as a signal that you’re finished.null
doesn’t need to be your signal. You could also send the string'done'
or'finished'
. You just have to agree on the signal with the main isolate that’s listening.
Note
In addition to strings and null
, Dart allows you to send almost any data type with SendPort.send
as long as you’re sending a message to another isolate in the same isolate group. This even includes user-defined data types like User
or Person
, but it comes with some restrictions. For example, you can’t send Socket
or ReceivePort
objects. If the isolate is in a different isolate group, which you can create using the Isolate.spawnUri
constructor, you can send only a few basic data types.
Dart sends immutable objects like strings by reference, which makes passing them very fast. Mutable objects, on the other hand, are copied. That can take longer for large objects with many properties which in turn have other properties. For example,
person
might include properties likeperson.home.address
andperson.job.duties
.
Next, replace the body of main
with the following code:
final receivePort = ReceivePort();
final isolate = await Isolate.spawn<SendPort>(
playHideAndSeekTheLongVersion,
receivePort.sendPort,
);
receivePort.listen((Object? message) {
if (message is String) {
print(message);
} else if (message == null) {
receivePort.close();
isolate.kill();
}
});
Because receivePort
is a stream, you can listen to it like any other stream. If the message is a string, you just print it. But if the message is null
, that’s your signal to close the receive port and shut down the isolate.
Run the code, and you’ll see:
OK, I'm counting...
1000000000! Ready or not, here I come!
Passing Multiple Arguments When Spawning an Isolate¶
The function playHideAndSeekTheLongVersion
in the example above only took a single parameter of type SendPort
. What if you want to pass in more than one argument? For example, it might be nice to specify the integer you want to count to.
An easy way to accomplish this is to make the function parameter a list or a map instead of a send port. Then, you can make the first element the send port and add as many other elements as you need for additional arguments.
Replace playHideAndSeekTheLongVersion
with the following modification:
void playHideAndSeekTheLongVersion(List<Object> arguments) {
final sendPort = arguments[0] as SendPort;
final countTo = arguments[1] as int;
sendPort.send("OK, I'm counting...");
var counting = 0;
for (var i = 1; i <= countTo; i++) {
counting = i;
}
sendPort.send('$counting! Ready or not, here I come!');
sendPort.send(null);
}
The parameter now is List<Object> arguments
. This isn’t quite as readable as having separately named parameters, but it allows you to pass in as many arguments as you like. With a list, you access the arguments by index. Your code assumes that arguments[0]
is the send port and arguments[1]
is the integer you’re counting to.
Note
If you want to use a map instead, write Map<String, Object> arguments
as the function parameter. Then, you could extract the send port with arguments['sendPort']
and the integer with arguments['countTo'];
. This offers the advantage of being somewhat more readable than arguments[0]
and arguments[1]
.
You’ve updated the entry-point function, but you also must modify how you create the isolate.
Replace the isolate
assignment in main
with the following version:
final isolate = await Isolate.spawn<List<Object>>(
playHideAndSeekTheLongVersion,
[receivePort.sendPort, 999999999],
);
Here are the differences:
- The generic type of
spawn
is nowList<Object>
instead ofSendPort
. - The second parameter of spawn is a list, where the first element is the send port and the second element is the value to count to.
Rerun the code, and you should see the new result:
OK, I'm counting...
999999999! Ready or not, here I come!
Two-Way Isolate Communication¶
One-way communication is fine for single tasks, but sometimes you might need to keep an isolate around for a while.
Here are some examples of long-running tasks where two-way communication may be necessary:
- Communicating with a game server.
- Decoding multiple JSON files.
- Handling server clients.
For two-way communication, both sides need a send port and a receive port:
Two-way communication isn’t built into isolates by default, but you can set it up in a two-step process:
- Create a receive port in the parent isolate and pass its send port to the child isolate. This allows the child to send messages to the parent.
- Create a receive port in the child isolate and send that receive port’s send port back to the parent isolate. This allows the parent to send messages to the child.
The sections below will guide you through setting up two-way communication. In the example, Earth will represent the main or parent isolate, and Mars will represent the worker or child isolate. The example will demonstrate two-way communication as Earth and Mars communicate back and forth.
Defining the Work¶
You’ll start by creating a class with methods that perform some work. Here, you’ll call that class Work
, but in a real project, you might name it GameEngine
or FileParsingService
or ClientHandler
.
Add the following import to your project file:
import 'dart:io';
This will give you access to the sleep
function, which you’ll use below.
Add the following class to your project file:
class Work {
Future<int> doSomething() async {
print('doing some work...');
sleep(Duration(seconds: 1));
return 42;
}
Future<int> doSomethingElse() async {
print('doing some other work...');
sleep(Duration(seconds: 1));
return 24;
}
}
You can use both sleep
and Future.delayed
to pause your program. But sleep
is synchronous, so it will block all execution of other code for the full duration you specify. If you used it in an app with a user interface, your app would become unresponsive during that time. Here, sleep
represents some computationally intensive task that you need to run on another isolate. In the example that follows, this is work that Earth requires but is offloading to Mars.
Creating an Entry Point for an Isolate¶
You’ll begin by creating your entry-point function. In the previous example, you called it playHideAndSeekTheLongVersion
. This time you’ll simply name it _entryPoint
.
First, make sure you still have the dart:isolate
import at the top of the project file:
import 'dart:isolate';
Then, add the following entry point as a top-level function:
// 1
Future<void> _entryPoint(SendPort sendToEarthPort) async {
// 2
final receiveOnMarsPort = ReceivePort();
sendToEarthPort.send(receiveOnMarsPort.sendPort);
// 3
final work = Work();
// TODO: add listener
}
This is what you’ve got so far:
sendToEarthPort
is the send port that belongs to Earth’s receive port. The Mars isolate can use this port to send messages back to the Earth isolate.- In the second step of setting up two-way communication, you create a receive port in the child isolate and send its send port back to the parent isolate. Thus, the first “message” you send back to Earth is
receiveOnMarsPort.sendPort
. - You create an instance of
Work
inside_entryPoint
. Now, you’re ready to perform your heavy work on the Mars isolate.
The diagram below pictures what you’re trying to accomplish. You’ve begun setting up the ports on the Mars side, and you’ll create the Earth ports later.
Listening for Messages from the Parent Isolate¶
A receive port is a stream, so you can listen to receiveOnMarsPort
to respond to messages from Earth.
Replace the comment // TODO: add listener
from above with the following listener:
receiveOnMarsPort.listen((Object? messageFromEarth) async {
// 1
await Future<void>.delayed(Duration(seconds: 1));
print('Message from Earth: $messageFromEarth');
// 2
if (messageFromEarth == 'Hey from Earth') {
sendToEarthPort.send('Hey from Mars');
}
else if (messageFromEarth == 'Can you help?') {
sendToEarthPort.send('sure');
}
// 3
else if (messageFromEarth == 'doSomething') {
final result = await work.doSomething();
// 4
sendToEarthPort.send({
'method': 'doSomething',
'result': result,
});
}
else if (messageFromEarth == 'doSomethingElse') {
final result = await work.doSomethingElse();
sendToEarthPort.send({
'method': 'doSomethingElse',
'result': result,
});
sendToEarthPort.send('done');
}
});
These points correspond to the numbered comments in the code:
- It takes at least five minutes for messages from Earth to reach Mars in real life. Pausing for a second here will make the final result feel a bit like interplanetary communication.
- Depending on the message from Earth, you can respond in different ways. And because you have Earth’s send port, you can send messages back to Earth.
- Generally, what you’ll do when you listen to messages from the parent isolate is to map some string message to its corresponding method on your worker class. For example, you match the string
'doSomething'
to the methodwork.doSomething
and'doSomethingElse'
towork.doSomethingElse
. This is a more realistic scenario than saying, “Hey”. - When these tasks have completed, you pass the result back to Earth over its send port. Remember that these methods will complete asynchronously. Including the method name in the message will help Earth know which method call this is coming from.
Preparing to Create the Child Isolate¶
In the one-way communication example earlier, you wrote all the isolate code inside of main
. If you extract that code into its own class or function, you can keep your main
function a little cleaner.
Add the following class to your project:
// 1
class Earth {
// 2
final _receiveOnEarthPort = ReceivePort();
SendPort? _sendToMarsPort;
Isolate? _marsIsolate;
// TODO: create isolate
// 3
void dispose() {
_receiveOnEarthPort.close();
_marsIsolate?.kill();
_marsIsolate = null;
}
}
Here are a few notes:
Earth
encapsulates all your isolate communication code. It represents the main isolate.- You’ve defined a receive port to listen to messages from the Mars child isolate and a send port to send messages back to Mars. Mars will give you this send port later after you’ve spawned the isolate.
- When you finish the work on Mars, you can call
dispose
to shut the isolate down and clean up the resources.
Creating the Child Isolate¶
Now that you’ve written the supporting code, you’re finally ready to create the Mars isolate.
Replace the comment // TODO: create isolate
above with the following code:
Future<void> contactMars() async {
if (_marsIsolate != null) return;
_marsIsolate = await Isolate.spawn<SendPort>(
_entryPoint,
_receiveOnEarthPort.sendPort,
);
// TODO: add listener
}
Isolate.spawn
assigns a value to _marsIsolate
. You provide the Mars _entryPoint
function with a send port as an argument.
Listening for Messages From the Child Isolate¶
Next, you must listen and respond to messages from your Mars isolate.
Replace the comment // TODO: add listener
above with the following listener on the _receiveOnEarthPort
stream:
_receiveOnEarthPort.listen((Object? messageFromMars) async {
await Future<void>.delayed(Duration(seconds: 1));
print('Message from Mars: $messageFromMars');
// 1
if (messageFromMars is SendPort) {
_sendToMarsPort = messageFromMars;
_sendToMarsPort?.send('Hey from Earth');
}
// 2
else if (messageFromMars == 'Hey from Mars') {
_sendToMarsPort?.send('Can you help?');
}
else if (messageFromMars == 'sure') {
_sendToMarsPort?.send('doSomething');
_sendToMarsPort?.send('doSomethingElse');
}
// 3
else if (messageFromMars is Map) {
final method = messageFromMars['method'] as String;
final result = messageFromMars['result'] as int;
print('The result of $method is $result');
}
// 4
else if (messageFromMars == 'done') {
print('shutting down');
dispose();
}
});
Here’s what’s happening:
- Recall that the first message you sent back to Earth from Mars was the send port for Mars’ receive port. Thus, the first message you receive in this message stream should be of type
SendPort
. This is your messaging link to Mars, so save a reference to it in_sendToMarsPort
. - Respond to messages from Mars. You can’t directly call functions in the Mars isolate, but you can send strings that will trigger function calls. You’ve already mapped those strings to their respective functions when you wrote
_entryPoint
earlier. - Because you can’t directly call functions, you also don’t directly get a function’s return value. However, you can listen for a message you know takes the form of a return value. In the case of
_entryPoint
, you defined the return value to be of typeMap
where the keys aremethod
andresult
. As you’ve seen, maps and lists are useful when you want to pass multiple values. - The isolate sends a message that it’s all finished with its work now, so you can shut it down.
Note
As an alternative, you could use a StreamQueue
rather than calling listen
on the receive port stream. This would make the code easier to read in some ways. The example here didn’t use it, because that would have required more background explanation. StreamQueue
is worth looking into, though.
Running Your Code¶
Everything is set up now, so you’re ready to see if it works.
Replace main
with the following:
Future<void> main() async {
final earth = Earth();
await earth.contactMars();
}
Run your code, and you should see the following exchange take place in one-second increments as your main isolate on Earth communicates with the Mars isolate:
Message from Mars: SendPort
Message from Earth: Hey from Earth
Message from Mars: Hey from Mars
Message from Earth: Can you help?
Message from Mars: sure
Message from Earth: doSomething
doing some work...
Message from Earth: doSomethingElse
doing some other work...
Message from Mars: {method: doSomething, result: 42}
The result of doSomething is 42
Message from Mars: {method: doSomethingElse, result: 24}
The result of doSomethingElse is 24
Message from Mars: done
shutting down
Note that the result of doSomething
doesn’t come directly after Earth sends the message, and the same is true for doSomethingElse
. Isolate communication is inherently asynchronous.
Challenges¶
Before finishing, here are some challenges to test your knowledge of isolates. It’s best if you try to solve them yourself, but if you get stuck, solutions are available in the challenge folder of this chapter.
Challenge 1: Fibonacci From Afar¶
Calculate the nth Fibonacci number. The Fibonacci sequence starts with 1, then 1 again, and then all subsequent numbers in the sequence are simply the previous two values in the sequence added together (1, 1, 2, 3, 5, 8…).
If you worked through the challenges in Dart Apprentice: Fundamentals, Chapter 6, “Loops”, you’ve already solved this. Repeat the challenge but run the code in a separate isolate. Pass the value of n to the new isolate as an argument and send the result back to the main isolate.
Challenge 2: Parsing JSON¶
Parsing large JSON strings can be CPU intensive and thus a candidate for a task to run on a separate isolate. The following JSON string isn’t particularly large, but convert it to a map on a separate isolate:
const jsonString = '''
{
"language": "Dart",
"feeling": "love it",
"level": "intermediate"
}
''';
Key Points¶
- You can run Dart code on another thread by spawning a new isolate.
- Dart isolates don’t share any mutable memory state and communicate only through messages.
- You can pass multiple arguments to an isolate’s entry-point function using a list or a map.
- Use a
ReceivePort
to listen for messages from another isolate. - Use a
SendPort
to send messages to another isolate. - For long-running isolates, you can set up two-way communication by creating a send port and receive port for both isolates.
Where to Go From Here?¶
You know how to run Dart code in parallel now. As a word of advice, though, don’t feel like you need to pre-optimize everything you think might be a computationally intensive task. Write your code as if it will all run on the main isolate. Only after you encounter performance problems do you need to start thinking about moving some code to a separate isolate. Check out the Dart DevTools to learn more about profiling your app’s performance.
One great thing about isolates is that when a child isolate crashes, it doesn’t need to bring your whole app down. For example, you could have hundreds or even thousands of separate isolates handling user connections on a server. One malicious user who finds a way to crash the isolate wouldn’t affect the other users on the server. Learning how to listen for and handle isolate errors would be a great next step.