Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disallow returning futures from async functions. #870

Open
lrhn opened this issue Mar 5, 2020 · 25 comments
Open

Disallow returning futures from async functions. #870

lrhn opened this issue Mar 5, 2020 · 25 comments
Assignees
Labels
feature Proposed language feature that solves one or more problems

Comments

@lrhn
Copy link
Member

lrhn commented Mar 5, 2020

In an async function with return type Future<T>, Dart currently allow you to return either a T or Future<T>.

That made (some) sense in Dart 1, where the type system wasn't particularly helpful and we didn't have type inference. Also, it was pretty much an accident of implementation because the return was implemented as completing a Completer, and Completer.complete accepted both a value and a future. If the complete method had only accepted a value, then I'm fairly sure the language wouldn't have allowed returning a future either.

In Dart 2, with its inference pushing context types into expressions, the return statement accepting a FutureOr<T> is more of a liability than an advantage (see, fx, dart-lang/sdk#40856).

I suggest that we change Dart to not accept a Future<T> in returns in an async function.
Then the context type of the return expression becomes T (the "future return type" of the function).

The typing around returns gets significantly simpler. There is no flatten on the expression, and currently an async return needs to check whether the returned value is a Future<T>, and if so, await it.
If T is Object, then that's always a possibility, so every return needs to dynamically check whether the value is a future, even if the author knows it's not.
This is one of the most complicated cases of implicit future handling in the language specification, and we'd just remove all the complication in one fell swoop.

And it would improve the type inference for users.

It would be a breaking change.
Any code currently returning a Future<T> or a FutureOr<T> will have to insert an explicit await.
This is statically detectable.
The one case which cannot be detected statically is returning a top type in a function with declared return type Future<top type>. Those needs to be manually inspected to see whether they intend to wait for any futures that may occur.
Alternatively, we can always insert the await in the migration, since awaiting non-futures changes nothing. It would only be a problem if the return type is Future<Future<X>> and the dynamic-typed value is a Future<X>. A Future<Future<...>> type is exceedingly rare (and shouldn't happen, ever, in well-designed code), so always awaiting dynamic is probably a viable approach. It may change timing, which has its own issues for badly designed code that relies on specific interleaving of asynchronous events.

That is the entirety of the migration, and it can be done ahead of time without changing program behavior*. We could add a lint discouraging returning a future, and code could insert awaits to get rid of the lint warning. Then they'd be prepared for the language change too.

The change would remove a complication which affects both our implementation and our users negatively, It would make an implicit await in returns into an explicit await, which will also make the code more readable, and it will get rid of the implementation/specification discrepancy around async returns.

*: Well, current implementations actually do not await the returned value, as the specification says they should, which means that an error future can get past a try { return errorFuture; } catch (e) {}. That causes much surprise and very little joy when it happens. I've also caught mistakes related to this in code reviews.

@lrhn lrhn added the feature Proposed language feature that solves one or more problems label Mar 5, 2020
@natebosch
Copy link
Member

This is statically detectable.
The one case which cannot be detected statically is returning a top type in a function with declared return type Future<top type>. Those needs to be manually inspected to see whether they intend to wait for any futures that may occur.

If we allow implicit cast from dynamic we wouldn't warn for any cases where the expression has static type dynamic, right? Would we special case that situation?

@lrhn
Copy link
Member Author

lrhn commented Mar 6, 2020

No static warnings for dynamic, as usual.
The expression would be implicitly cast to T before being returned, just as a return statement in a non-async function.

Currently the expression should first be evaluated to an object, then that object should be checked for being a Future<T>, and if it is, it is awaited and the result becomes the new value, otherwise we use the original value. Then the resulting value is implicitly cast to T.

The issue here is not new, it's just that it might hide something which becomes a run-time error.

@festelo
Copy link

festelo commented Jun 26, 2020

I think implementing this as analyzer rule is better, at least it doesn't require migration.

@jamesderlin
Copy link

BTW, this is the opposite of what the unnecessary_await_in_return lint recommends, so if this is done, the lint will need to be removed or disabled, and maybe analysis_options.yaml files will need to be updated during migration.

@mateusfccp
Copy link
Contributor

mateusfccp commented Sep 27, 2020

BTW, this is the opposite of what the unnecessary_await_in_return lint recommends, so if this is done, the lint will need to be removed or disabled, and maybe analysis_options.yaml files will need to be updated during migration.

This is not the same case.

unnecessary_await_in_return prefers

Future<int> future;
Future<int> f1() => future;

over

Future<int> future;
Future<int> f1() async => await future;

Both, however, would be valid in this issue proposal, as the first case is not async and returns a Future<T>, and the second case is async and return a T (because of await).

What would not be valid is

Future<int> future;
Future<int> f1() async => future;

@natebosch
Copy link
Member

BTW, this is the opposite of what the unnecessary_await_in_return lint recommends, so if this is done, the lint will need to be removed or disabled

Yes. The lint will need to be changed to only catch the case mentioned by @mateusfccp but allow it otherwise.

@natebosch
Copy link
Member

There is a subtle inference detail that I think this could help with. Currently the inference type that gets used for an expression which is returned is FutureOr, which may not be what the user expects. If there is a call to a generic method as the returned expression it can have user visible impacts on the inference.

T someMethod<T>(T arg) {
  print(T);
  return arg;
}

Future<S> returnIt<S>(S arg) async {
  return someMethod(arg); // Infers someMethod<FutureOr<T>>
}

Future<S> assignIt<S>(S arg) async {
  final result = someMethod(arg); // Infers someMethod<T>
  return result;
}

void main() {
  returnIt(1);
  assignIt(1);
}

If we require the await to get to a T, that inference will work like user expectations.

@natebosch
Copy link
Member

We could add a hint in the analyzer ahead of this change so that existing code can start to migrate ahead of time.

@lrhn
Copy link
Member Author

lrhn commented Sep 8, 2022

I really want to make this happen, preferably for Dart 3.0, but it's a potentially perilous migration.

I am working on a test of the viability of a particular migration strategy:

  • Add await to any return e where e has a static type implementing Future or being FutureOr.
  • Do nothing for a return e where e has any other type, other than dynamic
  • For a dynamic return, change the return to var $tmp = e; return $tmp is Future<E> ? await $tmp : $tmp as E;. (Basically do explicitly what we now do implicitly in the return.)

I want to check how viable it is to perform that migration before removing the current implicit await behavior,
as well as how well it preserves the current behavior after removing the current awaiting behavior.

Being successful depends on not having cases where we return a Future<Future<X>> and where this behavior would then await twice. Hopefully nested futures will be rare. You should never, ever have nested futures. If you do, your design should be improved instead. (Ob-nerd: ◇◇φ ⇔ ◇φ)

I just need to learn how to make a front-end transformation, ... that'll be any day now!

@lrhn
Copy link
Member Author

lrhn commented Oct 3, 2022

My current best guess is that this will not be considered safe enough in time to be included in Dart 3.0.

What I do want to do is:

  • Introduce a lint telling you to insert the await if you are returning something which would accept the await (a Future or FutureOr type which will be awaited anyway). Preferably with a quickfix/autofix.
  • Get that into the recommended.yaml lints.
  • Pass time while people get used to it.
  • Then make a later language versioned change change to change the behavior of return in await.

The risk is mainly around code returning dynamic. We can't see if that's intended to be FutureOr or Future or not.
We can consider a "strict_async_returns" lint which forces you to cast to a non-dynamic type, but that's annoying for

Future<dynamic> getJson(File file) async => jsonDecode(await file.readAsString);

which wants to return dynamic (and knows it cannot be a Future). I guess we'll just have to do some trial runs and see how much breaks, and if we can detect and fix it.

There are potential, hypothetical, cases where a return typedAsObject can contain a Future<Object>, but that's such a bad design that it's more likely to be an error than a deliberate choice.

@annagrin
Copy link

@nshahan @sigmundch @aam - I wonder what are the reasons that made flutter use this rule. Should we be concerned about potential performance/code size issues following this change? If yes, are optimizations possible?

@sigmundch
Copy link
Member

cc @alexmarkov @mkustermann - I believe you have both looked at async performance in the past on the VM side. Do you have any concerns here around this language proposal to force using await when returning a future from an async function?

@alexmarkov
Copy link

An additional await at return will add certain performance overhead, which may regress performance in case async method sometimes needs to delegate to another async method. However, it would also help to avoid runtime type check which is used when compiler cannot statically prove that return value is not future. That has a potential for improving performance in more common cases.

Ideally we should pair this change with only allowing to await futures. That would eliminate potentially costly runtime type check from await too and adding await at return would have less performance overhead (see dart-lang/sdk#49396).

These two changes (disallow returning futures and disallow await non-futures) would make overall language semantics clearer, async Dart code more strictly typed and would allow users to avoid error-prone cases like working with Future<Future<X>>. So it should be a win for user experience too.

In addition, in order to get back any regressed performance caused by this change we can optimize return await <expr> pattern in compiler if it is not inside try block to skip suspending current activation and forwarding result without resuming the current activation.

@eernstg
Copy link
Member

eernstg commented Mar 7, 2023

Agreed! - especially that it would

make overall language semantics clearer

However, I'd like to comment on the performance overhead:

An additional await at return will add certain performance overhead

The typical case should actually be that there is no additional await: The statement return e; in an async function body will need to be changed to return await e; in the situation where the current semantics would potentially evaluate an await expression anyway.

Moreover, in many of these cases the static type of e is Future<T> for some T which is a subtype of the future value type of the enclosing function, and in this case it's guaranteed that return e; in the current semantics would evaluate an await expression. In this case return e; with the old semantics and return await e; with the new semantics will have exactly the same behavior at run time. No extra cost.

The case where the returned expression has static type dynamic is special, and the code transformation which will ensure that the semantics is unchanged will contain an await expression, and it may or may not be executed. No extra cost here, though, because the rewritten code with the new semantics can have exactly the same behavior as return e; with the old semantics.

The only case where there is an extra cost is the following: We encounter return e; in an async function body with future value type F, e has a static type which is a subtype of F (so return e; is type correct, and it looks like we can just complete the already-returned future with the value of e). However, it is still possible that the value of e has a run-time type which is a subtype of Future<F>, and in that case we must await the future (according to the current semantics). So if we follow the general rule and change return e; to return await e; and run it with the new semantics, we might evaluate await o where o is an object which is not a future (or not a Future<F>, at least, where F is the future value type of the enclosing function).

This is an extra cost (because we could just have used the value of e directly). But, crucially, it's a different semantics if we don't perform the await, because awaiting that future will potentially have side effects and it will almost certainly yield a different object. That object could have a different run-time type as well (although it must still be some subtype of F).

For example:

import 'dart:async';

class A {}
class B1 extends A {}

class B2 extends A implements Future<B1> {
  Future<B1> fut = Future.value(B1());
  asStream() => fut.asStream();
  catchError(error, {test}) => fut.catchError(error, test: test);
  then<R>(onValue, {onError}) => fut.then(onValue, onError: onError);
  timeout(timeLimit, {onTimeout}) =>
      fut.timeout(timeLimit, onTimeout: onTimeout);
  whenComplete(action) => fut.whenComplete(action);
}

Future<A> g(A a) async {
  var a2 = await a; // `a2` has static type `A`, and the future is awaited.
  return a2;
}

void main() async {
  var a = await g(B2());
  print(a is B1); // Prints 'true'.
}

If the body of g had been return a; then the old semantics would have performed an await a at run time, even though the value of a is already a type correct completion of the already-returned future. So the actual body of g above spells out the meaning of return a; with the old semantics. However, with the new semantics we could also write return a;, in which case we would complete the already-returned future with the given A (whose run-time type is B2), and we would not have awaited the future.

I suspect that it is an exceedingly rare situation that an object has a run-time type which is a subtype of T as well as a subtype of Future<T>, for any T which isn't Object or a top type. I also suspect that these situations are rather error prone, and it's actually quite surprising that we can receive that instance of A (which is a class that has no relation to Future), then discover that it happens to be a Future<A> as well, and then await it.

All in all, I think this amounts to a rather strong argument that there is no real added performance cost. Moreover, the cases where there is an actual extra cost are likely to be rare, error-prone, and confusing, and it's probably much better to require that await operation to be requested explicitly by developers, or explicitly not requested, and then we can all see what's going on! 😄

@alexmarkov
Copy link

The typical case should actually be that there is no additional await: The statement return e; in an async function body will need to be changed to return await e; in the situation where the current semantics would potentially evaluate an await expression anyway.

Moreover, in many of these cases the static type of e is Future<T> for some T which is a subtype of the future value type of the enclosing function, and in this case it's guaranteed that return e; in the current semantics would evaluate an await expression. In this case return e; with the old semantics and return await e; with the new semantics will have exactly the same behavior at run time. No extra cost.

Maybe that's how it is specified, but not how it is implemented and how it works today. Currently VM doesn't perform an implicit await at return. Instead, if return value is a future, result future for async function is chained to complete when return value completes. This is more efficient than await, so an explicit await at return would incur additional performance overhead.

The following example demonstrates the point:

foo() async {
  await null;
  throw 'Err';
}

void main() async {
  try {
    return foo();
  } catch(e) {
    print('Caught: $e');
  }
}

Changing return foo() to return await foo() in this example shows the difference.

However, as I mentioned above, we can alleviate this performance overhead by adding an optimization which would combine return await to the future chaining if return await is not inside try block. So potential performance overhead of additional await shouldn't stop us from making this language change.

@eernstg
Copy link
Member

eernstg commented Mar 7, 2023

Oops, that's a bug: dart-lang/sdk#44395. I thought that had been fixed, but there are many failures: https://dart-ci.firebaseapp.com/current_results/#/filter=language/async/return_throw_test.

gnprice added a commit to gnprice/zulip-flutter that referenced this issue Jun 8, 2023
As is, the `finally` block gets run before the future returned by
the callback has completed.  I believe the underlying issue here
is this one in the Dart SDK:
  dart-lang/sdk#44395

The fix is to await the future and return the result of that,
rather than returning the future directly.

The Dart language folks (or at least some of them) hope to
eventually deal with this by making the old code here a type error,
requiring the value passed to `return` to have type T rather
than Future<T>:
  dart-lang/language#870
@codesculpture
Copy link

Well, Just waking this up from dart-lang/sdk#54311 and listening for this to land
cc: @lrhn

@gmpassos
Copy link

gmpassos commented Jan 3, 2024

If, in the future, Dart doesn't allow returning a Future without await inside an async block, it could potentially break a lot of code.

IMHO I consider it a bug only if it's inside a "try" block, as without it (try block), the behavior remains the same with or without await.

It might be challenging to implement this language change, and it could take a while to release it. A lint could help resolve the issue for now, in a simple and compatible way.

Also, consider allowing the return of Future (or anything) without await for a method returning FutureOr to avoid disrupting optimizations, since a method that returns FutureOr usually is attempting to optimize things. See https://pub.dev/packages/async_extension

@codesculpture
Copy link

I dont really get the part with and without try bloc @gmpassos Can u elaborate it

@gmpassos
Copy link

gmpassos commented Jan 3, 2024

Currently a try block only catches an exception of a Future if you use await before return it.

See:

dart-lang/sdk#59364

@codesculpture
Copy link

Thanks for explaining, understood. As of now implicilty a await is being performed on return value. But if we remove the await the future would have no context as like before.

@gmpassos
Copy link

gmpassos commented Jan 3, 2024

Well, I think that this new behavior could generate many side effects. At least this need to be well tested and the collateral effects well documented, including performance issues, due the overhead.

@eernstg
Copy link
Member

eernstg commented Jan 3, 2024

@gmpassos wrote:

IMHO I consider it a bug only if it's inside a "try" block

Right, the bug dart-lang/sdk#44395 is the behavior during execution of a return e; statement that occurs inside a try block in an async body, there's no known malfunction in this area for other placements of return e;.

... a method that returns FutureOr usually is attempting to optimize things

I think 'being optimized' in this context means 'executes with no asynchronous suspensions', that is, "it just runs normally all the way". In general, it is not safe to assume that code which is possibly-asynchronous would actually run without any suspensions (for instance, whether or not there's an extra suspension when an async function body returns a future). The behavior of the scheduler is deliberately specified with some wiggle room for implementations (for instance, the language specification doesn't even mention a scheduler, it just says that the execution must be suspended in certain situations).

The safe way to write code which is supposed to run without asynchronous suspensions is to make it fully non-asynchronous. You could then have a non-async function which has return type FutureOr<T> for a suitable T, and it would run asynchronous code and return a T in its fast paths. It would then also have some execution paths where the result is a Future<T> (and that might well be expressed as a different function with an async body, in order to allow for await expressions).

However, it is inconvenient that this "strict" approach doesn't fit packages like async_extension very well.

(From a brief look at it, I assume that this package is directly aimed at writing code which is "potentially asynchronous", and still obtain a non-suspended execution whenever possible. My advice is basically "don't try to do that, it's too difficult to ensure that it will actually never suspend, in the situations where that's expected.")

@gmpassos
Copy link

gmpassos commented Jan 3, 2024

Note that in the case of a method that returns FutureOr, I'm more concerned about methods without "async", to ensure that they are not affected by the new behavior (sorry for not being clearer).

For me an async method, returning FutureOr, should behave exactly like a method that returns Future. Also there's no meaning to have a method that is async and returns a FutureOr, unless you are respecting some "interface" signature.

@eernstg
Copy link
Member

eernstg commented Jan 3, 2024

For me an async method, returning FutureOr, should behave exactly like a method that returns Future.

Such a function will indeed return a Future<...> no matter which return type we specify (as long as that return type isn't a compile-time error). The actual type argument of that future is based on the declared return type, so if the function declares that it returns a FutureOr<T> then it will actually return a Future<T>.

Also there's no meaning to have a method that is async and returns a FutureOr, unless you are respecting some "interface" signature.

Exactly! You might be forced into using FutureOr<T> as the return type of some instance method with an async body, because it is a participant in an override relationship where some overriding declarations will actually return a T (and those overriding declarations might then have return type T, or they might also use FutureOr<T>, if they are overridden by a declaration that actually will return a Future<T>).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
Status: Being discussed
Development

No branches or pull requests