With C# 8 on our doorstep, I figure it is a good time to reflect on recent additions to the language that have come before. There are some great improvements you may have missed, some that I really enjoy using, and some I consider have reached canonical usage status that I think are all worth some reflection.
Multithreaded programming has always been a difficult thing to get your head around, and there are many pitfalls easily stumbled into. To help combat this, Microsoft gave us async/await in C#.
Async/Await is a language feature that has been around since Visual Studio 2012 and C# 5 and hides a bunch of the boilerplate state machine code required to safely park a logical thread of execution while it waits for some work to complete or to respond from another thread, IO, or network device. This allows code to be more logically procedural and linear, therefore easier to read and comprehend.
Since it first came out there has been a raft of improvements across different versions of C#.
The basics
The foundation of how it all works rests on the shoulders of a Library, the Task Parallel Library, or TPL. Tasks have been around since 2010 and were part of the .Net 4 Framework. Similar to what Promises provide in javascript, this library allowed a logical chain of execution across waiting for blocking or longrunning execution while releasing the UI thread from being blocked. This was the introduction of TaskFactory
and Task
, in a fairly similar form to what we have today. This was a huge improvement from the days of callback chaining because it reduced heavy nesting of lambdas into more of a linear pipeline and a clear place for error handling to take place.
When C# 5 introduced async/await as a first-class language feature, it was able to leverage the library and extend on it to give us more readable code. It even handled Exceptions by throwing them in the place where await
appeared, making try/catch
blocks useful in asynchronous code.
What does async/await look like in C# 5?
// A classic synchronous method
public string MakeAWebRequest(Uri uri)
{
// WebClient has synchronous methods, but it is recommended to use HttpClient for newer apps
var client = new WebClient();
return client.DownloadString(uri);
}
// It's async younger brother
public async Task<string> MakeAWebRequestAsync(Uri uri)
{
// I use WebClient again for better comparing. Use HttpClient!
var client = new WebClient();
return await client.DownloadStringAsync(uri);
}
There are two distinctive features in this comparison. The return type is wrapped in a Task<T>
, and there is a keyword async
on the method signature with await
beside method calls that return Task<T>
results. Otherwise, the linear execution flow is largely unchanged.
You can easily use the return type without the keywords. In this case, the code works and operates as normal, passing object references around without any async state. The result object captures the state required for the caller to do the asynchronous work in the future, or respond to its completion. However, if you do use async
and await
, then you should always return either Task
(where usually returns void) or Task<T>
(where usually returns T). (In my opinion, there are no reasons left to ever do async without Task - there used to be but not anymore.)
Now we have that out of the way, let's move forward to C# 6 and beyond!
Async and Exceptions
Exception handling was a big part of this feature on day one. You could simply wrap your async calls in a try{}catch{}
and it would work as you would expect it to. The task you are awaiting throws an Exception, your catch triggers.
But initially, this did not work inside the catch or finally blocks in any expected way. In fact, it caused a compiler error. In C# 6 await in catch
/finally
blocks were given proper compiler support to do the right thing.
Using the example from The new language feature docs directly:
Resource res = null;
try
{
res = await Resource.OpenAsync(…); // You could do this.
…
}
catch(ResourceException e)
{
await Resource.LogAsync(res, e); // Now you can do this …
}
finally
{
if (res != null) await res.CloseAsync(); // … and this.
}
Async and Console Apps
The language version was C# 7. We had async everywhere, and the NetStandard
s and Frameworks were full of async API calls and interfaces. It was a contagious thing, and you really had to jump through hoops to try to call a red function from a blue one.
And then there was main. That pesky little entry point into your application. The one the compiler generates for you that kicks off all of the application execution. The one that had to call into your top-most async method to RunAsync
. And it had to be public static void Main(string[] args)
or public static int Main()
. That is not an async method.
Luckily, with the first ever minor language update, C# 7.1, we were given the mighty and powerful async main!
// You can finaly use Task<int>!
public static async Task<int> Main()
{
...
}
// Also available in no return value flavour!
public static async Task Main()
{
...
}
Async and Tests
We have a tonne of async methods in our system. But we should also be testing that code. And our testing frameworks were synchronous.
Luckily our testing frameworks have finally caught up, and with XUnit we can write tests that return Task and are async, and we also have the
IAsyncLifetime` interface.
`
// This is what we had to do to test async
public class MyTestCase
{
private readonly MyClass _systemUnderTest;
public MyTestCase()
{
_systemUnderTest = MyClass();
Task.Run(async () => await _systemUnderTest.Init()).GetAwaiter().GetResult();
}
public void CanRunSuccessfully()
{
var result = Task.Run(async () => await _systemUnderTest.Run()).GetAwaiter().GetResult();
Assert.True(result);
}
}
`
Using Task.Run
and GetAwaiter
or other synchronising methods can be very error-prone, and prone to deadlocks. Avoiding these is the best approach always (though still not always avoidable).
`
The best way to tet with async
public class MyTestCase : IAsyncLifetime
{
private readonly MyClass _systemUnderTest;
public MyTestCase()
{
_systemUnderTest = MyClass();
}
public async Task InitializeAsync()
{
await _systemUnderTest.Init();
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
public async Task CanRunSuccessfully()
{
var result = await _systemUnderTest.Run();
Assert.True(result);
}
}
`
I'm not sure if other test runners are able to handle async as well as XUnit does, so I always just use XUnit.
ValueTask
The original implementation of the async/await language feature was strongly tied to the Task
and `Task types.
In C# 7, the language feature was enhanced, similar to other features, to use a pattern based on method signatures (like the Add for the initialiser syntax). Specifically, the GetAwaiter
method must be available on the type used with the await
keyword.
Along with this change was the introduction of a new type to leverage this pattern, ValueTask
. A ValueTask
is a value type (struct) that will be stack-allocated and copied by value. If your method uses caching, and most of the time returns a simple value instead of an awaited execution, the ValueTask
may be more efficient than the Task
type.
This is because the overheads of Heap-allocation of the reference type Task
can have an impact on performance. If you detect this as an issue, you can use the new ValueTask
instead. This will be a stack-allocated value type containing the response value and copied around.
Guidance for this: if you mostly return a value, but occasionally call an actual asynchronous IO execution, ValueTask
will probably add value (e.g. heavy result caching). If you actually await most of the time, Task
should be fine. As usual, measure and test before making the change arbitrarily.
Conclusion
Async programming is becoming the canonical way of building apps with IO in most languages. And in practice, most of the apps I write are IO-bound apps. C# and dotnet make this simple with async/await
and the language keeps improving our experience using this successfully. This is a must-use feature that is unavoidable, but knowing the limitations and extensions available to use it well is still very important to do. Use it, but make sure you know enough about how it works to use it well.
Top comments (0)