Asynchrony Primer for Dart and Flutter

Dart is a single-threaded language. This trait is often misunderstood. Many programmers believe this means Dart can't run code in parallel, but that's not the case. So how does Dart manage and execute operations?

The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.

Isolates

When you start a Dart application (with or without Flutter), the Dart runtime launches a new thread process for it. Threads are modeled as isolates, so called because the runtime keeps every isolate it's managing completely isolated from the others. Each has its own memory space, which prevents the need for memory locking to avoid race conditions, and each has its own event queues and operations. For many apps, this main isolate is all a coder needs to be concerned about, but it is possible to spawn new isolates to run long or laborious computations without blocking the program's main isolate.

Isolates can communicate with each other only through a simple messaging protocol. They cannot access each other's memory directly. Code execution within an individual isolate is single-threaded, meaning that only one operation executes at a time. This is where asynchronous programming patterns come in. You use them to avoid locking up the isolate while waiting for lengthy operations to complete, such as network access.

Asynchrony, microtasks, and the event loop

A lot of your Dart code runs within your app's isolate synchronously. Since an individual isolate is single-threaded, only one operation can be executed at a time, so when performing lengthy tasks, it's possible to block the thread. When the thread is kept busy in this way, there's no time for responding to user interaction events or updating the screen. This can make your app feel unresponsive or slow to your users, and frustrated users give up on apps quickly.

Here is an example of a synchronous Dart function:

void syncFunc() {
  var count = 0;
  
  for (int i = 0; i < 1000000; i++) {
    count++;
  }
}

On modern computing devices, even this loop that counts to a million will execute fairly quickly, but while it's happening, no other code within your Dart isolate can execute. The thread is said to be blocked; it's doing something, but the focus is entirely on that one thing until it's done. If the user taps a button while this function is running, they'll get no response until syncFunc() exits.

So how does Dart address this limitation? To answer this question, we first need to understand the major components of a Dart isolate.

What's in a thread?

When a Dart (or Flutter) app is executed, the Dart runtime creates an isolated thread process for it. For that thread, two queues are initialized, one for microtasks and one for events, and both are FIFO (first-in, first-out) queues. With those in place, the app's main() function is executed. Once that code finishes executing, the event loop is launched. For the life of the process, microtasks and events will enter their respective queues and are each handled in their turn by the event loop. The event loop is like an infinite loop in which Dart repeatedly checks for microtasks and events to handle while other code isn't being run.

The big picture is illustrated in the following diagram:

Your app spends most of its time in this event loop, running code for microtasks and events. When nothing urgent needs attention, things like the garbage collector for freeing up unused memory may be triggered. But what do these microtasks and events look like?

Microtasks

Microtasks are intended to be very short code tasks that need to be executed asynchronously, but that should be completed before returning control to the event loop. They have a higher priority than events, and so are always handled before the event queue is checked. It's relatively rare for a typical Flutter or Dart app to add code to the microtask queue, but doing so would look something like this:

void updateState() {
  myState = "New State";
  
  scheduleMicrotask(() {
    rebuild(myState);
  });
}

You pass scheduleMicrotask() a function to be run. In the example, we've passed an anonymous function with just one line of code, which calls the fictional rebuild() function. The anonymous callback will be executed after any other waiting microtasks have completed, but also after updateState() has returned, because the execution is asynchronous.

It's very important to keep microtask callbacks short and quick. Since the microtask queue has a higher priority than the event queue, lengthy processes executing as microtasks will keep standard events from being processed, which may result in an unresponsive application until processing completes.

Microtasks won't be a concern for most apps, but your apps will constantly interact with the event queue.

Events

Once there are no more microtasks waiting for attention, any events sitting in the event queue are handled. Between the times your app starts and ends, many events will be created and executed.

Some examples of events are:

  • User input (taps, clicks, keypresses, etc.): When users interact with your app, events are placed in the event queue, and the app is able to respond appropriately.
  • I/O with a local storage device or network: Getting or setting data over connections with latency are handled as asynchronous events.
  • Timers: Through events, code can be executed at a specific point in the future or even periodically.
  • Futures: When a future completes, an event is inserted into the event queue for later processing.
  • Streams: As data is added to a stream, listeners are notified via events.

When buttons get tapped by users or network responses arrive, code to be executed in response is entered into the event queue and run when it reaches the front of the queue. The same is true for futures that get completed or streams that acquire new values to disseminate. With this asynchronous model, a Dart program is able to handle events that occur unpredictably while keeping the UI responsive to input from users.

Conclusion

Now that you understand the basics of Dart's single-threaded isolates, and how microtasks and events enable asynchronous processing, you're ready to look at the ways the Flutter framework and Dart's core libraries use the language's asynchronous programming features: