Flutter is a great framework for writing cross-platform code that works across many devices and ecosystems. However, it wouldn’t be like that without Dart.
On the other hand, Dart, the language that powers up Flutter, is an object-oriented, type-safe programming language, and it supports proper null safety. These are essential features that determine the way Dart and Flutter work. But today, I want to focus on another cool feature of Dart, which is concurrency. Despite being a single-threaded language, Dart supports asynchronous programming and parallelism. But what does it all mean, and what’s the difference?
Dart as a single-threaded programming language
Within a Flutter app, all Dart code runs in an isolate, and it can be imagined as a space in a machine with its own part of memory and its own single thread of execution running a single event loop. None of each can be reached from outside. These individual memory heaps and threads are isolated from each other, hence the name of the isolate. Individual isolates can communicate with each other, but they do not share a memory! More on that later.
Most Dart apps use only one isolate, called the main isolate. That means the app can only do one thing simultaneously because it runs on one processor core. The downside of this approach is that any code can block the thread from executing if it takes a long time to complete the operation. This means that every time we send a network request or write something to the database, our application should stop for a while and stay blocked until the operation is done. But we don’t observe anything like this daily using Flutter applications. So how is it possible? To prevent such behavior, Dart supports asynchronous code execution. But to understand it correctly, first, we need to go back to the synchronous operations and event loop I mentioned before.
The example below shows synchronous code execution. In such a situation, longRunnigOperation blocks the execution of the code in the main function, which can only be executed when the countdown to 9 ends.
import 'dart:io';
void longRunningOperation() {
for (int i = 0; i < 10; i++) {
sleep(Duration(seconds: 1));
print('index equals: $i');
}
}
void main() {
longRunningOperation();
for (int i = 10; i < 20; i++) {
sleep(Duration(seconds: 1));
print('index from main function: $i');
}
print('end of main function');
}
Output:
Behavior like this can freeze the application and cause frame drops. To remedy this, we must execute the code asynchronously.
Event loop
Imagine your application’s lifetime with all events between the app start and end as a linear timeline. Note that the user significantly impacts how this timeline looks while using the application. User input, file I/O notifications, timers, and tapping on the screen are events that need to be processed. The app itself can’t predict when the user will tap on the screen when it has to send a request to a server or in what order these events will occur. However, to use the application smoothly and prevent it from freezing, the app must handle all events in a single thread that never blocks. This is where the event loop comes into play.
When a Dart program starts, one of the most important things that happen is an initialization of the event loop, which will last as long as the thread itself is alive. This mechanism is responsible for the order in which the program code is run. I imagine an event loop like a mill wheel or moving gear. It takes the oldest event in its event queue, processes it, returns for the next event, processes it, and continues until the event queue is empty. As long as the app is running, this event loop just keeps going, processing those events one at a time.
Asynchronous operations
Alright, but what if you have to wait a bit for the effect of a given operation? What does the event loop do, then? In such a case, Dart executes the code asynchronously (of course, if we, as developers, tell Dart to do so). Asynchronous means that when such an operation is initialized, it allows other operations to execute before it completes. Asynchronous code helps apps stay responsive, so we don’t need to block our app whenever we operate, such as:
- Fetching data from the web,
- Input/Output operations,
- Reading data from a file,
- Writing to the database.
To interact with these asynchronous results, we use the async and await keywords. While performing async operations, the event loop “knows” that the effect of the operation will come after some time, so it postpones its processing for later, but all the time remembers to come back to it when the operation is finished, so let’s say it “promises” to come back to it. While waiting, the event loop does not stand idle, instead starts processing the next events in the queue. In other words, the processor does not have to stop and wait until, e.g., the download is finished, it just jumps to the other part of our code.
For a while, let’s focus on this promise I mentioned. You can think of it as receiving a closed box. When we do an asynchronous operation, Dart returns a cluttered box and says, “don’t open the box yet, you’ll have to wait a while for its contents.” Only when the operation is completed do we get the box’s contents, which are either data or an error.
Calling an asynchronous method
So asynchronous operations may have two states: completed and uncompleted. When you call an asynchronous function, first, it returns an “uncompleted” future object (our closed box), for example, Future<T>. That represents a value of type T which we will receive in the future (the content of our box). That future is waiting for the function’s asynchronous operation to finish, and then it returns a value with type T or throws an error. So for example Future<String> somehow promises to produce a value of type String when completed, Future<int> will return type int in the future, and so on. When the future doesn’t produce a usable value, then its type is Future<void>.
If we want to perform both functions from the previous example to run asynchronously, we need to change the syntax slightly. Notice that in the following example both longRunningOperation and main return a Future object.
import 'dart:async';
Future<void> longRunningOperation() async {
for (int i = 0; i < 10; i++) {
await Future.delayed(Duration(seconds: 1));
print('index equals: $i');
}
}
Future main() async {
longRunningOperation();
for (int i = 10; i < 20; i++) {
await Future.delayed(Duration(seconds: 1));
print('index from main function: $i');
}
print('end of main function');
}
In this case, both methods work alternately (but not simultaneously!). The output looks like this:
If the asynchronous operation performed by the function fails for any reason, the future completes with an error.
Streams
Class Future is not the only way of receiving data from asynchronous operations. If we need to receive a sequence of events, Dart programming offers us class Stream. Stream is an asynchronous equivalent of Iterable. So it can have 0 or multiple values calculated in the future. Each event in the stream is either a data event, also known as a stream element, or an error event, which is a notification that something has failed. After the stream has emitted all its events, a single “ready” event notifies the listener that it has reached the end.
Important note: all asynchronous operations are performed single-threaded, which means the app is doing just one thing at a time! Notice that there is always only one part of the code running at any given time. For more information about Futures and Streams, check this excellent tutorial:
Isolates vs. parallelism in Dart
Asynchronous programming in Dart is extremely important and helpful, but sometimes, with particularly resource-consuming calculations, it is simply not enough, and you have to use a different approach, which is doing two things at the same time. This is called parallelism.
In some programming languages, like C++, developers can use shared-memory threads running concurrently. However, this shared-memory concurrency can easily lead to errors and complications in code. In Dart, this problem has been solved in an interesting way. Remember these isolates I mentioned earlier? Now let’s talk about them a bit more.
Taking advantage of the fact that most modern devices are equipped with processors with more than 1 core, Dart supports parallelism and multi-threaded code execution. Still, unlike C++, these threads are separated from each other inside isolates. They do not share a memory, ensuring that none of the states in an isolate is accessible from any other isolate. Below is a schematic image of an isolate containing its own event loop and with marked operations, which it can perform.
If you have a demanding task to perform, e.g., a computation that is so large and heavy that it could drop frames or freeze the app if run in the main isolate, you can always create more isolates using one of two functions:
Isolate.spawn()
or
Compute()
Both of these functions create a separate isolate with its own memory and a single thread running an event loop. This approach is called parallelism because it enables parallel code execution on multiple processor cores. Using more than one isolate, your Dart code can perform multiple independent tasks simultaneously. This is crucial for performing heavier tasks, taking the load off the main isolate. If we use more isolates and divide more difficult tasks between them, our app users will not notice any frame drops, freezing, or other blocking while using the app. And this is our goal, after all. The important thing is, individual isolates can communicate with each other, e.g., to transfer the results of computational operations. For more information, I recommend checking the official Dart documentation.
Contact
Would you like to discuss Flutter development opportunities for your application?
Dart’s features make it a powerful tool for Flutter apps
Most of the time, Dart operates on a single thread in the main isolate. Async is the ability to wait and run other parts of your code without blocking but still on a single thread.
Isolates allow you to run different parts of your program in parallel using multiple threads.
It is very important to distinguish between these two concepts because there are some similarities, and it might be confusing, especially if you are a beginner at Dart and Flutter.
To understand better concepts of asynchronous programming and parallelism in Dart and Flutter, I highly recommend getting familiar with the following tutorial: