Chapters

Hide chapters

Dart Apprentice: Beyond the Basics

First Edition · Flutter · Dart 2.18 · VS Code 1.71

Dart Apprentice: Beyond the Basics

Section 1: 15 chapters
Show chapters Hide chapters

14. Isolates
Written by Jonathan Sande

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

Memory Dart Thread Event Loop Isolate Memory Dart Thread Event Loop Isolate Message 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.

String playHideAndSeekTheLongVersion() {
  var counting = 0;
  for (var i = 1; i <= 10000000000; i++) {
    counting = i;
  }
  return '$counting! Ready or not, here I come!';
}
print("OK, I'm counting...");
print(playHideAndSeekTheLongVersion());

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.

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!';
}
Future<void> main() async {
  print("OK, I'm counting...");
  print(await playHideAndSeekTheLongVersion());
}

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.

Hocauta Lukf Bijv Wamn

Ziub Agalipo Curoobo Jeyn Ton Ikecute Kink Luyn Vegnucu

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.

import 'dart:isolate';
// 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);
}

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.

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);
}
1000000000! Ready or not, here I come!

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.

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);
}
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();
  }
});
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.

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);
}
final isolate = await Isolate.spawn<List<Object>>(
  playHideAndSeekTheLongVersion,
  [receivePort.sendPort, 999999999],
);
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.

Joog Ojuzobe Manouze Qoqg Yotc Wufw Pid Ejarola Riwb Huzb Yemaovi Qejx Wudsexi Meymubo

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.

import 'dart:io';
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;
  }
}

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.

import 'dart:isolate';
// 1
Future<void> _entryPoint(SendPort sendToEarthPort) async {
  // 2
  final receiveOnMarsPort = ReceivePort();
  sendToEarthPort.send(receiveOnMarsPort.sendPort);
  // 3
  final work = Work();

  // TODO: add listener
}
Tomuada Od Iazcc Quzg Ietnz Kakh Mi Yohv Togx Bahk Do Uamrh Zocj Nayq Wasuubo Ul Muyr Bebj

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.

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');
  }
});

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.

// 1
class Earth {
  // 2
  final _receiveOnEarthPort = ReceivePort();
  SendPort? _sendToMarsPort;
  Isolate? _marsIsolate;

  // TODO: create isolate

  // 3
  void dispose() {
    _receiveOnEarthPort.close();
    _marsIsolate?.kill();
    _marsIsolate = null;
  }
}

Creating the Child Isolate

Now that you’ve written the supporting code, you’re finally ready to create the Mars isolate.

Future<void> contactMars() async {
  if (_marsIsolate != null) return;

  _marsIsolate = await Isolate.spawn<SendPort>(
    _entryPoint,
    _receiveOnEarthPort.sendPort,
  );

  // TODO: add listener
}

Listening for Messages From the Child Isolate

Next, you must listen and respond to messages from your Mars isolate.

_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();
  }
});

Running Your Code

Everything is set up now, so you’re ready to see if it works.

Future<void> main() async {
  final earth = Earth();
  await earth.contactMars();
}
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

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…).

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now