Concurrency and Isolates in Flutter and Dart

Nima Farzin
9 min readMar 16, 2024

--

Concurrency allows a program to perform multiple tasks apparently at the same time. Dart achieves this using two main mechanisms: async APIs and isolates.

Async APIs: These are functions and objects that handle non-blocking operations. This means the program can continue executing other code while waiting for an async operation to complete. Examples include Future and Stream.

  • Future represents the eventual result of an asynchronous operation. It can either be a value or an error.
  • Stream provides a sequence of data values delivered over time.
  • The async and await keywords are used with async functions to pause execution and wait for async operations to finish.
const String filename = 'with_keys.json';

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

Isolates: These are independent units of execution that run in their own memory space. They communicate with each other by sending messages. Isolates are useful for performing CPU-bound tasks in parallel on multi-core processors.

  • Isolates are different from threads in other languages. They don’t share memory directly, which avoids concurrency issues like race conditions.
  • There are two ways to create isolates:
  • Isolate.run for executing a single computation on a separate isolate.
  • Isolate.spawn for creating a long-lived isolate that can handle multiple messages.

don’t worry we break down and explain all the things you must know :)

grab some tea or coffee and let’s go 😊

What are Isolates?

All Dart code runs in isolates, which are similar to threads but differ in that isolates have their own isolated memory. They do not share state in any way, and can only communicate by messaging. By default, Flutter apps do all of their work on a single isolate — the main isolate. In most cases, this model allows for simpler programming and is fast enough that the application’s UI doesn’t become unresponsive.

Sometimes though, applications need to perform exceptionally large computations that can cause “UI jank” (jerky motion). If your app is experiencing jank for this reason, you can move these computations to a helper isolate. This allows the underlying runtime environment to run the computation concurrently with the main UI isolate’s work and takes advantage of multi-core devices.

Each isolate has its own memory and its own event loop. The event loop processes events in the order that they’re added to an event queue. On the main isolate, these events can be anything from handling a user tapping in the UI to executing a function to painting a frame on the screen. The following figure shows an example event queue with 3 events waiting to be processed.

Imagine your app as a restaurant kitchen. The main isolate is the head chef, responsible for the overall flow and presentation of the food (UI). Isolates are like assistants in the kitchen, handling tasks that take time but don’t directly affect the presentation (user experience).

How Do Isolates Differ from Threads?

To better understand the differences between isolates and threads, let’s examine some key distinctions:

Thread vs Isolate

1. Memory Isolation: Isolates have separate memory spaces, whereas threads in a multi-threaded environment share memory. This memory isolation makes isolates less prone to data races and other concurrency-related bugs.

2. Concurrency Model: Isolates follow a message-passing concurrency model, meaning they communicate by passing messages rather than sharing variables. Threads, on the other hand, can directly access shared memory.

3. Parallel Execution: Isolates can be run in parallel on multi-core processors, taking full advantage of the available hardware resources. Threads can also run concurrently, but they may contend for shared resources.

4. Safety: Due to memory isolation, isolates are inherently safer than threads. In a multi-threaded environment, developers must be diligent in managing locks and synchronization to prevent race conditions.

Isolate Key points

  • Separate memory space.
  • Communicate through messages (not shared memory).
  • Useful for offloading heavy computations to avoid UI lags.

When to use Isolates

  • Large computations: Tasks that take longer than the time between UI frames (60 times per second).

UseCases

  • Reading data from a local database
  • Sending push notifications
  • Parsing and decoding large data files
  • Processing or compressing photos, audio files, and video files
  • Converting audio and video files
  • When you need asynchronous support while using FFI(Foreign function interface)
  • Applying filtering to complex lists or filesystems

Short-lived Isolates:

  • The easiest way to offload tasks.
  • Use Isolate.run method.
  • Spawn an isolate, perform a computation, and shut down the isolate.
  • Example: Decoding a large JSON file.
const String filename = 'with_keys.json';

void main() async {
// Read some data.
final jsonData = await Isolate.run(() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
});

// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
// Produces a list of 211,640 photo objects.
// (The JSON file is ~20MB.)
Future<List<Photo>> getPhotos() async {
final String jsonString = await rootBundle.loadString('assets/photos.json');
final List<Photo> photos = await Isolate.run<List<Photo>>(() {
final List<Object?> photoData = jsonDecode(jsonString) as List<Object?>;
return photoData.cast<Map<String, Object?>>().map(Photo.fromJson).toList();
});
return photos;
}
int slowFib(int n) => n <= 1 ? 1 : slowFib(n - 1) + slowFib(n - 2);

// Compute without blocking current isolate.
void fib40() async {
var result = await Isolate.run(() => slowFib(40));
print('Fib(40) = $result');
}

If you’re using Flutter, you can use Flutter’s compute function instead of Isolate.run().

We should use Isolate.spawn() over Isolate.run() when we need more granular control over the spawning of an isolate. For example, we might want to specify the isolate’s name, its memory usage, or its CPU affinity.

In summary, We use Isolate.run() for simpler, short-lived tasks within the current isolate.

Long-lived Isolates:

Short-lived isolates are convenient to use but require performance overhead to spawn new isolates and to copy objects from one isolate to another. If your code relies on repeatedly running the same computation using Isolate.run, you might improve performance by instead creating long-lived isolates that don’t exit immediately.

  • Useful for repeated computations or tasks that need to yield multiple results.
  • Use lower-level APIs like Isolate.spawn and Isolate.exit.
  • Communicate through ports (like walkie-talkies).
  • Example: Background music player that needs to update the UI.

Message Passing between Isolates:

Imagine two islands (isolates) in an ocean. They cannot directly access each other’s things (memory).

To communicate, they send messages through boats (ports). These messages are like photocopies (for mutable data) or the actual item itself (for unchangeable data).

Sending messages:

  • Islands send copies of messages (like photocopies) to each other. This ensures the original message on the sending island stays untouched.
  • Only unchangeable things (like simple text or pictures) are sent directly, similar to handing them over on the boat.

Receiving messages:

  • Islands receive the copies (or the actual item for unchangeable data).
  • Any changes made to the message on the receiving island do not affect the original message.

This keeps things separate and avoids confusion:

  • Islands cannot accidentally change each other’s stuff.
  • Sending islands don’t have to worry about their messages being modified.

Exceptions:

  • When an island shuts down (like a sinking boat), it can directly send the original message (not a copy) to another island. This ensures only one island has the message.

Key points:

  • Isolate.spawn() and Isolate.exit()
  • ReceivePort and SendPort
  • send() method

This messaging system is similar to actors in a play. Each actor (isolate) only interacts with others through messages, keeping things organized and preventing conflicts.

import 'dart:async';
import 'dart:convert';
import 'dart:isolate';


void main() async {
final worker = Worker();
await worker.spawn();
await worker.parseJson('{"key":"value"}');
}


class Worker {

late SendPort _sendPort;
final Completer<void> _isolateReady = Completer.sync();

Future<void> spawn() async {
final receivePort = ReceivePort();
receivePort.listen(_handleResponsesFromIsolate);
await Isolate.spawn(_startRemoteIsolate, receivePort.sendPort);
}

void _handleResponsesFromIsolate(dynamic message) {
if (message is SendPort) {
_sendPort = message;
_isolateReady.complete();
} else if (message is Map<String, dynamic>) {
print(message);
}
}

static void _startRemoteIsolate(SendPort port) {
final receivePort = ReceivePort();
port.send(receivePort.sendPort);
receivePort.listen((dynamic message) async {
if (message is String) {
final transformed = jsonDecode(message);
port.send(transformed);
}
});
}

Future<void> parseJson(String message) async {
await _isolateReady.future;
_sendPort.send(message);
}
}

The diagrams in this section are high-level and intended to convey the concept of using ports for isolates.

  1. Create a ReceivePort in the main isolate. The SendPort is created automatically as a property on the ReceivePort.
  2. Spawn the worker isolate with Isolate.spawn()
  3. Pass a reference to ReceivePort.sendPort as the first message to the worker isolate.
  4. Create another new ReceivePort in the worker isolate.
  5. Pass a reference to the worker isolates ReceivePort.sendPort as the first message back to the main isolate.

Along with creating the ports and setting up communication, you’ll also need to tell the ports what to do when they receive messages. This is done using the listen method on each respective ReceivePort.

  1. Send a message via the main isolate’s reference to the worker isolate’s SendPort.
  2. Receive and handle the message via a listener on the worker isolate’s ReceivePort. This is where the computation you want to move off the main isolate is executed.
  3. Send a return message via the worker isolate’s reference to the main isolate’s SendPort.
  4. Receive the message via a listener on the main isolate’s ReceivePort.

In summary, we use Isolate.spawn() when we need multiple isolates, or want to offload long-running tasks.

Long-Lived vs Sort-Lived:

Limitations of Isolates

  • Not exactly like threads (different memory spaces).
  • Limited access to global variables.
  • Web platforms and Flutter Web don’t support isolates (use compute method).
  • No access to UI methods or rootBundle in isolates.
  • Limited unsolicited messages from the host platform (e.g., Firestore listeners).

Additional Points

  • Using platform plugins in isolates: As of Flutter 3.7, you can use platform plugins in background isolates. This opens many possibilities to offload heavy, platform-dependent computations to an isolate that won’t block your UI. For example, imagine you’re encrypting data using a native host API (such as an Android API on Android, an iOS API on iOS, and so on). Previously, marshaling data to the host platform could waste UI thread time, and can now be done in a background isolate. Platform channel isolates use the BackgroundIsolateBinaryMessenger API. The following snippet shows an example of using the shared_preferences package in a background isolate.
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
// Identify the root isolate to pass to the background isolate.
RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
Isolate.spawn(_isolateMain, rootIsolateToken);
}
Future<void> _isolateMain(RootIsolateToken rootIsolateToken) async {
// Register the background isolate with the root isolate.
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
// You can now use the shared_preferences plugin.
SharedPreferences sharedPreferences = await SharedPreferences.getInstance();
print(sharedPreferences.getBool('isDebug'));
}

Summary Table:

See the benefits of isolates in Flutter applications by exploring a simple example in this GitHub repository: https://github.com/nimafarzin-pr/flutter-isolate

Summary

Isolates Separate units of execution with their own memory. Communication message passing (not shared memory). Use casesOffloading heavy computations, and background tasks. Short-lived isolatesUse Isolate.run for simple tasks. Long-lived isolates Use lower-level APIs for complex communication.LimitationsSeparate memory, no direct UI access, limitations on the web. By understanding these concepts, you can effectively leverage isolates to improve the performance and responsiveness of your Flutter applications.

Resources

https://www.linkedin.com/pulse/understanding-isolates-dart-comparison-threads-neha-tanwar/

You can find me on LinkedIn and GitHub

www.linkedin.com/in/nimafarzin-pr

https://github.com/nimafarzin-pr

--

--