FLUTTER: Concurrency and Parallelism?
At present, user expectations are higher than ever. The performance and responsiveness of your mobile applications might be the only difference that makes or breaks the user experience.Being a Mobile developer, you already know how much is at stake in terms of keeping the UI smooth and interactions snappy, but when doing that, it’s easy to get caught up in the mindset of “just make it work”.
When we talk about concurrency and parallelism in Flutter, we’re not just referring to how you can manage tasks in your app. We’re talking about empowering your app to do more, to handle complex operations without compromising the user interface, and to take full advantage of the available system resources.
Concurrency vs. Parallelism (Why They Matter)
Concurrency
Concurrency is about managing multiple tasks at once, but not necessarily running them simultaneously. In essence, you juggle multiple operations by cleverly switching between them, making the app feel responsive.
To get more clear understanding, imagine you’re a single barista at a busy coffee shop. You can only make one drink at a time, but you manage multiple customers by multitasking. While you’re waiting for one coffee machine to brew, you start preparing another order. Then, while that second drink is brewing, you go back and finish the first one. You’re not making two drinks simultaneously, but you’re switching between tasks to keep the customers happy.
Concurrency in Dart is about managing multiple tasks overlappingly without simultaneous execution, primarily achieved through its event-driven, single-threaded model.
In Flutter, Dart code operates within an isolated environment, each with its own single-threaded event loop. This loop manages various events, including user interactions, I/O operations, and UI updates. Events are processed sequentially, ensuring a smooth flow of tasks. To maintain a responsive UI, Flutter aims for a 60 FPS refresh rate, adding repaint requests to the event loop every 16 milliseconds. Timely processing of these requests is crucial for preventing UI lags and ensuring a seamless user experience.
While the event loop processes tasks sequentially, synchronous operations can monopolize its time. This can lead to missed frames and a temporarily unresponsive UI. To prevent this, Flutter encourages the use of asynchronous programming techniques, such as futures and streams, to avoid blocking the main thread.
Also concurrency helps to run fast and smooth user interfaces with no UI junk.Ok! but how?
By leveraging asynchronous APIs and the await
keyword in Dart, the primary isolate's event loop can continue to process other events, such as UI updates, without being blocked by lengthy operations. This allows for a more responsive and fluid user experience.
Future<void> fetchData() async {
final response = await http.get(Uri.parse('https://api.example.com/data'));
final data = jsonDecode(response.body);
// Process the data and update the UI
setState(() {
// ...
});
}
In this example, the fetchData
function is asynchronous. When the await
keyword is used before http.get
, the function pauses execution until the HTTP request completes. Meanwhile, the event loop can continue processing other tasks, preventing the UI from freezing. Once the request finishes, the data
is processed and the UI is updated using setState
.
Future<List<User>> fetchData() async {
final response = await getUserList();
final processData = _parseData(response); // complex task method
return processData;
}
Now consider above code snippet and you can see that _parseData function has to handle large chunk of synchronous work, even though method is asynchronous, flutter UI blocks the UI from updating due to its complexity(more processing/compute). Just because a method is asynchronous , it doesn’t mean its work is done concurrently. (Async doesn’t automatically make your app multithreaded).
Flutter advises offloading the work to a separate worker isolate for more demanding tasks. This strategy prevents blocking the main isolate dedicated to UI rendering and that’s where isolates comes to play.
Parallelism
Parallelism is like having a team of workers all pitching in at the same time. It’s great for heavy-lifting tasks like crunching numbers or dealing with huge amounts of data. By splitting up the work and having everyone work together, you can get things done much faster. As an another example imagine there are two baristas at the coffee shop. One makes the first coffee while the other makes the second coffee at the same time. Each barista works independently, so the drinks are made in parallel, significantly speeding up the process.
In Dart, parallelism is implemented through isolates, akin to independent threads that can execute simultaneously, often on different CPU cores.
Flutter team suggests that offload the work to another thread by spawning a new isolate (if work blocks more than 2 frames). (eg: using build in compute function)
Future<void> loadImage() async {
final imageBytes = await loadImageBytes();
final decodedImage = await compute( // example of using compute function
decodeImage,
imageBytes,
);
}
Instead of blocking the main thread, Flutter offers a powerful tool for offloading short-lived background tasks: the Isolate.run
method. This method creates a separate isolate, essentially a lightweight worker thread, to execute a specific function. Once the function finishes its work and sends a single message back to the main isolate, the worker isolate shuts down automatically. This approach is ideal for short-lived background tasks(process a image, large json blob) that would otherwise stall the main thread responsible for smooth UI rendering, ensuring a responsive and fluid user experience.
Performance Overhead Isolates created with Isolate.run
are very convenient, as they can take an expensive computation off the main thread. However, for short-lived isolates you have to pay the overhead of spawning an isolate and transferring objects across the isolate boundaries. You will experience such overhead in particular if you spawn an isolate frequently or send large objects between isolates frequently. This is due to the fact that a new isolate requires creating an execution context and its memory space.
On the other hand, long-lived isolates, known as background workers, are used for tasks that require ongoing execution or need to communicate over time frequently. These isolates are ideal for continuous or repeated processes during an app’s life- cycle. For example, in applications needing constant complex computations like AI-model response generate, using a long-lived isolate helps offload heavy tasks. This ensures these operations run parallel to the main app, keeping the main thread free and responsive.
To establish communication between long-lived isolates in Dart, you can use ReceivePort and SendPort. SendPort acts like a StreamController, where you send messages using the send() method. ReceivePort works as the listener, triggering a callback with the message when a new one arrives.
Future? createIsolate() async {
ReceivePort receivePort = ReceivePort();
Isolate.spawn(isolateFunction, receivePort.sendPort);
SendPort childSendPort = await receivePort.first;
ReceivePort responsePort = ReceivePort();
childSendPort.send(['https://example.app/api', responsePort.sendPort]);
var response = await responsePort.first;
print(response['results'][0]['email']);
}
As Flutter continues to evolve, so too does the need for developers to understand and implement advanced concepts like concurrency and parallelism. By mastering async, await, and Isolates, you’ll be well-equipped to tackle any challenge, ensuring your apps are always one step ahead.
Refer below for more info.
Asynchronous Operations
Concurrency in Dart
If you found it valuable, hit the clap button 👏 and consider following me for more such content. See you all again soon!