Concurrency is a fundamental concept in computer programming that lets us build highly performant applications. It can also be quite tricky to implement, as well as a headache to manage. Thankfully, many languages and frameworks – especially more modern ones – have mechanisms in place that abstract away a lot of the heavy lifting of primitive concurrency principles like thread management. Flutter is one of these. Read on to learn more about Flutter’s system for handling concurrency, plus some insight into performance considerations that can crop up when implementing these mechanisms.
Concurrency vs. parallelism vs. asynchronous programming
Before we dive into Flutter, it’s important to review some key concepts and how they differ in practice. Concurrency, parallelism, and asynchronous programming are related, yet distinct, ideas.
Concurrency refers to the ability of multiple different tasks to execute, from start to finish, within the same set time period. This can be via true parallelism, which is possible only with a multi-processor device, and in which numerous operations run simultaneously on different processors.
Without utilizing more than one core, you can still achieve concurrency via asynchronous programming techniques. With asynchronous programming, a single processor rapidly switches between different tasks that are part of a program, often running on multiple different threads.
Asynchronous programming is incredibly useful, and indeed essential, to maintain app performance because it allows for longer-running tasks to start without forcing the rest of the program, user interface, etc. to freeze up until they’re finished. Making calls to APIs, fetching data, converting data to JSON, and many standard I/O operations are typical use cases for asynchronous functions.
How Flutter and Dart handle concurrency
Understanding Flutter’s underlying thread infrastructure is important when thinking about concurrency in the framework. While Flutter maintains a set pool of threads at the VM level to interact with the operating system and perform I/O tasks, the threads are not directly exposed to the developer. Instead, when building in Flutter or with Dart, you work with isolates, a structure almost identical to threads for practical purposes, but existing in a higher level of abstraction.
Isolates are designed specifically to avoid some of the messy situations that happen when different threads try to access the same location in memory, like race conditions. A few things to note about isolates in Dart:
- Each has its own memory heap
- Each has its own event queue and event loop
- Cannot share mutable values with other isolates
- Communicate with each other via messaging passing
- Data between isolates is duplicated
The main Dart program runs on a single main isolate, so you may not even end up manipulating isolates directly. This is, however, one way to approach concurrency in Dart if you need to do very heavy computations and want to use multiple threads, whether working on one or multiple cores. Lighter concurrency requirements can be handled with async techniques, explained in detail below.
Isolates can be created in the following ways:
- Isolate.spawn( ): This call requires opening a port for the main isolate to listen to the response of the newly created isolate. You can reuse isolates by creating two ports, one to send and one to receive.
- Compute( ): This function is easier as it abstracts some of the implementations above (like port creation), but removes some of the flexibility you get by working one layer down.
Future and Stream APIs
In most cases, you might be fine letting the Dart VM handle the isolates and use the handy asynchronous techniques that are available to you with Flutter. Two of these are Futures and Streams.
A Stream is very similar to a Future in that it’s an object that returns data in the future. Rather than returning a single value, like a string, a Stream returns a sequence of data, like a list or an array. A Stream can have multiple “listeners” which receive the same data, and we can create a Stream with the StreamController class, which works similarly to a List.
Creating Streams from scratch involves using the asynchronous generator (async*) function. Here is a very simple example of what that might look like:
Async and Await
The async and await keywords in Dart further simplify the asynchrony provided by Futures and Streams.
A couple of things to note about async/await: these keywords must be used in functions together, and an async operation runs on the same isolate that began it – it does not create a new isolate.
Potential Performance Pitfalls
As we’ve touched on, Flutter and its underlying language, Dart, provide numerous ways to deal with concurrency and create asynchronous functions. Concurrency helps greatly in optimizing computing power and ensuring a smooth user experience, but there are some things to consider when using these various mechanisms so as to not actually hinder performance. A few of these include:
- Large overhead when working with isolates: If you’re manipulating isolates in Flutter to take a multi-threaded approach, remember that isolates do not share memory and all the data between them is actually duplicated. This can create a serious overhead when dealing with large amounts of data and puts a heavier burden on memory. Flutter offers the TransferableTypedData, a byte wrapper that can be moved between isolates, so sending it through a send port will only take constant time. This helps mitigate the overhead problem.
- Moving long-running operations to worker isolates: You might choose to use async/await to achieve concurrency in parts of Flutter code rather than dealing with isolates at all. However, note that async functions by default run on the same isolate that called them – usually the main Dart isolate where everything else is also running. In this case, time-intensive operations might be causing the rest of your code to run too slowly for users’ expectations, and spinning up isolates to handle that work separately is actually better for performance.
- Running asynchronous code inside the build() function: In Flutter, the build() method is synchronous and returns a Widget. The build() method also fires many, many times – potentially every frame – as it repaints the screen while a user scrolls. This can create havoc for async functions within a build() method, which are subsequently also firing many times unnecessarily and eating up CPU. It’s generally not advised to run async code inside any method that returns a widget.
While by no means exhaustive, these examples point to the fact that even methods to increase performance must be used carefully lest they have the opposite effect. To learn more about Flutter performance best practices, check out some of our other blogs on the topic.
Embrace can help you diagnose and solve most performance issues with our new Flutter SDK, as it surfaces both Dart-layer and native-layer errors. Get started for free here.