Dart
May 5, 2021

Completer: Creating own Futures

Sometimes while writing your code, you may realize the interface the Future class provides is insufficient for your needs. This happens mostly when you create own libraries or controllers.

Future is a basic term to understand while coding in Dart. You can have a complete dive-in into asynchronous programming in a dart.dev codelab.

In this article we detect a Race Condition error in some asynchronous code and solve it using a Completer.

This article is a translation of our original article in Ukrainian

What do you mean, insufficient?

Let's see Future's interface:

Future(FutureOr<T> computation())
// Calls computation() asynchronously, using Timer.run
Future.delayed(Duration duration, [FutureOr<T> computation()])
// Just like Future(), but execution delays
Future.error(Object error, [StackTrace? stackTrace])
// Creates Future which completes with an error
Future.microtask(FutureOr<T> computation())
// Just like Future(), but uses scheduleMicrotask
Future.sync(FutureOr<T> computation())
// Just like Future(), but function gets called synchronously
Future.value([FutureOr<T>? value])
// Creates an already completed Future with a result

As we can see, the given interface gives ability to produce Future as a result of some different calculations, but there's no way to indicate computations which are still running.

Instead, the given interface can be used, for example, if we're implementing some interface which requires a future to be returned, while our implementation is synchronous:

import 'dart:io';

abstract class FileReader {
  Future<String> readFile(String name);
}

class SyncFileReader implements FileReader {
  @override
  Future<String> readFile(String name) {
    return Future(() {
      return File(name).readAsStringSync();
    });
  }
}

Now let's consider we're creating a database controller. To work with a database, you need to connect to it. Furthermore, you need to store the established connection somewhere in the controller, since creating multiple parallel connections is ineffective.

Let's try creating such a controller with what we learned:

import 'package:sqflite/sqflite.dart';

class DbConnection {
  Database? _database;

  Future<Database> use() async {
    _database ??= await openDatabase(
      'my.db',
      onCreate: (db, version) async {
        ...
      },
      version: 1,
    );

    return _database!;
  }
}

This class has a use() method, which:

  • Checks if there's an established connection stored
  • If there's no such connection, it creates it, saves it in the controller and returns it
  • If there is, it returns one

It seems like everything is all right, but you can find an issue in this implementation โ€” if multiple independent parts of our code will try to access the database at the same time before the first connection was initiated, each request will receive its own connection:

Demonstration of what happens in this example

One more example of the problem:

Let's see it in detail:

  • NumberHolder class must receive the value from the asyncNumberGet() just once, after what the value must be stored to be returned every next time
  • asyncNumberGet() increments its value after each call
  • In main():
    • Starting two requests to get the number at the same time
    • Launching single separate request to get the number

So, it seems like it has to work so:

  1. We create a NumbeHolder instance
  2. Call 1: NumberHolder class retrieves a value from the asyncNumberGet() function just once and writes the value to the _result variable. Result: 1
  3. Call 2: The _result variable is not empty, returning it. Result: 1
  4. Call 3: The _result variable is not empty, returning it. Result: 1

In conclusion, the expected result is:

[1, 1]
1

But if we launch the example, we see this instead:

[1, 2]
2

This result happens due to a semantic error called a Race Condition: a state, when the result depends on the order the code gets executed.

In the database example we'd face a resource leak due to the unused and not closed database connection.

Let's see why this happens. After checking how Future.wait works, we make a conclusion that Future.wait([task1(), task2()]) looks something like this inside:

task1();
task2();

It means both asynchronous tasks run concurrently, while not waiting for each other.

Concurrency in Dart

JavaScript developers should be already familiar with the concept of the event loop.

In short: despite the fact Dart supports true multi-threading (isolates), all the usual (synchronous and asynchronous) code gets executed in a single thread. It means all commands can run only one after other.

At the same time the queue has one special behavior: if the interpreter reaches some asynchronous command, this command moves to the end of the queue, waiting while all the other synchronous code, located after it, runs.

Asynchronous code

In our example instead of task1 we call number.use. Let's check this function again:

Future<int> use() async {
    _result ??= await asyncNumberGet();
    return _result!;
}

Let's rewrite this function simpler and without the async-await keywords to understand its logic:

Future<int> use() {
  if (_result == null) {
    return asyncNumberGet() # Future
    .then((number) {
      _result = number;
      return number;
    });
  }
  return Future.value(_result);
}

Now it's time to apply our knowledge about concurrency to illustrate the order of its execution in the way the Dart interpreter sees it:

After this, let's call our function two times in a row as Future.wait does it and sort the queue:

Since the asynchronous code was moved, asyncNumberGet() was not called yet, so _result variable value wasn't set yet. Because of this the second check _result == null evaluates to true, so the given part of code doesn't get skipped, and in conclusion we get the asyncNumberGet() to be called twice:

Finally, the asynchronous function runs synchronously in the end of the queue.

To see how it runs, we rewrite the moved code with replacing Future().then() with equal await constructions so it's easier to understand it.

As result, we have two asyncNumberGet() calls because of the fact the condition check during the second call happens sooner than the first call ends.

Completer to the rescue

So, what do we do if we need to create seamless access to some cached result?

Completer is a built-in Future controller. It can produce Futures of any kind and complete them anytime.

Completer is easy to use. A Completer instance has these methods:

complete([FutureOr<T>? value]) โ†’ void
// Completes the future with a given value
completeError(Object error, [StackTrace? stackTrace]) โ†’ void
// Completes the future with the given error

And these properties:

future โ†’ Future<T>
// Future instance we are controlling
isCompleted โ†’ bool
// Returns true is the future is already completed

And now, let's rewrite our example with a Completer:

Our output this time:

CALLED
[1, 1]
1

Conclusions

Thanks to Completer, we were able to create a controlled Future instance which we can return while concurrent calculations are happening. With that, we escaped a Race Condition error.