내용 보기

작성자

관리자 (IP : 172.17.0.1)

날짜

2020-07-10 05:33

제목

[C#] Async/Await FAQ


From time to time, I receive questions from developers which highlight either a need for more information about the new “async” and “await” keywords in C# and Visual Basic. I’ve been cataloguing these questions, and I thought I’d take this opportunity to share my answers to them.

Conceptual Overview

Where can I get a good overview of the async/await keywords?

Generally, you can find lots of resources (links to articles, videos, blogs, etc.) on the Visual Studio Async page at https://msdn.com/async. To call out just a few specific resources, the October 2011 issue of MSDN Magazine included a trio of articles that provided a good introduction to the topic. If you read them all, I recommend you read them in the following order:

  1. Asynchronous Programming: Easier Asynchronous Programming with the New Visual Studio Async CTP
  2. Asynchronous Programming: Pause and Play with Await
  3. Asynchronous Programming: Understanding the Costs of Async and Await

The .NET team blog also includes a good overview of asynchrony in .NET 4.5: Async in 4.5: Worth the Await.

Why do I need the compiler to help me with asynchronous programming?

Anders Hejlsberg’s Future directions for C# and Visual Basic talk at //BUILD/ provides a great tour through why a compiler is really beneficial here. In short, the compiler takes on the responsibility of doing the kind of complicated transformations you’d otherwise be doing by hand as you manually invert your control flow using callbacks and continuation-passing style. You get to write your code using the language’s control flow constructs, just as you would if you were writing synchronous code, and the compiler under the covers applies the transformations necessary to use callbacks in order to avoid blocking threads.

To achieve the benefits of asynchrony, can’t I just wrap my synchronous methods in calls to Task.Run?

It depends on your goals for why you want to invoke the methods asynchronously. If your goal is simply to offload the work you’re doing to another thread, so as to, for example, maintain the responsiveness of your UI thread, then sure. If your goal is to help with scalability, then no, just wrapping a synchronous call in a Task.Run won’t help. For more information, see Should I expose asynchronous wrappers for synchronous methods? And if from your UI thread you want to offload work to a worker thread, and you use Task.Run to do so, you often typically want to do some work back on the UI thread once that background work is done, and these language features make that kind of coordination easy and seamless.

The “Async” Keyword

What does the “async” keyword do when applied to a method?

When you mark a method with the “async” keyword, you’re really telling the compiler two things:

  1. You’re telling the compiler that you want to be able to use the “await” keyword inside the method (you can use the await keyword if and only if the method or lambda it’s in is marked as async). In doing so, you’re telling the compiler to compile the method using a state machine, such that the method will be able to suspend and then resume asynchronously at await points.
  2. You’re telling the compiler to “lift” the result of the method or any exceptions that may occur into the return type. For a method that returns Task or Task<TResult>, this means that any returned value or exception that goes unhandled within the method is stored into the result task. For a method that returns void, this means that any exceptions are propagated to the caller’s context via whatever “SynchronizationContext” was current at the time of the method’s initial invocation.

Does using the “async” keyword on a method force all invocations of that method to be asynchronous?

No. When you invoke a method marked as “async”, it begins running synchronously on the curren thread. So, if you have a synchronous method that returns void and all you do to change it is mark it as “async”, invocations of that method will still run synchronously. This is true regardless of whether you leave the return type as “void” or change it to “Task”. Similarly, if you have a synchronous method that returns some TResult, and all you do is mark it as “async” and change the return type to be “Task<TResult>”, invocations of that method will still run synchronously.

Marking a method as “async” does not affect whether the method runs to completion synchronously or asynchronously. Rather, it enables the method to be split into multiple pieces, some of which may run asynchronously, such that the method may complete asynchronously. The boundaries of these pieces can occur only where you explicitly code one using the “await” keyword, so if “await” isn’t used at all in a method’s code, there will only be one piece, and since that piece will start running synchronously, it (and the whole method with it) will complete synchronously.

Does the “async” keyword cause the invocation of a method to queue to the ThreadPool? To create a new thread? To launch a rocket ship to Mars?

No. No. And no. See the previous questions. The “async” keyword indicates to the compiler that “await” may be used inside of the method, such that the method may suspend at an await point and have its execution resumed asynchronously when the awaited instance completes. This is why the compiler issues a warning if there are no “awaits” inside of a method marked as “async”.

Can I mark any method as “async”?

No. Only methods that return void, Task, or Task<TResult> can be marked as async. Further, not all such methods can be marked as “async”. For example, you can’t use “async”:

  • On your application’s entry point method, e.g. Main. When you await an instance that’s not yet completed, execution returns to the caller of the method. In the case of Main, this would return out of Main, effectively ending the program.
  • On a method attributed with:
    • [MethodImpl(MethodImplOptions.Synchronized)]. For a discussion of why this is disallowed, see What’s New for Parallelism in .NET 4.5 Beta; attributing a method as Synchronized is akin to wrapping the entire body of the method with lock/SyncLock.
    • [SecurityCritical] and [SecuritySafeCritical]. When you compile an async method, the implementation / body of the method actually ends up in a compiler-generated MoveNext method, but the attributes for it remain on the signature you defined. That means that attributes like [SecuritySafeCritical] (which is meant to have a direct impact on what you’re able to do in the body of the method) would not work correctly, and thus they’re prohibited, at least for now.
  • On a method with ref or out parameters.  The caller would expect those values to be set when the synchronous invocation of the method completes, but the implementation might not set them until its asynchronous completion much later.
  • On a lambda used as an expression tree.  Async lambda expressions cannot be converted to expression trees.

Are there any conventions I should use when writing methods marked as “async”?

Yes. The Task-based Asynchronous Pattern (TAP) is entirely focused on how asynchronous methods that return Task or Task<TResult> should be exposed from libraries. This includes, but is not limited to, methods implemented using the “async” and “await” keywords. For an in-depth tour through the TAP, see the Task-based Asynchronous Pattern document.

Do I need to “Start” Tasks created by methods marked as “async”?

No.  Tasks returned from TAP methods are “hot”, meaning the tasks represent operations that are already in-progress.  Not only do you not need to call “.Start()” on such tasks, but doing so will fail if you try.  For more details, see FAQ on Task.Start.

Do I need to “Dispose” Tasks created by methods marked as “async”?

No. In general, you don’t need to Dispose of any tasks.  See Do I need to dispose of Tasks?.

How does “async” relate to the current SynchronizationContext?

For methods marked as “async” that return Task or Task<TResult>, there is no method-level interaction with the SynchronizationContext. However, for methods marked as “async” that return void, there is a potential interaction.

When an “async void” method is invoked, the prolog for the method’s invocation (as handled by the AsyncVoidMethodBuilder that is created by the compiler to represent the method’s lifetime) will capture the current SynchronizationContext (“capture” here means it accesses it and stores it). If there is a non-null SynchronizationContext, two things will be affected:

  • The beginning of the method’s invocation will result in a call to the captured context’s OperationStarted method, and the completion of the method’s execution (whether synchronous or asynchronous) will result in a call to that captured context’s OperationCompleted method. This gives the context a chance to reference count outstanding asynchronous operations; if instead the method had returned a Task or Task<TResult>, the caller could have done the same tracking via that returned task.
  • If the method completes due to an unhandled exception, the throwing of that exception will be Post’d to the captured SynchronizationContext. This gives the context a chance to deal with the failure. This is in contrast to a Task or Task<TResult>-returning async method, where the exception can be marshaled to the caller through the returned task.

If there isn’t a SynchronizationContext when the “async void” method is called, no context is captured, and then as there are no OperationStarted / OperationCompleted methods to call, none are invoked. In such a case, if an exception goes unhandled, the exception is propagated on the ThreadPool, which with default behavior will cause the process to be terminated.

The “Await” Keyword

What does the “await” keyword do?

The “await” keyword tells the compiler to insert a possible suspension/resumption point into a method marked as “async”.

Logically this means that when you write “await someObject;” the compiler will generate code that checks whether the operation represented by someObject has already completed. If it has, execution continues synchronously over the await point. If it hasn’t, the generated code will hook up a continuation delegate to the awaited object such that when the represented operation completes, that continuation delegate will be invoked. This continuation delegate will re-enter the method, picking up at this await location where the previous invocation left off. At this point, regardless of whether the awaited object had already completed by the time it was awaited, any result from the object will be extracted, or if the operation failed, any exception that occurred will be propagated.

In code, this means that when you write:

await someObject;

the compiler translates that into something like the following (this code is an approximation of what the compiler actually generates):

private class FooAsyncStateMachine : IAsyncStateMachine
{
    // Member fields for preserving “locals” and other necessary state
    int $state;
    TaskAwaiter $awaiter;
    …
    public void MoveNext()
    {
        // Jump table to get back to the right statement upon resumption
        switch (this.$state)
        {
            …
            case 2: goto Label2;
            …
        }
        …
        // Expansion of “await someObject;”
        this.$awaiter = someObject.GetAwaiter();
        if (!this.$awaiter.IsCompleted)
        {
            this.$state = 2;
            this.$awaiter.OnCompleted(MoveNext);
            return;
            Label2:
        }
        this.$awaiter.GetResult();
        …

    }
}

What are awaitables? What are awaiters?

While Task and Task<TResult> are two types very commonly awaited, they’re not the only ones that may be awaited.

An “awaitable” is any type that exposes a GetAwaiter method which returns a valid “awaiter”. This GetAwaiter method may be an instance method (as it is in the case of Task and Task<TResult>), or it may be an extension method.

An “awaiter” is any type returned from an awaitable’s GetAwaiter method and that conforms to a particular pattern. The awaiter must implement the System.Runtime.CompilerServices.INotifyCompletion interface, and optionally may implement the System.Runtime.CompilerServices.ICriticalNotifyCompletion interface. In addition to providing an implementation of the OnCompleted method that comes from INotifyCompletion (and optionally the UnsafeOnCompleted method that comes from ICriticalNotifyCompletion), an awaiter must also provide an IsCompleted Boolean property, as well as a parameterless GetResult method. GetResult returns void if the awaitable represents a void-returning operation, or it returns a TResult if the awaitable represents a TResult-returning operation.

Any type that follows the awaitable pattern may be awaited. For a discussion of several approaches to implementing custom awaitables, see await anything;. You can also implement awaitables customized for very specific situations: for some examples, see Advanced APM Consumption in Async Methods and Awaiting Socket Operations.

Where can’t I use “await”?

You can’t use await:

Is “await task;” the same thing as “task.Wait()”?

No.

“task.Wait()” is a synchronous, potentially blocking call: it will not return to the caller of Wait() until the task has entered a final state, meaning that it’s completed in the RanToCompletion, Faulted, or Canceled state. In contrast, “await task;” tells the compiler to insert a potential suspension/resumption point into a method marked as “async”, such that if the task has not yet completed when it’s awaited, the async method should return to its caller, and its execution should resume when and only when the awaited task completes. Using “task.Wait()” when “await task;” would have been more appropriate can lead to unresponsive applications and deadlocks; see Await, and UI, and deadlocks! Oh my!.

There are some other potential pitfalls to be aware of when using “async” and “await”. For some examples, see:

Is there a functional difference between “task.Result” and “task.GetAwaiter().GetResult()”?

Yes, but only if the task completes non-successfully.  If the task ends in the RanToCompletion state, these are completely equivalent statements.  If, however, the task ends in the Faulted or Canceled state, the former will propagate the one or more exceptions wrapped in AggregateException, while the latter will propagate the exception directly (and if there are more than one in the task, it’ll just propagate one of them).  For background on why this difference exists, see Task Exception Handling in .NET 4.5.

How does “await” relate to the current SynchronizationContext?

This is entirely up to the type being awaited. For a given await, the compiler generates code that ends up calling an awaiter’s OnCompleted method, passing in the continuation delegate to be executed. The compiler-generated code knows nothing about SynchronizationContext, and simply relies on the awaited object’s OnCompleted method to invoke the provided callback when the awaited operation completes. It’s the OnCompleted method, then, that’s responsible for making sure that the delegate is invoked in the “right place,” where “right place” is left entirely up to the awaiter.

The default behavior for awaiting a task (as implemented by the TaskAwaiter and TaskAwaiter<TResult> types returned from Task’s and Task<TResult>’s GetAwaiter methods, respectively) is to capture the current SynchronizationContext before suspending, and then when the awaited task completes, if there had been a current SynchronizationContext that got captured, to Post the invocation of the continuation delegate back to that SynchronizationContext. So, for example, if you use “await task;” on the UI thread of your application, OnCompleted when invoked will see a non-null current SynchronizationContext, and when the task completes, it’ll use that UI’s SynchronizationContext to marshal the invocation of the continuation delegate back to the UI thread.

If there isn’t a current SynchronizationContext when you await a Task, then the system will check to see if there’s a current TaskScheduler, and if there is, the continuation will be scheduled to that when the task completes.

If there isn’t such a context or scheduler to force the continuation back to, or if you do “await task.ConfigureAwait(false)” instead of just “await task;”, then the continuation won’t be forced back to the original context and will be allowed to run wherever the system deems appropriate. This typically means either running the continuation synchronously wherever the awaited task completes or running the continuation on the ThreadPool.

Can I use “await” in console apps?

Sure. You can’t use “await” inside of your Main method, however, as entry points can’t be marked as async. Instead, you can use “await” in other methods in your console app, and then if you call those methods from Main, you can synchronously wait (rather than asynchronously wait) for them to complete, e.g.

public static void Main()
{
    FooAsync().Wait();

}

private static async Task FooAsync()
{
    await Task.Delay(1000);
    Console.WriteLine(“Done with first delay”);
    await Task.Delay(1000);

}

You could also use a custom SynchronizationContext or TaskScheduler to achieve similar capabilities. For more information, see:

Can I use “await” with other asynchronous patterns, like the Asynchronous Programming Model (APM) pattern and the Event-based Async Pattern (EAP)?

Sure. You can either implement a custom awaitable for your asynchronous operation, or you can convert the existing asynchronous operation to something that’s already awaitable, like Task or Task<TResult>. Here are some examples:

Does the code generated by async/await result in efficient asynchronous execution?

For the most part, yes, as a lot of work has been done to optimize the code generated by the compiler and the .NET Framework methods on which the generated code relies. For more information, including on best practices for minimizing the overhead of using tasks and async/await, see:


출처1

https://devblogs.microsoft.com/pfxteam/asyncawait-faq/

출처2