跳转至

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.

img

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 Futureconstructor 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.

img

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.

img

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:

  1. This time, you have a void function with a SendPort parameter.
  2. 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:

  1. You created a receive port to listen for messages from the new isolate.
  2. 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.
  3. 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.
  4. The second argument of Isolate.spawn is the argument for the entry-point function. In this case, it’s a SendPort object.
  5. ReceivePort implements the Stream interface, so you can treat it like a stream. Calling await 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.spawnUriconstructor, 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 like person.home.address and person.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 now List<Object> instead of SendPort.
  • 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:

img

Two-way communication isn’t built into isolates by default, but you can set it up in a two-step process:

  1. 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.
  2. 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.

img

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:

  1. 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.
  2. 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.
  3. 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.

img

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:

  1. 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.
  2. 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.
  3. 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 method work.doSomething and 'doSomethingElse' to work.doSomethingElse. This is a more realistic scenario than saying, “Hey”.
  4. 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 mainfunction 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:

  1. Earth encapsulates all your isolate communication code. It represents the main isolate.
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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 type Map where the keys are method and result. As you’ve seen, maps and lists are useful when you want to pass multiple values.
  4. 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.