Despite all of the time that Tasks and the Task Parallel Library have been in .NET, their capabilities are still underutilized. For the record, Tasks were introduced in .NET 4, released in February 2010, nearly 12 years ago! That's a few lifetimes in the IT world. For long-time CODE Magazine readers, you may recall my Simplest Thing Possible (STP) series that ran periodically from 2012 to 2016. Past hits include three issues on Dynamic Lambdas, Promises in JavaScript, NuGet, and SignalR. I still get pinged on the Dynamic Lambda work - an oldie, but a very much relevant goodie!

I was a bit dumbfounded to realize that I never did an STP on Tasks! Better late than never! The STP series had and has one goal: to get you up and running on a .NET feature as quickly as possible so you can take it to the next level for your context and use-cases. As .NET evolves and its history grows, as well as the numerous blog posts with opinions – some good, some not, some dogmatic, some neutral – more than ever, it has become important to cut through the noise. In most cases, the official Microsoft Documentation is the best source of raw, unopinionated information.

Let's get to it!

What Is a Task?

Think of a Task as a unit of work that is to be executed sometime in the future. I've added the emphasis on future because that's what a Task is: a future. An oft-asked question in the various online forums (Stack Overflow, etc.) is whether a Task is a promise in the same way promises are implemented in JavaScript. Promises are supported in .NET through the TaskCompletionSource class, which is beyond the scope of this article.

In the meantime, consider a Task as a future unit of work that may exist on its own or in the context of TaskCompletionSource. When in the context of TaskCompletionSource, a Task participates in fulfilling a promise. A promise doesn't guarantee that the operation is successful. What's guaranteed is that a result of some kind will be returned. In most cases, it's sufficient to implement tasks as independent entities, apart from TaskCompletionSource. A good use case for TaskCompletionSource is in the API context where some kind of result, even if it's an error, must be returned before yielding control back to the caller. In that regard, TaskCompletionSource is a promise in the same way promises are implemented in JavaScript.

Tasks have been a .NET feature since version 4.0, which was released in 2010. Despite being an available feature for over a decade, it remains a somewhat under-utilized feature, giving credence to the notion that sometimes, what's old is new! The same is true with the async/await language feature introduced in C# 5 in 2012.

Sometimes, what's old is new!

Tasks are asynchronous (async). It's impossible to discuss Tasks without discussing the async/await language feature. Implemented in both C# 5 and VB.NET 11 in 2012, async and await have been around for a long time! If you're going to implement tasks, you must necessarily confront asynchronous programming, which is a very different concept than the synchronous programming of the past. Microsoft made this task much easier when it introduced some “syntactic sugar” to make things easier. In other words, the .NET compiler undertakes the heavy lifting to transform your C# or VB.NET into the necessary IL (Intermediate Language) code to support async calls.

An async call, as the name implies, means that the code making the call does not wait for the result. And yet, the associated keyword to async is await, which can be confusing because it implies that await means that the code waits, which contradicts async programming! Instead, what it means is that the calling code continues its work while it waits for (or awaits) the async code to complete.

Before async/await, you needed to implement callbacks, which meant that you had to pass a reference of the function that was to receive the async function's result. Async calls often took a bit to understand if the only world you were familiar with was synchronous calls where the code would wait, and wait, and continue to wait until either the call completed or timed-out. This often made for a very unpleasant user experience because the client, typically a user interface, wouldn't update. The UI and the whole app appeared to be frozen, and then after some time, everything magically came back to life!

async/await allows for calls to be non-blocking, meaning that the current thread isn't blocked while the task is running in another thread. Hence, as stated previously, the current thread continues its work while it waits. The async/await syntax alleviates you of the direct burden of establishing callbacks. Async programming presents you with an opportunity for better performing applications because it results in more efficient use of resources. Underneath the covers, .NET manages the spawning of a new thread in which to carry out the task, making sure that result gets back to your calling code.

The following document list contains resources that you'll want to review. (Note: Be sure to view the docs relevant to the .NET Framework version that you and your team are using!)

A Simple Async Task and Test Example

The following is a very simple example of implementing a Task in a custom method:

public async Task<HttpResponseMessage> 
    WebCall(string url = "https://www.google.com") {
        using var client = new HttpClient();
        var result = await client.GetAsync(url);
        return result;
}

The WebCall method is a very simple wrapper around the HttpClient class, which contains async methods to perform delete, get, patch, post, and put operations. HttpClient is the preferred class to interact with APIs. If you aren't familiar with the HttpClient class, the reference documents are a good source of information and can be found here: https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=net-5.0.

Whenever a method body contains an await statement, the method signature must be marked as async. The other element to note is the return type specified in the signature: Task<HttpResponseMessage>. The return statement itself is of type HttpResponseMessage. Ultimately, the HttpResponseMessage will be returned in the future via a Task. There are four simple elements to implement async programming in this first example:

  • Specify in the method signature that the return type is wrapped in a task: Task<HttpResponseMessage>.
  • Mark the method signature as async.
  • Have at least one awaitable method call in the method body.
  • Return the type specified in the task generic declaration: HttpResponseMessage.

That's all there is to it. Until use cases become more complex, you don't need to be an expert in all the details of async programming to get up and running for most use cases.

The following is a simple XUnit test that calls the WebCall method:

[Fact]
public async void WebCallASyncTest()
{
    var result = await WebCall();
    var content = await result.Content.ReadAsStringAsync();
    Assert.True(!string.IsNullOrEmpty(content));
}

Because the WebCall method is marked as async, it is awaitable. If you wish to test the async operation, the unit test itself must be async because of the requirement to use the await operator. Note that the void keyword instead of Task is used. The Task class comes in two flavors: generic and non-generic. For clarity purposes, if you wanted to replace the void keyword with Task, you can do that. Either way, the unit test executes the code under test asynchronously.

What if you wanted to run an awaitable method in such way that it blocked the current thread? In other words, can you run an async method synchronously? You can do it by directly accessing the task's result property, which itself is an anti-pattern! That is not to suggest that employing an anti-pattern is the incorrect thing to do. Employing anti-patterns is often necessary based on context.

The following code is the previous test reworked to run async code synchronously:

[Fact]
public  void WebCallSyncTestResult()
{
    var result =  WebCall().Result;
    var content = result.Content.ReadAsStringAsync().Result;
    Assert.True(!string.IsNullOrEmpty(content));
}

Wrapping Existing Non-Async Code in a Task

Tasks can be very useful in offloading existing workloads to another thread. Perhaps you have an application that requires a call to a process and you'd like the user to still be able to interact with the UI while it runs. If that code runs synchronously, the UI thread will be blocked until it gets a response back from this long running process. The following example employs the static Task.Run method to wrap a call to a legacy method:

[Fact]
public async  void TestLegacyCallWithTask() {
    var result = await Task.Run(
        () => LegacyProcess(new string[1] {"1234"})
    );
    Assert.True(result >= 1);
}

The previous example assumes a short running, non-CPU-intensive process. The Run method is a short-hand method for defining and launching a Task in one operation. The Run method causes the task to run on a thread allocated from the default thread pool.

What if it's a long running process? In that case, you'd want to use Task.Factory.StartNew(), which provides more granular control over how the Task gets created by providing access to more parameters. Figure 1 illustrates the third StartNew method signature with the additional parameters controlling how the Task is created.

Figure 1: The StartNew() method accepts additional parameters to control how the task is created.
Figure 1: The StartNew() method accepts additional parameters to control how the task is created.

Task.Run on the other hand, is a simpler, shorthand way to create and start a task in one operation.

If your legacy process is long-running or CPU-intensive, you'll want to use the approach illustrated in the following example:

var source = new CancellationTokenSource();
var result = await Task.Factory.StartNew<int>(
    () => LegacyProcess(new string[1]  {"1234"}),source.Token);

Current docs on the Run and StartNew methods may be found here:

For more information on Task creation options, and there are many, that guidance may be found here: https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcreationoptions?view=net-5.0.

Cancellation Tokens

In the previous example, take note of the cancellation token passed as a parameter. This is one of async programming's major benefits, to cancel a long running Task. If the user elects to cancel the task while it's running, that can be accomplished because the current thread is not blocked, allowing user interaction. A great tutorial on cancelling long-running tasks may be found in Brian Languas's YouTube Video: https://youtu.be/TKc5A3exKBQ.

Conclusion

Tasks and async programming are powerful tools to add to your development toolbox and this article has only scratched the surface. More complex use cases include wrapping multiple tasks together and waiting for all to complete (each running in their own thread). .NET's Task and async capabilities are a rich and interesting environment! If you want to see more of this type of content, drop a line to me at CODE Magazine and I'll keep pumping them out. With .NET 5 and VS Code, there's much to distill and demystify.