Unnecessary async/await when await is last?

2020-02-11 19:53发布

问题:

I've been dealing quite a lot with async await lately (read every possible article including Stephen's and Jon's last 2 chapters) , but I have come to conclusion and I don't know if it's 100% correct. - hence my question .

Since async only allows the word await to be present , i'll leave async aside.

AFAIU , await is all about continuation . instead of writing functional (continuational) code , write synchronous code. ( i like to refer it as callback'able code)

So when the compiler reaches await - it splits the code to 2 sections and registers the second part to be executed after the first part is done ( I don't know why the word callback isn't used - which is exactly what is done). ( meanwhile working - the thread is back doing other things).

But looking at this code :

public async  Task ProcessAsync()
        {
           Task<string> workTask = SimulateWork();
           string st= await workTask;
           //do something with st
        }

 public    Task <string> SimulateWork()
        {
            return ...
        }

When thread reaches await workTask; it split the method to 2 sections . so after SimulateWork is finished - the continuation of the method : AKA : //do something with st - is executed.

all ok

But what if the method was :

public async  Task ProcessAsync()
        {
           Task<string> workTask = SimulateWork();
           await workTask; //i don't care about the result , and I don't have any further commands 
        }

Here - I don't need continuation , meaning - I don't need the await to split the method which means - I don't need async /await here at all ! and still I will have the same results/behaviour !

So I could do it like :

   public void ProcessAsync()
            {
               SimulateWork();
            }

Question:

  • Was I 100% correct with my diagnostics ?

回答1:

So, you think the await below is redundant, as the question's title implies:

public async Task ProcessAsync()
{
    Task<string> workTask = SimulateWork();
    await workTask; //i don't care about the result , and I don't have any further 
}

First of all, I assume that under "when await is last" you mean "when the await is the only await". It's got to be that, because otherwise the following simply would not compile:

public async Task ProcessAsync()
{
    await Task.Delay(1000);
    Task<string> workTask = SimulateWork();
    return workTask; 
}

Now, if it's the only await, you can indeed optimize it like this:

public Task ProcessAsync()
{
    Task<string> workTask = SimulateWork();
    return workTask; 
}

However, it would give you completely different exception propagation behavior, which may have some unexpected side effects. The thing is, now exceptions may be thrown on the caller's stack, depending on how SimulateWork is internally implemented. I posted a detailed explanation of this behavior. This normally never happens with async Task/Task<> methods, where exception is stored inside the returned Task object. It still may happen for an async void method, but that's a different story.

So, if your caller code is ready for such differences in exception propagation, it may be a good idea to skip async/await wherever you can and simply return a Task instead.

Another matter is if you want to issue a fire-and-forget call. Usually, you still want to track the status of the fired task somehow, at least for the reason of handling task exceptions. I could not imagine a case where I would really not care if the task never completes, even if all it does is logging.

So, for fire-and-forget I usually use a helper async void method which stores the pending task somewhere for later observation, e.g.:

readonly object _syncLock = new Object();
readonly HashSet<Task> _pendingTasks = new HashSet<Task>();

async void QueueTaskAsync(Task task)
{
    // keep failed/cancelled tasks in the list
    // they will be observed outside
    lock (_syncLock)
        _pendingTasks.Add(task);

    try
    {
        await task;
    }
    catch
    {
        // is it not task's exception?
        if (!task.IsCanceled && !task.IsFaulted)
            throw; // re-throw

        // swallow, but do not remove the faulted/cancelled task from _pendingTasks 
        // the error will be observed later, when we process _pendingTasks,
        // e.g.: await Task.WhenAll(_pendingTasks.ToArray())
        return;
    }

    // remove the successfully completed task from the list
    lock (_syncLock)
        _pendingTasks.Remove(task);
}

You'd call it like this:

public Task ProcessAsync()
{
    QueueTaskAsync(SimulateWork());
}

The goal is to throw fatal exceptions (e.g., out-of-memory) immediately on the current thread's synchronization context, while task result/error processing is deferred until appropriate.

There's been an interesting discussion of using tasks with fire-and-forget here.



回答2:

You're close. It means that you can write it like this:

public Task ProcessAsync()
{
    // some sync code
    return SimulateWork();
}

That way you don't "pay" for the overhead of marking the method as async but you still keep the ability to await that whole operation.


P.S: Here's a study about the common misuses of async-await:

  1. Fire-Forget async Methods
  2. Unnecessary async Methods
  3. Long-running Operations Under async Methods
  4. Unnecessarily Capturing Context Under async Methods