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:
One more example of the problem:
Let's see it in detail:
NumberHolder
class must receive the value from theasyncNumberGet()
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:
- We create a
NumbeHolder
instance - Call 1:
NumberHolder
class retrieves a value from theasyncNumberGet()
function just once and writes the value to the_result
variable. Result: 1 - Call 2: The
_result
variable is not empty, returning it. Result: 1 - 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.
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.