Click here to Skip to main content
15,889,315 members
Articles / All Topics

The Curious Case of Async, Await, and IDisposable

Rate me:
Please Sign up or sign in to vote.
5.00/5 (6 votes)
4 May 2017CPOL3 min read 8.7K   3   2
The Curious Case of Async, Await, and IDisposable

Consider these two methods:

C#
public Task DoWorkAsync()
{
    var arg1 = ComputeArg();
    var arg2 = ComputeArg();
    return AwaitableMethodAsync(arg1, arg2);
}
C#
public async Task DoWork2Async()
{
    var arg1 = ComputeArg();
    var arg2 = ComputeArg();
    await AwaitableMethodAsync(arg1, arg2);
}

Do you notice the difference?

The first is a synchronous method that returns a Task. The Task may or may not have completed when the method returns. The second as an async method that returns the result of awaiting other work.

These two methods look almost the same, but the code generated by the compiler for them is very different. These two talks on InfoQ are by Jon Skeet and I go into all the gory details about the differences:

In most cases, you should prefer writing the first version when possible. The method is much simpler, and is much easier to reason about. It's a synchronous method that returns an object that represents work that may be ongoing.

The second is more complicated. It builds a state machine. It manages re-entrancy for code that should execute when the awaited task finishes. It returns. It resumes execution. It's difficult to reason about.

You can write the first version for any task-returning method that could be a synchronous method. That's the case when:

  • The method does not do any work after the only task-returning method is called.
  • The return of the only task returning method matches the signature of this method.

The Curious Case of IDisposable

Now, let's look at a variation of the two methods above:

C#
public Task DoWorkAsync()
{
    using (var service = new Service())
    {
        var arg1 = ComputeArg();
        var arg2 = ComputeArg();
        return service.AwaitableMethodAsync(arg1, arg2);
    }
}
C#
public async Task DoWork2Async()
{
    using (var service = new Service())
    {
        var arg1 = ComputeArg();
        var arg2 = ComputeArg();
        await service.AwaitableMethodAsync(arg1, arg2);
    }
}

Can you spot the difference? Can you spot the bug? The introduction of a local variable that refers to an object that implements IDisposable means you must use the second version, where the compiler generates the state machine and a continuation.

I gave a hint as to the reason in the first description. The first method is synchronous. There are no continuations. The service object will be Disposed() as soon as AwaitableMethodAsync() returns. The object is disposed if the async work is completed. The object is disposed when the async work is not completed. The compiler generated finally clause will be executed before the method returns the (possibly still running) task. There is a high-probability that this idiom results in an ObjectDisposedException in some cases.

The asynchronous method generates the code so that the compiler generated finally clause executes only after the task returned from AwaitableMethodAsync() completes. The service will be Disposed only when it's done doing all its work.

Note that my explanation of when you can write the synchronous version above is accurate: because of the compiler generated finally clause, there is code that must execute after the task completes. It's just not easily visible in your source code.

Testing for this Case

This condition can be hard to catch in automated unit tests. (In fact, the error I introduced this week was not caught by unit tests in the library I was working on.) Often, we write unit tests for asynchronous methods that always return synchronously, using Task.FromResult(). These tests are fine, and verify that the fast path works correctly.

You should also write tests that verify the slow path, where a Task has not completed synchronously. It doesn't have to be measurably slow. Just sprinkle an "await Task.Yield()" statement in your mock implementation and you will force the slow path.

Yes, that bug I introduced is fixed. It's also now caught by a test.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect Bill Wagner Software LLC
United States United States
Bill Wagner is one of the world's foremost C# developers and a member of the ECMA C# Standards Committee. He is President of the Humanitarian Toolbox, has been awarded Microsoft Regional Director and .NET MVP for 10+years, and was recently appointed to the .NET Foundation Advisory Council. Wagner currently works with companies ranging from start-ups to enterprises improving the software development process and growing their software development teams.

Comments and Discussions

 
GeneralMy vote of 5 Pin
dstelow6-May-17 7:22
dstelow6-May-17 7:22 
GeneralMy vote of 5 Pin
KGustafson5-May-17 11:35
professionalKGustafson5-May-17 11:35 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.