Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
Jul 25, 2023

10 rules for writing asynchronous code in C#

Автор:
Anthony Trad
Источник:
Просмотров:
3094

Bending the Clean Architecture Principles

10 rules for writing asynchronous code in C#
Async await meme

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!

1) Do not use Async void

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.

public void BadAddUserAsync(User user); // You should not do this
public Task GoodAddUserAsync(User user); // Do this instead!

2) Use ValueTask or Task.FromResult for easy computations

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

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.

public Task<int> BestComputeDiscountAsync(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:

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

3) Use await instead of ContinueWith

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 better to use async/await instead of ContinueWith().

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

4) Task.WaitAsync over Task.Delay

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.

5) Use new Thread() instead of Task.Run for long running processes

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!

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;
    }
}

6) Do not use Task.Result or Task.Wait

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 leads to thread starvation and can cause deadlocks. This is a common anti-pattern called Sync over Async, you can find a detailed explanation here.

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.

public class TimerHealthCheckService
{
    private readonly Timer _badTimer;

    public TimerHealthCheckService()
      => _badTimer = new Timer(BadCheckHealth, 100, 200);

    // this is bad
    public void BadCheckHealth() => client.CheckHealth(...);

    // this is a bit better
    public void BetterCheckHealth() => DoHealthCheck();

    private async Task DoHealthCheck()=> await client.CheckHealth(...);
}

An alternative is to use the PeriodicTimer that supports a Task instead.

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(...);
    }
}

7) Always pass down cancellation tokens

There are cases, especially in APIs where passing down cancellation tokens will help you save a lot of unused threads when a cancellation occur. Always spread this parameter all the way down to your functions. Most Async calls supports it!

8) Do not use Action parameter alone

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!

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

9) Flush Streams before Disposing

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.

// 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.

// 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");
   stream.FlushAsync();
}

10) Do not directly return task in async functions

This one is a bit oppinionated, 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.

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.

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

Conclusion

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 document is Stephen Toub’s explanation of async/await here. It is a must read if you care for that!

Похожее
Mar 18
Author: codezone
File reading operations in C# are crucial for many applications, often requiring efficiency and optimal performance. When handling file reading tasks, employing the right strategies can significantly impact the speed and resource utilization of your application. Here are some best...
Jun 3
Author: Dayanand Thombare
Introduction Delegates are a fundamental concept in C# that allow you to treat methods as objects. They provide a way to define a type that represents a reference to a method, enabling you to encapsulate and pass around methods as...
Sep 6, 2023
Author: Kenji Elzerman
To write files with C#, you have to know how to do it. The basics aren’t rocket science. There are a few lines of code you need to know. But writing and reading files with C# is something every beginning...
Jan 14
Author: Israel Miles
Content index #1 — Wake up with the sun #2 — Learn how to cook #3 — Use time savings to invest in your life #4 — Schedule your time #5 — Advocate for yourself Remote work has found itself...
Написать сообщение
Тип
Почта
Имя
*Сообщение
RSS
Если вам понравился этот сайт и вы хотите меня поддержать, вы можете
Психология удалёнки: как не слететь с катушек
Сравнение REST и GraphQL
Выгорание эволюционирует. Что такое «тихий уход» — новый тренд среди офисных сотрудников
Почему вы никогда не должны соглашаться на собеседования с программированием
Типичные взаимные блокировки в MS SQL и способы борьбы с ними
GraphQL решает кучу проблем — рассказываем, за что мы его любим
9 главных трендов в разработке фронтенда в 2024 году
Из интровертов в менторы: как мидлы становятся сеньорами
NULL в SQL: что это такое и почему его знание необходимо каждому разработчику
Модуль, пакет, библиотека, фреймворк: разбираемся в разнице
LinkedIn: Sergey Drozdov
Boosty
Donate to support the project
GitHub account
GitHub profile