10 rules for writing asynchronous code in C#

Series - C# in Production

Introduction

Imagine you’re a chef in a kitchen full of ingredients, some fresh, some a bit past their prime, all thanks to Microsoft’s “We never throw anything away” policy. This is what programming asynchronously in C# is like — an overwhelming mix of new and old syntax all in one big pot.

This pot can turn to be a big ball of code mud, you could end up with the coding equivalent of food poisoning: thread starvation, deadlocks, or even the dreaded application crashes. Most of these problems boil down to a lack of understanding of the underlying concepts such as state machines, the thread pool, context switching etc…

I drafted a document for my team explaining those concepts and decided to narrow it down to a simple 10 rule cook book here as those are pretty common in the industry. There’s a lot more than 10 rules, feel free to let me know if you want the rest !

From all the rules, this one is the most critical potentially crashing your whole application. async void can’t be tracked and if any exception is triggered, it will crash the whole application.

In a previous project, this caused our whole API to crash randomly on production because of an async void present in a background service.

c#

public void BadAddUserAsync(User user); //You should not do this

c#

public Task GoodAddUserAsync(User user); //Do this instead!

The below example is not optimal, wasting a thread pool thread to return something that does not need an async call.

c#

public Task<int> BadComputeDiscountAsync(User user) => Task.Run(() 
    => 0.3 * user.BaskedAmount);

You should definitely use ValueTask<T> here to avoid using an extra thread AND save a minor Task allocation.

c#

public Task<int> BetterComputeDiscountAsync(User user) 
    => new ValueTask<int>(0.3 * user.BaskedAmount);

ValueTask is relatively new and not available in every case. Task.FromResult is your default if the first one didn’t work out. It will still allocate a Task object on the heap but that’s still far better than wasting a whole thread:

c#

public Task<int> GoodComputeDiscountAsync(User user) 
    => Task.FromResult(0.3 * user.BaskedAmount);

ContinueWith is part of the old ways to deal with tasks. C# had Task long before async/await and this was the only way to deal with it before. Now, because of how the state machine work and how powerful it is in capturing context. It is generally better to use async/await instead of ContinueWith().

c#

public Task<int> BadAddUserAsync(User user)
{
    return _client.AddUser(user).ContinueWith(task =>
    {
        return ++totalUserCount;
    });
}

You should favor doing this instead:

c#

public async Task<bool> GoodAddUserAsync(User user)
{
    await _client.AddUser(user);
    return ++totalUserCount;
}

Task.WaitAsync supports passing down a cancellation token instead of only creating a task and failing it after a delay. This is useful when you want to add cancellation or timeout ability for async methods, that inherently don’t provide such capability.

The advantage of having a managed thread pool is to manage and reuse tasks on the fly and not waste resources. A Task.Run enqueue a task to the thread pool and leaves the thread to be reused for another call later on.

If you’re doing a long running background process with Task.Run, you’re effectively “stealing” a thread that was meant to be used for multiple asynchronous operations such as timer callbacks and so on.

Blocking this thread will make the Thread Pool grow unnecessarily. A good alternative is to create a Thread manually and let it live on on its own ! Here’s a quick example:

c#

public class HealthCheckService : IBackgroundService
{
    public async Task BadHandleAsync(Client client, CancellationToken token)
    {
        while(true)
        {
            await Task.Run(()=> client.CheckHealth(token));
            await Task.Delay(500);
        }
    }

    public Task GoodHandleAsync(Client client, CancellationToken token)
    {
       var t = new Thread(()=> client.CheckHealth(token))
       {
          IsBackground = true //important to keep it alive
       };
       t.Start();
       return Task.CompletedTask;
    }
}

The problem with both if not used correctly is that the caller will be blocked until the asynchronous operation is executed. This uses 2 threads for a synchronous call instead of 1 which can lead to thread starvation and can cause deadlocks. This is a common anti-pattern called Sync over Async, you can find a detailed explanation here.

Here’s how you can change that:

c#

public bool BadAddUser1(User user)
  => _client.AddAsync(user).Result; //Do not do this

public bool BadAddUser2(User user) //Do not do this
{
   var addUserTask= _client.AddAsync(user);
   addUserTask.Wait();
   return addUserTask.Result;
}

public async Task<bool> GoodAddUserAsync(User user) //Do this instead
  => await _client.AddAsync(user);

Another very common case is Timer callbacks, the default Timer class in .NET has void in it’s signature which make things tricky to use. You can use the PeriodicTimer that supports a Task instead.

c#

public class BetterTimerHealthCheckService
{
    private readonly PeriodicTimer _timer;

    public BetterTimerHealthCheckService()
    {
        _timer = new PeriodicTimer(100);
        Task.Run(DoHealthCheck);
    }

    private async Task DoHealthCheck()
    {
        while(await _timer.WaitForNextTickAsync())
            await client.CheckHealth(...);
    }
}

There are cases, especially I/O operations where passing down cancellation tokens will help you save resources and cancel what you don’t want anymore. Always spread this parameter all the way down to your functions. Most async i/o calls supports it!

c#

public class MessagePublisher
{
    public static void Publish(Action action) 
    {
        //...
    }
}

var publisher = new MessagePublisher();
publisher.Publish(async () =>
{
    await _client.PublishAsync(messages);
});

This is creating an async void call implicitely! One way around it is to define another overload accepting a Func<Task> as well to redirect async calls there instead!

c#

public class MessagePublisher
{
    public static void Publish(Func<Task> action) 
    {
        //...
    }
}

I found that this one is not known at all in the community. One thinks that disposing things do tear down everything in the most optimal way. But that might not be always the case when you’re using Stream or StreamWriter.

When you call the Dispose() for those, it will synchronously flush the buffer blocking the current thread. If your code is IO heavy, this may result in thread starvation.

c#

// this calls Dispose() under the hood
using (var stream = new StreamWriter(httpResponse))
{
   await stream.WriteAsync("Hello World");
}

An easy fix for the above is to call DisposeAsync() instead or manually call FlushAsync to do those operations asynchronously.

c#

// this calls DisposeAsync() under the hood
await using (var stream = new StreamWriter(httpResponse))
{
   await stream.WriteAsync("Hello World");
}

// this calls DisposeAsync() under the hood
using (var stream = new StreamWriter(httpResponse))
{
   await stream.WriteAsync("Hello World");
   await stream.FlushAsync();
}

⚠️ This one is debatable !

I have seen both and there’s quite some arguments on both sides. But for the majority of codebases, one should always await underlying calls.

c#

public Task<int> BadUpdateCountersAsync() 
  => UpdateCountersAsync();

You may gain a very minor performance benefit if you do the above, by skipping a state machine but I doubt that is even impactful or measurable for most use cases. But you lose a lot in term of debug-ability and exception handling.

The debugger will just hang, Exceptions will entirely skip the context. There’s some actual benefits of having a state machine as those are part of its job.

c#

public async Task<int> GoodUpdateCountersAsync() 
  => await UpdateCountersAsync();

By using those conventions, you will generally avoid bad things and bad code leaking away in your application. I tried to compile the most used ones I saw but there’s many more to discuss.

**A underrated article is Stephen Toub’s explanation of async/await **here. It is a must read if you care for that!