Futures, async and await

Article · 14:00

Dart runs your code on a single thread with an event loop: nothing you write runs in parallel with anything else you write, so you never need a lock - and anything that blocks stops everything. What await really suspends, and Future.wait with a timeout.

Futures, async and await

Dart runs your code on a single thread with an event loop. Nothing you write runs in parallel with anything else you write, which means you never need a lock

  • and it also means that any code which blocks stops everything.

A Future<T> is a value that is not here yet. It is not a thread; it is a promise that something will complete, with a value or with an error.

Future<String> fetchName() async {
  await Future<void>.delayed(const Duration(milliseconds: 50));
  return 'Ada';
}

void main() async {
  print('before');
  final name = await fetchName();
  print('got $name');
  print('after');
}

async marks a function that may suspend; await is where it suspends. The function returns a Future immediately, and the rest of it runs later, when the awaited future completes.

What await actually does to the order

Future<void> main() async {
  print('1 sync');

  Future<void>.delayed(Duration.zero, () => print('4 event queue'));
  scheduleMicrotaskLike();

  final value = await Future<int>.value(99);
  print('5 after await: $value');

  print('6 last');
}

void scheduleMicrotaskLike() {
  Future<void>.microtask(() => print('3 microtask'));
  print('2 sync inside the call');
}

Two queues decide that order. Microtasks - which is where a completed Future resumes - all run before the next event. Timers and I/O go on the event queue and wait their turn. It is why a tight loop of microtasks can starve a timer.

Errors travel through futures

An async function that throws returns a failed future. await inside a try turns it back into a normal exception, which is the whole reason await is nicer than callbacks.

Future<int> risky(int n) async {
  if (n < 0) throw ArgumentError.value(n, 'n', 'must not be negative');
  return n * 2;
}

Future<void> main() async {
  print(await risky(21));

  try {
    await risky(-1);
  } on ArgumentError catch (e) {
    print('caught: ${e.message}');
  }

  // Without await, the error has nowhere to go but the zone's error handler -
  // which in a server is the difference between a logged 500 and a silent one.
  await risky(-1).then((v) => print(v)).catchError((Object e) {
    print('handled in catchError: $e');
  });
}

Doing several things at once

Awaiting in sequence takes as long as the sum. Future.wait starts everything and takes as long as the slowest.

Future<String> load(String name, int ms) async {
  await Future<void>.delayed(Duration(milliseconds: ms));
  return name;
}

Future<void> main() async {
  final clock = Stopwatch()..start();

  // Sequential: 60 + 60 + 60.
  await load('a', 60);
  await load('b', 60);
  await load('c', 60);
  print('sequential: ${clock.elapsedMilliseconds >= 180}');

  clock.reset();

  // Concurrent: all three are already running by the time we wait.
  final results = await Future.wait([
    load('a', 60),
    load('b', 60),
    load('c', 60),
  ]);
  print('concurrent: ${results.join(",")} in about 60ms: '
      '${clock.elapsedMilliseconds < 180}');
}

Future.wait fails as soon as any of its futures fails. If you want every result regardless, wrap each one so its failure becomes a value.

Future<String> maybe(String name, {bool fail = false}) async {
  await Future<void>.delayed(const Duration(milliseconds: 10));
  if (fail) throw StateError('$name failed');
  return name;
}

Future<void> main() async {
  final outcomes = await Future.wait([
    maybe('a'),
    maybe('b', fail: true),
    maybe('c'),
  ].map((f) => f.then<String>((v) => 'ok:$v').catchError((Object e) => 'err:$e')));

  outcomes.forEach(print);
}

Timeouts, because something that never completes is the worst case

// Future and Stream are in dart:core, but TimeoutException is not.
import 'dart:async';

Future<void> main() async {
  Future<String> slow() async {
    await Future<void>.delayed(const Duration(seconds: 5));
    return 'eventually';
  }

  try {
    await slow().timeout(const Duration(milliseconds: 100));
  } on TimeoutException {
    print('gave up after 100ms');
  }

  // Or supply a value instead of throwing.
  final value = await slow()
      .timeout(const Duration(milliseconds: 100), onTimeout: () => 'default');
  print(value);
}

What to take away

  • One thread, one event loop: no locks, and nothing may block.
  • async returns a future immediately; await is where the function suspends.
  • Microtasks (resumed futures) run before events (timers, I/O).
  • An error in an async function becomes a failed future; await inside try catches it.
  • await in sequence costs the sum; Future.wait costs the slowest.
  • Future.wait fails fast - convert failures to values if you need every result.
  • Put a timeout on anything that talks to the outside world.