Contents

Futures, async, await in Dart

Notes about asynchronous programing in Dart. In this article I test and learn about two variants of writing asynchronous code in Dart: async/await and Future-based.

Goal

As I learn Dart, I want to reinforce - or rather feel - how asynchronous programming look like. I already have some basic experience with JavaScript and I think Dart Futures are similar.

My goal in this article is to go through a very simple Dart code examples and learn about Futures, making the code a bit more complex bit by bit. I thought it would be funny to try and compare two ways (or flavours) of writing asynchronous code.

Create a future

Let’s create a simple compute function that mimics heavy calculation. After two secons returned future would be completed with value 1.

1
2
3
4
5
6
7
Future<int> compute({bool throwing: false}) async {
  if (throwing) {
    throw MyError(message: 'calculation exception');
  } else {
    return Future.delayed(Duration(seconds: 2), () => 1);
  }
}

Single optional parameter (false by default) allows to force the function into throwing an exception MyError. Here is the definition of MyError:

1
2
3
4
5
6
class MyError implements Exception {
  final String message;
  final Exception? cause;
  MyError({this.message = "Default error", this.cause = null});
  String toString() => "MyError: ${message}";
}

The throwing parameter would let me test the behavior of the returned future later. I can call compute(..) in two ways: forcing it to throw or not to force:

Complete future with value

Letthe future complete with value. main would call compute() and print the result:

1
2
3
4
5
void main(List<String> args) {
  compute().then((v) {
    print(v);
  });
}

or

1
2
3
void main(List<String> args) async {
  print(await compute());
}

Both variants do the same (print “1” after 2 seconds).

I present here both code styles which can be used to express asynchrony in Dart code. Both exist e.g. in JavaScript. If you were ever writing asynchronous code in JS then Dart will feel like home.

  • first style is “callback” style in which resulting Future allows to call its method like: then, onError, timeout or catchError. Those methods take other functions that can handle different scenarios of possible future completion (by error or by value).
  • second style is called “async/await”; function that is using await must be declared as being async in method signature.

Complete future with exception

main would call compute({throwing: true}) and print result like this:

1
2
3
4
5
void main(List<String> args) {
  compute(throwing: true).then((v){
    print(v);
  });
}

or like this:

1
2
3
void main(List<String> args) async {
  print(await compute(throwing: true));
}

Both variants produce:

1
2
3
4
5
6
Unhandled exception:
My error: calculation exception
#0      compute (file:///home/karma/dev/dart/asyncerr/main.dart:3:5)
#1      main (file:///home/karma/dev/dart/asyncerr/main.dart:36:15)
#2      _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:295:32)
#3      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:192:12)

Now, based on the two ways of calling asynchronous code, let’s see what we can do with exceptional situations.

Exceptions

Let’s assume our computation throws exception. What can we do about it? Let’s handle it by printing the exception to stdout. This behavior of handling an error would be implemented in a separate function and would simply print the exception:

1
2
3
void _handleError(Exception e) {
  print("_handleError: $e");
}

I can use it in try-on-catch block in async main:

1
2
3
4
5
6
7
8
void main(List<String> args) async {
  try {
    int a = await compute(throwing: true);
    print(a);
  } on Exception catch (e) {
    _handleError(e);
  };
}

which will result in:

1
_handleError: My error: calculation exception

Note: this code will only catch subclasses of Exception. To handle other types, you need to catch other possible things that are thrown (everything can be thrown: integers, arbitrary objects….) (credits: SO):

1
2
3
4
5
6
7
try {
  ...
} on Exception catch (exception) {
  ... // only executed if error is of type Exception
} catch (error) {
  ... // executed for errors of all types other than Exception
}

Other variant of the code, using Future’s onError, requires some special attention, because this code simply does not compile:

1
2
3
void main(List<String> args) {
  compute(throwing: true).onError(_handleError)
}

The argument type ‘void Function(Exception)’ can’t be assigned to the parameter type ‘FutureOr Function(Object, StackTrace)’.dartargument_type_not_assignable

so I need to:

  1. write a test that would filter out thrown values and only trigger handler if the test passes (here I test that thrown value is of type MyError) and
  2. write the handler; however, the handler is expected to actually return a FutureOr (which is a special type denoting that I can return int and Future<int> as well).

In the handler I’m returning 213, and I’m not sure how I could use this value…

Here is what I came to:

1
2
3
4
5
void main(List<String> args) {
  compute(throwing: true).onError(
    (error, stackTrace){_handleError(error as MyError) ; return 213; },
    test: (e)=>e is MyError);
}

Oh. I forgot about the code in .then(....) block! Let’s add it:

1
2
3
4
5
6
7
8
9
void main(List<String> args) {
  compute(throwing: true)
  .then((v) {
    print(v);
  }).onError((error, stackTrace) {
    _handleError(error as MyError);
    return 213;
  }, test: (e) => e is MyError);
}

Now Dart recognized that a future in then does not return a Future with int value; it just returns void. Providing an int value in onError is compilation error. Is this really what happens? What if I’m wrong?

Let’s pretend out business case is to take computed value and then classify the value as being small or big. We have a classification function Future<Size> classify(int):

1
2
3
4
5
enum Size {small, big, unspecified}

Future<Size> classify(int number) async {
  return number > 100 ?  Size.big : Size.small;
}

Using this function on then(...):

1
2
3
4
5
6
7
void mainf(List<String> args) {
  compute(throwing: true)
  .then(classify)
  .onError((error, stackTrace) {
    _handleError(error as MyError);
  }, test: (e) => e is MyError);
}

raises compilation error. From vscode:

1
2
The body might complete normally, causing 'null' to be returned, but the return type, 'FutureOr<Size>', is a potentially non-nullable type.
 Try adding either a return or a throw statement at the end.

Or from dart:

1
2
3
4
Error: A non-null value must be returned since the return type 'FutureOr<Size>' doesn't allow null.
 - 'Size' is from 'main.dart'.
  .onError((error, stackTrace) {
           ^

Fix it: provide correct the completion value

Aha! Let’s fix that by providing Size.unspecified:

1
2
3
4
5
6
7
8
void main(List<String> args) {
  compute(throwing: true)
  .then(classify)
  .onError((error, stackTrace) {
    _handleError(error as MyError);
    return Size.unspecified;
  }, test: (e) => e is MyError);
}

What is going on here?

Futures can be executed in succession. We are building a “pipeline” of execution. Each level of execution returns other future (possibly with other type). onError’s type of handler function depends on what future we’re handling. Although we’re handling errors thrown anywhere up the stream, we only need to provide completion value for last future in the chain, i.e. result of classify.

Observe the output of the above execution:

1
_handleError: My error: calculation exception

Handling exception trown in classify

If classify threw an exception, what would be the output? I’d expect that nothing would change - actually, classify is never called, because exception is thrown higher in the pipe, in compute. What if compute() provides int value, and classify fails? Let’s see:

Firs, make classify throw on negative numbers and make compute() use provided result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Future<Size> classify(int number) async {
  if (number < 0) {
    throw MyError(message: "classify error: negative");
  }
  return number > 100 ? Size.big : Size.small;
}

Future<int> compute({bool throwing: false, int result: 1}) async {
  if (throwing) {
    throw MyError(message: 'calculation exception');
  } else {
    return Future.delayed(Duration(seconds: 2), () => result);
  }
}

Now, let’s run the example:

  • with negative compute value
  • throwing: false What would happen?
  • compute would return normally after 2 seconds
  • classify would throw exception

So, would onError correctly report classify exception? Let’s find out:

1
2
3
4
5
6
7
8
void main(List<String> args) {
  compute(throwing: false, result: -10)
  .then(classify)
  .onError((error, stackTrace) {
    _handleError(error as MyError);
    return Size.unspecified;
  }, test: (e) => e is MyError);
}

_handleError: My error: classify error: negative

😄 That was easy! onError handles exception thrown somewhere up the chain. Would the same be true for async variant?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void main(List<String> args) async {
  try {
    int a = await compute(throwing: false, result: -120);
    Size size = await classify(a);
    print(size);
  } on Exception catch (e) {
    _handleError(e);
  }
  ;
}

The same output! Great!

Using a value in case of exception

I’m wondering: how can I use the value that onError returned to complete classify returned future? In Future-base variant I can further chain another then(_) call and use the value with which previous Futire was completed with (i.e.Size.unspecified):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void main(List<String> args) {
  compute(throwing: false, result: -1).then(classify).onError(
      (error, stackTrace) {
    _handleError(error as MyError);
    return Size.unspecified;
  }, test: (e) => e is MyError)
  .then((value) {
    print("Size is $value");
  });
}

results in

1
2
_handleError: My error: classify error: negative
Size is Size.unspecified

and async variant requires explicit asignment in on-catch block:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void main(List<String> args) async {
  Size? size = null;
  try {
    int a = await compute(throwing: false, result: -1);
    size = await classify(a);
    print(size);
  } on Exception catch (e) {
    _handleError(e);
    size = Size.unspecified;
  } finally {
    print("Size is $size");
  }
  ;
}

So, it seems everything can be done in both variants.

How about timout?

Timeout

Let’s say that we are presented with a business requirement to return Size.unspecified every time the computation takes longer than 500 ms. In order to test that, let’s prepare compute() so that it would take duration (of type Duration):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Future<int> compute(
    {bool throwing: false,
    int result: 1,
    Duration dur: const Duration(seconds: 2)
    }) async {
  if (throwing) {
    throw MyError(message: 'calculation exception');
  } else {
    return Future.delayed(dur, () => result);
  }
}

So, if we change the compute future to timeout after requestedTimeout, that’s all: our on Exception catch (e) block would catch TimeoutException and correctly assign size:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

void maina(List<String> args) async {
  Size? size = null;
  Duration requestedTimeout = Duration(milliseconds: 500);
  try {
    int a = await compute(
            throwing: false, result: 200, dur: Duration(seconds: 2))
        .timeout(requestedTimeout);
    size = await classify(a);
    print(size);
  } on Exception catch (e) {
    _handleError(e);
    size = Size.unspecified;
  } finally {
    print("Size is $size");
  }
  ;
}

Output:

1
2
_handleError: TimeoutException after 0:00:00.500000: Future not completed
Size is Size.unspecified

With Future variant some more work is needed. Not only should we set timeout on the future, byt also note that our test function in onError would effectively prevent handlng TimeoutException (it won’t allow _handleError to fire as well as won’t complete the Future with Size.unspecified).

Setting only timeout in Future variant gives this output:

1
2
Unhandled exception:
TimeoutException after 0:00:00.500000: Future not completed

Let’s relax the test, or actually, let’s remove it altogether; we could call _handleError conditionally, but return (i.e. complete) proper Size unconditionally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void mainf(List<String> args) {
  Duration requestedTimeout = Duration(milliseconds: 500);
  compute(throwing: false, result: -1)
  .timeout(requestedTimeout)
  .then(classify)
  .onError(
      (error, stackTrace) {
    if (error is MyError) {
      _handleError(error);
    }
    return Size.unspecified;
  }).then((value) {
    print("Size is $value");
  });
}

Now the above code results in proper handling of required value:

1
Size is Size.unspecified

Summary

Asynchronous execution syntax, language support or at least a library support is present in almost all major languages:

Sometimes the asynchrony is single-threaded (JS) and sometimes the tasks are scheduled on a number of OS threads (go).

Different approach exist in

  • Go: channels where goroutines are handled by the runtime (i.e. schedued automatically - by language runtime - for execution on syste threads)
  • Java with completable futures where currently using Furure-based approach requires thinking about actual executors (and underlying thread pools). Projec Loom and the arrival of JEPP 428 would introcude structured concurrency which furter complicates/simplifies the development of concurrent/asynchronous code.

No matter what language you choose, you need to learn the API, write small programs to get used to the API and have a grasp of how to deal with exceptional situations, read, experiment and be ready for all the troubles the async execution environment brings (with non-deterministic behavior, debugging, reasoning about code flow - to name a few).

And don’t ever think you know concurrent programming. It is hard (tm) and our carbon-based hardware would always have problems in processing concurrency (both as a concept and as a code).