When to Use Dart Isolates: Limitations and Practical Examples
All examples from this article are available on our GitHub:
The concept of Isolate in Dart is implemented for concurrent processing. Concurrency means the execution of multiple tasks at overlapping time periods. Isolate is a thread with an event loop continuously processing events in its own memory space. Isolates, unlike threads, don’t share memory. Interaction between different isolates is implemented through messages.
By the way, the main thread is also an isolate, but for clarity in the text of the article two different terms are used: main thread and isolate.
The purpose of isolates in your Flutter application is to execute code in threads other than the main one. If the main thread performs complex calculations, network requests, or other time-consuming operations, rather than rendering the user interface, the time to display single frame will eventually increase.
In fact, the time available to perform any operation in the main thread is limited to ~16 ms, the time required to render one smooth frame at 60 FPS. As FPS increases, this time window decreases, at 90 FPS it is ~11 ms, at 120 FPS - ~8 ms.
This leads to a logical conclusion: if the task takes more time to complete than frame time, it makes sense to perform it in a separate isolate.
Isolate Use Limitations
Earlier it was mentioned that isolates in Flutter do not share memory. Communication between them is organized by passing messages. This leads to the following issue: limit on the size of transmitted data. It is costly and in some cases impossible to send large amount of data from the isolate to the main thread.
Comment from one of the threads on Reddit:
compute
- no frame loss occurs only if the result returned is small. For example, when compute
returned me an array of strings (after reading them from a file) that contained hundreds of thousands of elements, marshalling, passing and deserializing this collection on the main thread could lead to freezes of hundreds of milliseconds.Therefore, in practice, we have to be guided by the following rule:
compute
shows good results if it performs a large amount of calculations and returns a small amount of data.It is also important to understand that the transmitted data is essentially copied. Sending data to an isolate, at the moment it is simultaneously in the memory of the isolate and in the memory of the main thread. This can lead to an out of memory (Out-Of-Memory Risk).
A crude example: if we want to pass 2 GB of data, on iPhone X (3 GB of RAM) we cannot complete the message transfer operation.
Thus, it is best to use isolates for resource-intensive operations that return small amount of data.
Isolate Examples
Further in the article, we present some examples of the use of isolates. To analyze the effectiveness of isolates, experiments were carried out, the results of which are shown in the tables.
Downloading data
The first example is downloading a large amount of data from the network. As an experiment, 5 requests to the server were simultaneously executed, with a repetition of 10 times. One request returned a JSON - array of 1.12Mb in size, consisting of 2.000 objects. The data was loaded in three ways: in the main thread, in the compute
function, and in a separate isolate created using a low-level API.
compute
makes it easy to run a function in a separate isolate. It takes a function as an argument and automatically creates an isolate to run that function in parallel with the UI. The main difference between Flutter Isolate and compute
is the level of control and complexity they provide to the developer.The following is a sample code for loading data on the main thread:
Future<void> loadDataFromMainThread() async {
List<Map<String, dynamic>> result = [];
for (int i = 0; i < 10; i++) {
result.add(await fetchData(requestCount: 5));
}
}
Loading data using the compute
function in two ways (separately and all 10 requests in one compute
):
Future<void> loadDataFromCompute() async {
List<Map<String, dynamic>> result = [];
for (int i = 0; i < 10; i++) {
result.add(await compute(fetchData, 5));
}
// alternative way
result = await compute((int count) async {
List<Map<String, dynamic>> list = [];
for (int i = 0; i < 10; i++) {
list.add(await fetchData(count));
}
return list;
}, 5,);
}
The following example loads data using Dart's isolates API. The main thread listens for messages from the isolate and collects the responses.
Future<void> loadDataFromIsolate() async {
List<Map<String, dynamic>> result = [];
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(fetchDataIsolate, receivePort.sendPort);
await for (var response in receivePort) {
if (response == null) {
break; // Exit the loop on completion signal
}
if (response is Map<String, dynamic>) {
result.add(response);
}
}
print('Isolate execution completed');
}
void fetchDataIsolate(SendPort sendPort) async {
for (int i = 0; i < 10; i++) {
sendPort.send(await fetchData(requestCount: 5));
}
sendPort.send(null); // Signal completion
}
The experimental results are presented in the table below:
Average FRT, ms | Average FPS | Max FRT, ms | Min FPS | Average time of requests, ms | |
---|---|---|---|---|---|
Requests are sent from the main thread | 14,036 | 71,25 | 100,332 | 9,97 | 226,894 |
Each of the 10 iterations was performed in its own separate compute() | 11,254 | 88,86 | 22,304 | 44,84 | 386,253 |
All 10 iterations of 5 queries were performed in one compute() | 11,252 | 88,87 | 22,306 | 44,83 | 231,747 |
All work is done in Isolate | 11,151 | 89,68 | 11,152 | 89,67 | 218,731 |
Based on these results, the following conclusions can be drawn:
- The performance of your application is heavily impacted when a significant volume of resource-intensive network requests is executed within the main thread;
compute
avoids severe frame drops compared to working in the main thread;- Overhead when using
compute
compared to Isolate in terms of running time ~ 150-160 ms; - Using Isolates completely solves the problem of frame drawdown.
Search in uploaded data
The following experiment was carried out with the operation of searching the entered substring in the data array obtained in the previous example. Three characters were entered sequentially in three lines, and a search was carried out according to the primitive principle of the presence of an entered substring in an array element. The data array consisted of 20.000 elements.
Asynchronous search functions do not run until the previous search has completed. It works like this - by entering the first character in the input field, the search is started, until it is completed, i.e. the data returns to the main thread, the UI is not redrawn and the second character in the input field, respectively, is not entered. When all actions are completed, the second character is entered and so on.
Main function code is shown below. searchFunction is a change handler for the search text field. emulateTextInput is a function for simulating character input into a text field.
void main() async {
inputController.addListener(searchFromMainThread);
await emulateTextInput();
inputController.removeListener(searchFromMainThread);
}
Example code for local search for matches in the list of received data in the main thread:
void searchFromMainThread() {
searching = true;
final input = inputController.text;
if (input.isNotEmpty && list.isNotEmpty) {
filteredList.clear();
filteredList.addAll(list.where((Map<String, dynamic> map) {
return map.values.where((e) => e.value.contains(input)).isNotEmpty;
}).toList());
}
searching = false;
}
compute
change handler code:
Future<void> searchFromCompute() async {
searching = true;
final input = inputController.text;
if (input.isNotEmpty && list.isNotEmpty) {
await compute(() {
filteredList.clear();
filteredList.addAll(list.where((Map<String, dynamic> map) {
return map.values.where((e) => e.value.contains(input)).isNotEmpty;
}).toList());
}, null);
}
searching = false;
}
Example of performing data list filtering using a single isolate
Future<void> searchFromIsolate() async {
searching = true;
final ReceivePort receivePort = ReceivePort();
final isolate = await Isolate.spawn(filterDataIsolate, receivePort.sendPort);
await for (var response in receivePort) {
if (response == null) {
break;
}
if (response is List<Map<String, dynamic>>) {
filteredList = response;
}
}
searching = false;
}
void filterDataIsolate(SendPort sendPort) {
final input = inputController.text;
final filteredData = list.where((Map<String, dynamic> map) {
return map.values.any((e) => e.toString().contains(input));
}).toList();
sendPort.send(filteredData);
sendPort.send(null);
}
The results of the second experiment are presented in the table below:
Average FRT, ms | Average FPS | Max FRT, ms | Min FPS | |
---|---|---|---|---|
Calculations in the main thread | 21.588 | 46.32 | 668,986 | 1.50 |
Calculations with compute() | 12.682 | 78.85 | 111.544 | 8.97 |
Calculations in a separate Isolate | 11.354 | 88.08 | 33.455 | 29.89 |
From this table and the previous experiment, it follows that:
- To ensure a minimum of 60 frames per second, operations that take longer than 16 ms should not be performed in the main thread;
- Although
compute
can be used for frequent and resource-intensive operations, it introduces additional time overhead compared to Isolates and exhibits less stable performance compared to a persistent Isolate.
Other candidates for work in Isolate
- JSON decoding, may take quite a while;
- Encryption: can be a very power hungry operation;
- Image processing (cropping, etc.) definitely takes a long time;
- Working with heavy objects (example in the comment from Reddit below).
Once I needed to parallelize interaction with large data objects that were heavy to create and relatively fast to run, and the only way to get rid of frame loss when transferring such objects to the main thread (after they were initialized in the Isolate) was to create a pool of isolates, create/store objects there and pass their API to the main thread via RPC (based on message passing between isolates).
Conclusion
Isolates play a crucial role in achieving concurrent processing. Performing resource-intensive operations in the main thread can have a negative impact on the application's performance. As the time available for each frame decreases with higher frame rates, tasks that exceed the available time should be executed in separate isolates to maintain smooth rendering.
Although isolates come with some limitations on the size of transmitted data, utilizing them appropriately can significantly enhance the performance and efficiency of Flutter applications.
All examples from this article are available on our GitHub: