Konrad Viltersten
Konrad Viltersten

Reputation: 39220

Why am I required to await a call when it's already been await'ed (.NET Core 2.2)

I have the service method returning an awaitable member view model.

public async Task<MemberVm> GetMember(Guid id)
{
  Task<Member> output = Context.Members
    .SingleOrDefaultAsync(e => e.Id == id);

  return await output != null
    ? new MemberVm(output)
    : null;
}

This doesn't compile because of new MemberVm(output). Instead, the computer requires me to do new MemberVm(await output). I would understand it if it was a simple return statement but in this case it's already been awaited while evaluation the conditional expression. To me it seems like this pseudo-code.

if(await output != null)
  return await-again-but-why new MemberVm(output)
else
  return null;

Am I doing it wrong or is it just an unintended and unfortunate consequence of the language syntax?

Upvotes: 1

Views: 408

Answers (5)

millimoose
millimoose

Reputation: 39980

If you haven't read how async..await works, you probably should to reason about it better; but what those keywords primarily do is trigger an automagical rewrite of your original code into continuation-passing style.

What basically happens is your original code is transformed into:

public Task<MemberVm> GetMember(Guid id)
{
    Task<Member> output = Context.Members
        .SingleOrDefaultAsync(e => e.Id == id);
    return output.ContinueWith((Task<Member> awaitedOutput) => 
        awaitedOutput.Result != null ? new MemberVm(output.Result) : null);
}

The original output variable remains untouched, the result of the await (so to speak) is passed into the continuation when it's available. Since you're not saving it into a variable, it's not available to you after it's first used. (It's the lambda parameter I called awaitedOutput, it's actually probably going to be something garbled the C# compiler generates if you don't assign the awaited output to a variable yourself.)

In your case it's probably easiest to just store the awaited value in the variable

public Task<MemberVm> GetMember(Guid id)
{
    Member output = await Context.Members
        .SingleOrDefaultAsync(e => e.Id == id);
    return output != null
        ? new MemberVm(output)
        : null;
}

You could also probably use output.Result directly in the code under the await, but that's not really how you're supposed to do things and it's slightly error-prone. (If you reassign output inadvertently to a different task for some reason. This would cause the whole thread to Wait(), and my guess is it'd just freeze.)


Crucially, it doesn't make any sense to say "a call that's already been awaited". Under the hood, await isn't a thing you do to a call or a task; it's an instruction to the compiler to take all the code following the await, pack it up into a closure, pass that to Task.ContinueWith(), and immediately return the new task. That is: await doesn't in and of itself cause a wait for the result of the call, it causes the waiting code to be registered as a callback to be invoked whenever the result is available. If you await a Task whose result is already available, all that changes is that this callback will be called sooner.

The way this achieves asynchrony is that control returns to some event loop outside your code at every point where you need to wait for a call to complete. This event loop watches for stuff to arrive "from the outside" (e.g. some I/O operation completing), and wakes up whatever continuation chain is waiting for this. When you await the same Task more than once, all that happens is that it handles several such callbacks.

(Hypothetically, yes, the compiler could also transform the code so that following an await, the original variable name refers to the new value. But there's a bunch of reasons why I imagine it's not implemented that way - the type of a variable changing mid-function is unprecedented in C# and would be confusing; and generally it seems like it'd be more complex and harder to reason about overall.)


Going on a hopefully illustrative tangent here: what I believe more or less happens when you await a Task in the same async function twice is:

  1. Execution reaches the first await, a callback (which contains is created and passed to ContinueWith(), control returns to toplevel.
  2. Some time later, the result of the call is available, and the callback ends up invoked with the result as the parameter.
  3. Execution of the first callback reaches the second await, another callback is created and passed to ContinueWith()
  4. Since the result is already available, this second callback is probably invoked immediately, and the rest of your function runs.

As you can see, awaiting the same Task twice is mostly a pointless detour through the event loop. If you need the value of the task, just put it into a variable. In my experience, a lot of the time you might as well immediately await a call to any async function, and move this statement as close to where the task result is used as you can. (Since any code after the await won’t run until after the result is available, even if it’s not using that result.) The exception is if you have some code you need to run after starting the call, but before using the result, that you can't just run before the call starts for some reason.

Upvotes: 3

bmm6o
bmm6o

Reputation: 6515

It's important to understand the the problem that the compiler flagging is a type problem. The MemberVm constructor takes a Member argument, but your output variable is of type Task<Member>. The compiler doesn't really want you to await for the Task again, but that is the most common way to extract the result from a Task and make the types work. Another way to rewrite your code is by changing the type of output:

Member output = await Context.Members.SingleOrDefaultAsync(e => e.Id == id);

Now you can pass output directly to the MemberVm constructor, because you've saved the result of the first await.

Upvotes: 1

Chris Pratt
Chris Pratt

Reputation: 239430

There's already correct answers here, but the explanations are far more complex than necessary. The await keyword both holds execution until the task completes and unwraps the task (i.e Task<Member> becomes just Member). However, you're not persisting that unwrapping part.

The second output is still a Task<Member>. It's completed now, but it isn't unwrapped because you didn't save the result of that.

Upvotes: 3

Fildor
Fildor

Reputation: 16128

It doesn't compile because output is a Task, not a Member.

This would work:

public async Task<MemberVm> GetMember(Guid id)
{
  Member member = await Context.Members
    .SingleOrDefaultAsync(e => e.Id == id);

  return member != null
    ? new MemberVm(member)
    : null;
}

This doesn't :

Task<Member> output = Context.Members
                             .SingleOrDefaultAsync(e => e.Id == id);

return await output != null // <= "await output" is null or a Member instance
    ? new MemberVm(output)  // "output" is always a Task<Member>
    : null;

By writing await output "ouput" itself will not be replaced by the result of the await. It is still the same reference to the Task you created above.


Unrelated: I wouldn't recommend returning null. I guess I'd have MemberVM deal with being setup with null or throw an exception if this is a strong indication something is wrong with the application code or DB consistency.

Upvotes: 1

peaceoutside
peaceoutside

Reputation: 962

Does this work?

What does the constructor for MemberVm look like?

public async Task<MemberVm> GetMember(Guid id)
{
  var output = await Context.Members
    .SingleOrDefaultAsync(e => e.Id == id);

  if (output == null)
     return null;

  return new MemberVm(output);
}

It would seem that the constructor for MemberVm doesn't take a Task param in its constructor (although without seeing the code, I can't tell for sure). Instead, I think the constructor needs just a regular MemberVm param, so by evaluating the Context.Members... call before anything else, should help fix what you have going on. If not, let me know and we'll figure it out.

Upvotes: 1

Related Questions