Understanding Threads and Isolates in Flutter

Mohamed Abdo Elnashar
Flutter UAE
Published in
11 min readMar 31, 2023

Concurrency and parallelism are essential for building responsive and performant Flutter applications. In this article, we’ll take a closer look at threads and isolates, two key concepts for enabling concurrency and parallelism in Flutter.

Understanding Threads

A thread is a sequence of instructions executed concurrently with other threads. Threads allow an application to perform multiple tasks simultaneously, which can be particularly useful for time-consuming operations such as network requests or file input/output.

In Flutter, the main thread is the UI thread, responsible for rendering the user interface and processing user input. However, performing long-running operations on the UI thread can cause the application to become unresponsive, leading to a poor user experience. To avoid this, developers can use background threads to perform such operations.

Flutter provides two types of threads: the UI thread and the background thread. The UI thread is responsible for rendering the user interface and processing user input. In contrast, the background thread is used for time-consuming operations such as network requests or file input/output.

Understanding Isolates

An isolate is a standalone instance of the Dart runtime that can run in parallel with other isolates. Each isolate has its own memory heap, which means that isolates do not share the memory with each other. This allows isolates to perform operations in parallel without worrying about concurrency issues such as race conditions.

In Flutter, isolates are used to perform background tasks that require a high degree of parallelism, such as image processing or audio decoding. Isolates are created using the Isolate.spawn() function, which creates a new isolate and passes it a callback function to execute.

Isolates in Dart have a few important characteristics:

  • They are similar to threads, in that they represent a separate flow of execution within the application.
  • Isolate memory is not shared between isolates, which means that each isolate has its own heap and call stack.
  • Isolates communicate with each other using ports and messages. Each isolate has its own set of ports, which can be used to send and receive messages to and from other isolates.
  • Isolates can be used to take advantage of multiple processor cores, if available. Because each isolate runs in its own memory space, multiple isolates can execute in parallel without interfering with each other.
  • Isolates can be used to perform work in parallel, which can improve the performance of the application by reducing the amount of time spent waiting for blocking operations to complete.

In Flutter there are four runners that handle different aspects of the application:

  1. UI Runner: The UI Runner handles the rendering and layout of the application’s user interface. It runs on the main UI thread and is responsible for updating the UI in response to user input or data changes.
  2. GPU Runner: The GPU Runner handles graphics rendering using the device’s GPU. It runs on a separate thread and communicates with the UI Runner to update the UI with new graphics.
  3. IO Runner: The IO Runner is responsible for handling input and output operations, such as network requests or file I/O. It also runs on a separate thread and communicates with the UI Runner to update the UI with new data.
  4. Platform Runner: The Platform Runner is responsible for handling platform-specific functionality, such as accessing native device features or integrating with other native applications. It runs on a separate thread and communicates with the other runners as needed to perform platform-specific tasks.

It’s worth noting that although each runner is responsible for different aspects of the application, they all share the same underlying platform runner. This allows them to communicate and coordinate with each other as needed to provide a smooth and responsive user experience.

In Flutter applications, there are often multiple sources of work that need to be processed, such as user input, timers, and network requests. These tasks cannot all be executed in a single step-by-step line of code, so Flutter uses an event loop to manage the processing of these tasks.

The event loop in Flutter works by maintaining a queue of events that need to be processed. These events can come from a variety of sources, such as user input or timer callbacks. When an event is received, it is added to the end of the event queue.

The event loop then begins processing events from the front of the queue, one at a time. When an event is processed, it is removed from the front of the queue, and any associated work is executed.

In addition to the event queue, Flutter also uses a microtask queue to manage the execution of asynchronous code. Microtasks are small units of work that need to be executed asynchronously but are not triggered by an external event. Instead, they are typically used for processing work that needs to be executed as soon as possible, such as updating the user interface after some state has changed.

When a microtask is added to the microtask queue, it is executed immediately after the current event has been processed, but before any new events are processed. This means that microtasks have higher priority than regular events, and can be used to ensure that important work is executed as soon as possible.

Implementation

Isolate

you can create isolated processes using the Isolate class. An isolate is a separate instance of the Dart virtual machine that runs concurrently with the main isolate. Each isolate has its own memory heap and runs independently of the other isolates. This can be useful for running long-running or computationally intensive tasks without blocking the main UI thread.

Here is an example of how to create an isolate in Flutter:

import 'dart:isolate';

void runIsolated(SendPort sendPort) {
// Do some computation or other long-running task here
for (int i = 0; i < 10; i++) {
print(i);
}
sendPort.send('Task completed');
}


Future<void> runIsolate() async {
ReceivePort receivePort = ReceivePort();
await Isolate.spawn(runIsolated, receivePort.sendPort);
receivePort.listen((message) {
print('Received message: $message');
setState(() {
text = message;
});
});
}

In this example, we first create a ReceivePort, which allows us to listen for messages sent from the isolate. We then create a new isolate using the Isolate.spawn() method, passing in the function runIsolated as the entry point for the isolate. The sendPort parameter of runIsolated is a SendPort the object that allows us to send messages back to the main isolate.

In the runIsolated function, we perform our long-running task and then send a message back to the main isolate using the sendPort object.

Finally, we listen for messages sent from the isolate using the receivePort.listen() method, and print out any messages received.

Note that you can also pass arguments to the Isolate.spawn() method if you need to initialize the isolate with some data. You can also create multiple isolates to run different tasks concurrently.

Compute()

you can also use the compute() function to run computationally intensive tasks in a separate isolate. This function is a simpler way to run isolates compared to using the Isolate class directly, as it handles the creation of the isolate and passing of arguments for you.

Here is an example of how to use the compute() function:

String reverseString(String input) {
return input.split('').reversed.join();
}

Future<void> runCompute() async {
String originalString = 'Hello world';
String reversedString = await compute(reverseString, originalString);
print(reversedString);
setState(() {
text = reversedString;
});
}

In this example, we define a function reverseString that takes a string as input and returns the string in reverse order.

We then call the compute() function, passing in the reverseString function as the first argument and the original string as the second argument. The compute() function will automatically create an isolate and run the reverseString function in that isolate with the provided arguments.

The result of the computation is returned as a Future so we use the await keyword to wait for the result to be available. We then print out the reversed string.

can communicate between two isolated threads using asynchronous message passing.

What are ReceivePort and SendPort?

ReceivePort and SendPort are two classes that are used for inter-isolate communication in Flutter.

  • A SendPort is used to send messages from one isolate to another.
  • A ReceivePort is used to receive messages from other isolates.

SendPort and ReceivePort work together as a pair. A SendPort is used to send messages to a specific ReceivePort and a ReceivePort is used to receive messages from a specific SendPort.

Using ReceivePort and SendPort

To use ReceivePort and SendPort, you need to create instances of both classes in your isolates.

Creating a ReceivePort

To create a ReceivePort, you simply need to create a new instance of the ReceivePort class. Here's an example:

ReceivePort receivePort = ReceivePort();

In this example, receivePort is an instance of ReceivePort that you can use to receive messages from other isolates.

Sending Messages

To send a message from one isolate to another, you need to get a SendPort for the receiving isolate and use it to send the message. Here's an example:

// get a SendPort for the receiving isolate
sendPort.send("Hello, world!");

In this example, we’re sending a string message to the receiving isolate.

Receiving Messages

To receive messages from other isolates, you need to listen to the ReceivePort. Here's an example:

ReceivePort receivePort = ReceivePort();
receivePort.listen((message) {
print("Received message: $message");
});

In this example, we’re listening to receivePort and printing any messages that we receive.

Closing a ReceivePort

Once you’re done using a ReceivePort, you should close it to free up any resources that it's using. Here's an example:

receivePort.close();

So that, ReceivePort and SendPort are powerful tools for inter-isolate communication in Flutter. With these classes, you can easily send and receive messages between isolates in your app. Just remember to close your ReceivePorts when you're done using them to avoid leaking resources.

example

Here’s an example of how you can use ReceivePort and SendPort to send and receive messages between two isolates in Flutter:

import 'dart:isolate';
class Message {
final String content;
Message(this.content);
}


void runIsolateFunctionCommunication() async {
ReceivePort receivePort = ReceivePort();
await Isolate.spawn(isolateCommunicationFunction, receivePort.sendPort);

receivePort.listen((message) {
if (message is SendPort) {
SendPort sendPort = message;
sendPort.send(Message("Hello from the main isolate!"));
} else if (message is Message) {
print("Received message: ${message.content}");
}
});
}


void isolateFunctionCommunication(SendPort sendPort) {
// Create a receive port to listen for incoming messages
ReceivePort receivePort = ReceivePort();

// Send the send port of the receive port to the main isolate
sendPort.send(receivePort.sendPort);

// Wait for a message from the main isolate
receivePort.listen((message) {
print("Received message from main isolate: $message");

// Send a message back to the main isolate
sendPort.send("Hello from isolate!");
});
}

In this example, we have two functions: runIsolateFuntionCommunication() and isolateFuntionCommunication()sets up a ReceivePort and spawns an isolate using Isolate.spawn(). The SendPort for the new isolate to be sent to isolateFuntionCommunication() through the ReceivePort.

The runIsolateFuntionCommunication() the function then listens for messages on the ReceivePort. When it receives SendPort the new isolate, it sends an Message object to it. When it receives an Message object from the isolate, it prints the content of the message.

The isolateFuntionCommunication() sets up its own ReceivePort and listens for messages. When it receives an Message object, it prints the content of the message and sends a new Message object back to the main isolate using the SendPort.

Note that isolates are independent of each other and run in separate memory spaces. Any data that you want to pass between isolates need to be serialized and deserialized. In this example, we’re using a simple Message class to pass data between the two isolates.

Stopping Thread

how to stop isolating?

In Dart, you cannot directly kill a thread, but you can request that a running isolate (which is the Dart equivalent of a thread) be stopped by using Isolate.kill().

Here’s an example of how to use Isolate.kill() to stop a running isolate:

import 'dart:isolate';
void runInfiniteInIsolate(int seconds) {
print("Isolate running");

// Do some work here...

Timer.periodic(Duration(seconds: seconds), (timer) {
print(timer.tick);
});
}
stopIsolate() async {
Isolate isolate = await Isolate.spawn(runInfiniteInIsolate, 1);
// Wait for 5 seconds and then kill the isolate
await Future.delayed(const Duration(seconds: 3));
isolate.kill(priority: Isolate.immediate);
print("Isolate stopped");
}

In this example, Isolate.spawn() is used to create a new isolate that runs the stopIsolate() function. The runInfiniteInIsolate() function waits for 5 seconds and then kills the isolate using isolate.kill(). The priority the parameter determines the priority of the kill request. In this example, we're using, Isolate.immediate which means that the isolate will be stopped as soon as possible.

Note that Isolate.kill() only requests that the isolate be stopped. It does not guarantee that the isolate will be stopped immediately or at all. The isolate may continue to run until it completes its current work. Therefore, it's important to design your isolate so that it can be safely stopped at any point in its execution.

How to stop compute()?

It is not possible to kill a compute function.

Why

The reason for this is that looking at the source code of the compute function, the created isolate is only killed after the result completer has finished, The result is only completed if there is either an error or the function you pass to compute returns.

Solution

If you want to be able to kill the isolate you launch, do not use compute. Instead, you will have to create them Isolate yourself.

In this tutorial, we’ve learned that threads and isolates are powerful tools that allow Flutter developers to manage the concurrency of their applications. By using threads and isolates, developers can create high-performance applications that can perform multiple tasks at the same time without sacrificing the user experience.

I hope you all liked this blog and it helped you start with Flutter! Don’t forget to smash that clap button and leave a comment down below.

If you liked this article make sure to 👏 it below, and connect with me on Portfolio, Github, and LinkedIn.

Meet you at the next one.

--

--

Mohamed Abdo Elnashar
Flutter UAE

Senior Flutter Developer, and I study a master of computer science in the faculty of computer & information sciences at Mansoura university