39

I'm writing client libraries for Google Cloud APIs which have a fairly common pattern for async helper overloads:

  • Do some brief synchronous work to set up a request
  • Make an asynchronous request
  • Transform the result in a simple way

Currently we're using async methods for that, but:

  • Transforming the result of await ends up being annoying in terms of precedence - we end up needing (await foo.Bar().ConfigureAwait(false)).TransformToBaz() and the brackets are annoying. Using two statements improves readability, but means we can't use an expression-bodied method.
  • We occasionally forget ConfigureAwait(false) - this is solvable with tooling to some extent, but it's still a bit of a smell

Task<TResult>.ContinueWith sounds like a good idea, but I've read Stephen Cleary's blog post recommending against it, and the reasons seem sound. We're considering adding an extension method for Task<T> like this:

Potential extension method

public static async Task<TResult> Convert<TSource, TResult>(
    this Task<TSource> task, Func<TSource, TResult> projection)
{
    var result = await task.ConfigureAwait(false);
    return projection(result);
}

We can then call this from a synchronous method really simply, e.g.

public async Task<Bar> BarAsync()
{
    var fooRequest = BuildFooRequest();
    return FooAsync(fooRequest).Convert(foo => new Bar(foo));
}

or even:

public Task<Bar> BarAsync() =>
    FooAsync(BuildFooRequest()).Convert(foo => new Bar(foo));

It seems so simple and useful that I'm slightly surprised there isn't something already available.

As an example of where I'd use this to make an expression-bodied method work, in the Google.Cloud.Translation.V2 code I have two methods to translate plain text: one takes a single string and one takes multiple strings. The three options for the single-string version are (simplified somewhat in terms of parameters):

Regular async method

public async Task<TranslationResult> TranslateTextAsync(
    string text, string targetLanguage)
{
    GaxPreconditions.CheckNotNull(text, nameof(text));
    var results = await TranslateTextAsync(new[] { text }, targetLanguage).ConfigureAwait(false);
    return results[0];
}

Expression-bodied async method

public async Task<TranslationResult> TranslateTextAsync(
    string text, string targetLanguage) =>
    (await TranslateTextAsync(new[] { GaxPreconditions.CheckNotNull(text, nameof(text)) }, targetLanguage)
        .ConfigureAwait(false))[0];

Expression-bodied sync method using Convert

public Task<TranslationResult> TranslateTextAsync(
    string text, string targetLanguage) =>
    TranslateTextAsync(new[] { GaxPreconditions.CheckNotNull(text, nameof(text)) }, targetLanguage)
        .Convert(results => results[0]);

I personally prefer the last of these.

I'm aware that this changes the timing of the validation - in the final example, passing a null value for text will immediately throw an ArgumentNullException whereas passing a null value for targetLanguage will return a faulted task (because TranslateTextAsync will fail asynchronously). That's a difference I'm willing to accept.

Are there differences in scheduling or performance that I should be aware of? (We're still constructing two state machines, because the Convert method will create one. Using Task.ContineWith would avoid that, but has all the problems mentioned in the blog post. The Convert method could potentially be changed to use ContinueWith carefully.)

(I'm somewhat tempted to post this on CodeReview, but I suspect the information in the answers will be more generally useful beyond whether this is specifically a good idea. If others disagree, I'm happy to move it.)

Woody1193
  • 7,252
  • 5
  • 40
  • 90
Jon Skeet
  • 1,421,763
  • 867
  • 9,128
  • 9,194
  • You're adding another state machine to the pipeline. There are definitely performance penalties to that. What's so wrong about `(await foo.Bar().ConfigureAwait(false)).TransformToBaz()`? – Paulo Morgado Apr 21 '17 at 09:28
  • @PauloMorgado: How is it adding another state machine? It's going from one state machine in the async method `BarAsync` (which I've just noticed wasn't explicitly declared as async) to calling the async `Convert` method (which has a state machine) from a *non-async* `BarAsync` method. The "what's so wrong" is: 1) it's not as readable IMO; 2) it's easy to forget `ConfigureAwait(false)` whereas the method puts that in a single place. – Jon Skeet Apr 21 '17 at 09:55
  • of course written code is to be read by humans, but, if you introduce that `Convert` method, you're not educating anyone. And one single line of code executed millions of times at run-time will change the performance and resource allocation of the program. Now, if the use of your API commonly requires transformation, why don't you add overloads with a selector function? – Paulo Morgado Apr 22 '17 at 21:38
  • @PauloMorgado: Because it's an RPC API - it doesn't have the concept of overloads, and adding extra RPCs wouldn't make sense IMO. Adding the overloads into the public client library API is doing the work to make this as useful as possible without introducing redundancy in the RPC API. Now, you originally said that it was introducing another state machine into the pipeline - do you stand by that? If so, I'm definitely missing something. – Jon Skeet Apr 23 '17 at 06:11
  • Your implementation of `Convert`. by using the `async` keyword, introduces a state machine. Have a look at [Eduasync](https://codeblog.jonskeet.uk/category/eduasync/). :D – Paulo Morgado Apr 23 '17 at 15:58
  • @PauloMorgado: But I've *removed* a state machine due to `BarAsync` (or `TranslateTextAsync`) now not using the `async` contextual keyword. So it's just moved the state machine from one place to another. There's still a single state machine involved, plus whatever the underlying method (`FooAsync` or the other `TranslateTextAsync` overload) uses. – Jon Skeet Apr 23 '17 at 16:37
  • then using `Convert` to an already written method implies rewriting it, right? And if `TranslateTextAsync(Whatever[], string)` throws an exception, that will show up in the stack trace as being thrown from `Convert`. Wouldn't that confuse users? – Paulo Morgado Apr 23 '17 at 20:38
  • @PauloMorgado: I'm not sure what you mean by "to an already written method", or what the rewriting has to do with it - it'll be a matter of potentially refactoring some existing code, and we could use it for future code. I don't think it would be particularly confusing to have `Convert` show up in a stack trace though, to be honest - especially as `TranslateTextAsync` will still be in there too. But do you now agree that there are the same number of state machines as before? – Jon Skeet Apr 23 '17 at 21:33
  • you said there's a single state machine involved because you change the method that uses `Convert` to not be async. That means changing written code just for the purpose of not paying the penalty of using `Convert`. All that for not using a pair of parenthesis. – Paulo Morgado Apr 24 '17 at 06:10
  • I know that it's not the main point of your question but why isn't the whole method asynchronous so that you don't need the ConfigureAwait(false) ? – maracuja-juice Feb 19 '19 at 20:08
  • @maracuja-juice: I'm not sure what you mean - I suspect you may have misunderstood what `ConfigureaAwait(false)` does, and its limitations. What change are you suggesting? – Jon Skeet Feb 20 '19 at 06:41
  • @GoldenLion: I don't know what you specifically mean by superfunction, but it's just a method that creates a `Task` from a `Task` and a `Func`. I'm not sure what you believe to be missing from the description in the question. – Jon Skeet Nov 15 '22 at 19:07
  • I figured it out. Action is a delegate function. TResult is a generic that has no parameter. TSource is generic type – Golden Lion Nov 15 '22 at 21:23
  • @GoldenLion: `Action` is a generic type, yes - and both `TSource` and `TResult` are type parameters, following the normal conventions. I don't know what you mean by "a generic that has no parameter". – Jon Skeet Nov 16 '22 at 07:20
  • The convert seems to work similar to IMapper . BHrtrainingReservation newData = _mapper.Map(myData); where map TDestination Map(object source); CreateMap(); The main difference being CreateMap seems to use a lookup table for the conversion – Golden Lion Nov 16 '22 at 15:27
  • @GoldenLion: I don't know what `IMapper` you're talking about, but it doesn't seem relevant to the question particularly. You've left a lot of comments on old questions over the last couple of days, as if wanting to discuss things and ask new questions. That's not what comments on Stack Overflow are for; please don't use them that way. – Jon Skeet Nov 16 '22 at 15:30
  • I am trying to figure out where Convert would fit in an development cycle. – Golden Lion Nov 16 '22 at 16:37

1 Answers1

24

Transforming the result of await ends up being annoying in terms of precedence

I generally prefer to introduce a local var, but as you noted, that prevents expression-bodied methods.

We occasionally forget ConfigureAwait(false) - this is solvable with tooling to some extent

Since you're working on a library and should use ConfigureAwait(false) everywhere, it may be worthwhile to use a code analyzer that enforces ConfigureAwait usage. There's a ReSharper plugin and a VS plugin that do this. I haven't tried them myself, though.

Task<TResult>.ContinueWith sounds like a good idea, but I've read Stephen Cleary's blog post recommending against it, and the reasons seem sound.

If you used ContinueWith, you'd have to explicitly specify TaskScheduler.Default (this is the ContinueWith equivalent of ConfigureAwait(false)), and also consider adding flags such as DenyChildAttach. IMO it's harder to remember how to use ContinueWith correctly than it is to remember ConfigureAwait(false).

On the other hand, while ContinueWith is a low-level, dangerous method, if you use it correctly then it can give you minor performance improvements. In particular, using the state parameter can save you a delegate allocation. This is the approach commonly taken by the TPL and other Microsoft libraries, but IMO it lowers the maintainability too much for most libraries.

It seems so simple and useful that I'm slightly surprised there isn't something already available.

The Convert method you suggest has existed informally as Then. Stephen doesn't say so, but I assume that the name Then is from the JavaScript world, where promises are the task equivalent (they are both Futures).

On a side note, Stephen's blog post takes this concept to an interesting conclusion. Convert/Then is the bind for the Future monad, so it can be used to implement LINQ-over-futures. Stephen Toub has also published code for this (rather dated at this point, but interesting).

I have thought a few times about adding Then to my AsyncEx library, but each time it didn't make the cut because it's pretty much the same as just await. Its only benefit is solving the precedence problem by allowing method chaining. I assume it doesn't exist in the framework for the same reason.

That said, there's certainly nothing wrong with implementing your own Convert method. Doing so will avoid the parenthesis / extra local variable and allow for expression-bodied methods.

I'm aware that this changes the timing of the validation

This is one of the reasons that I'm wary of eliding async/await (my blog post goes into more reasons).

In this case, I think it's fine either way, since the "brief synchronous work to set up a request" is a preconditions check, and IMO it doesn't matter where boneheaded exceptions are thrown (because they shouldn't be caught anyway).

If the "brief synchronous work" was more complex - if it was something that could throw, or could reasonably throw after someone refactors it a year from now - then I would use async/await. You could still use Convert to avoid the precedence issue:

public async Task<TranslationResult> TranslateTextAsync(string text, string targetLanguage) =>
  await TranslateTextAsync(SomthingThatCanThrow(text), targetLanguage)
  .Convert(results => results[0])
  .ConfigureAwait(false);
Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810