Enforcing asynchronous code good practices using a Roslyn analyzer

  • Gérald Barré

In a previous post I wrote about one rule of Meziantou.Analyzer to help using CancellationToken correctly. Meziantou.Analyzer is an open-source Roslyn analyzer I wrote to enforce some good practices in C# in terms of design, usage, security, performance, and style.

In this post, we'll explore the rules that help when writing asynchronous code in C#.

#How to install the Roslyn analyzer

The recommended way to use the analyzer is to add the Meziantou.Analyzer NuGet package to your project:

<Project>

  <ItemGroup>
    <PackageReference Include="Meziantou.Analyzer" Version="1.0.508">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

</Project>

#MA0004 - Use ConfigureAwait when awaiting a task

You should use ConfigureAwait(false) except when you need to use the current SynchronizationContext, such as in a WinForm, WPF, or ASP.NET context.

If you want to know more about ConfigureAwait, you should Stephen Toub's post: ConfigureAwait FAQ

async Task Sample()
{
    await Task.Delay(1000); // 👈 MA0004
    await Task.Delay(1000).ConfigureAwait(false); // ok
    await Task.Delay(1000).ConfigureAwait(true); // ok

    await foreach (var item in AsyncEnumerable()) { } // 👈 MA0004
    await foreach (var item in AsyncEnumerable().ConfigureAwait(false)) { } // ok

    await foreach (var item in AsyncEnumerable().WithCancellation(token)) { } // 👈 MA0004
    await foreach (var item in AsyncEnumerable().WithCancellation(token).ConfigureAwait(false)) { } // ok

    await using var disposable = new AsyncDisposable(); // 👈 MA0004
    await using var disposable = new AsyncDisposable().ConfigureAwait(false); // ok

    await using (var disposable = new AsyncDisposable()) { } // 👈 MA0004
    await using (var disposable = new AsyncDisposable().ConfigureAwait(false)) { } // ok
}

The analyzer is automatically disabled in a WPF, WinForms, or ASP.NET Core context. Indeed in WPF, WinForms, or Blazor, you may want to continue the execution on the current SynchronizationContext, this means using ConfigureAwait(true). In ASP.NET Core MVC, there is no SynchronizationContext, so using ConfigureAwait(false) is useless. Thus, to avoid useless code, the rule is disabled when it detects one of these contexts.

using System.Windows.Controls;

public class MyControl : Control // WPF Control
{
    public async Task Sample()
    {
        await Task.Delay(1000); // ok in a WPF context
        await Task.Delay(1000).ConfigureAwait(false); // ok
        await Task.Delay(1000); // 👈 MA0004 because there is a previous await that uses ConfigureAwait(false)
    }
}

#MA0022 - Return Task.FromResult instead of returning null

You should not return null task as awaiting them would throw a NullReferenceException. It may occur if you remove the async keyword without adapting the return value. The analyzer prevents you from doing this mistake.

await Sample(); // NullReferenceException as Sample() returns null.

Task<object> Sample()
{
    return null; // 👈 MA0022
}

async Task<object> Sample()
{
    return null; // ok as the method is async, so the returned task is not null
}

Task<object> Sample()
{
    return Task.FromResult(null); // ok
}

#MA0040 - Flow the cancellation token when available

When possible, you should use a CancellationToken to allow call sites to cancel the current operation. I've already written a complete post about this rule here, so I won't show all the supported cases.

public void MyMethod(CancellationToken cancellationToken = default) { }

public void Sample(CancellationToken cancellationToken)
{
    MyMethod(); // 👈 MA0040: Missing cancellation token (cancellationToken)
    MyMethod(cancellationToken); // ok
}
public void MyMethod(CancellationToken cancellationToken = default) { }

public void Sample(HttpContext httpContext)
{
    MyMethod(); // 👈 MA0040: Missing cancellation token (httpContext.RequestAborted)
    MyMethod(cancellationToken); // ok
}

#MA0079 - Flow the cancellation token in await foreach using WithCancellation

When using async foreach you should provide a CancellationToken using WithCancellation(cancellationToken).

public async Task MyMethod(IAsyncEnumerable<int> enumerable, CancellationToken cancellationToken)
{
    await foreach(var item in enumerable) // 👈 MA0079: Flow CancellationToken using WithCancellation(cancellationToken)
    {
    }

    await foreach(var item in enumerable.WithCancellation(cancellationToken)) // ok
    {
    }
}

#MA0100 - Await tasks before disposing resources

You should await a task before exiting a using block. Otherwise the task may use disposed resources and crash.

Task Demo1()
{
    using var scope = new Disposable();
    return Task.Delay(1); // MA0100, you must await the task before disposing the scope
}

#MA0042 - Do not use blocking calls in an async context

In an async method, you should not use any blocking method such as Task.Wait, Task.Result, or Thread.Sleep().

async Task Sample()
{
    Task<string> task;

    task.Wait(); // 👈 MA0042, use await task
    task.GetAwaiter().GetResult(); // 👈 MA0042, use await task
    _ = task.Result; // 👈 MA0042, use await task

    Thread.Sleep(1000); // 👈 MA0042, use await Task.Delay(1000)
}

This analyzer also detects when you use synchronous methods when equivalent asynchronous methods are available. The analyzer search for methods having the following properties:

  • It should have the same name or have the Async suffix
  • It should return a Task or a ValueTask
  • It should have the same parameters and optionally an additional CancellationToken parameter
async Task Sample()
{
    File.WriteAllText("author.txt", "meziantou"); // 👈 MA0042, use await File.WriteAllTextAsync
    Wait(); // 👈 MA0042, use await WaitAsync()
}

void Wait() { }
Task WaitAsync(CancellationToken cancellationToken) { }

#MA0032 / MA0045 / MA0080 - Change the method to be async

These analyzers detect the same cases as the previous rules (MA0040/MA0042/MA0079) except it doesn't require the containing method to return a Task nor to have an available CancellationToken. This means these rules report locations when you need to change the signature of the containing method to apply the fix. These rules can be useful when you want to migrate your codebase to use asynchronous code.

These rules are disabled by default because they can be very noisy and report lots of false positives. First, you need to enable them by creating or editing the .editorconfig file:

dotnet_diagnostic.MA0032.severity = suggestion
dotnet_diagnostic.MA0045.severity = suggestion
dotnet_diagnostic.MA0080.severity = suggestion
public void Sample()
{
    // 👇 MA0045, use Task.Delay(1).
    // It requires to change the signature of the containing method to be async
    Thread.Sleep(1);

    // 👇 MA0032, use a CancellationToken but there is no token available in the current context.
    // It requires to change the signature of containing method accept a CancellationToken
    MyMethod();

    // 👇 MA0080, use a CancellationToken using WithCancellation() but there is no token available in the current context.
    // It requires to change the signature of containing method accept a CancellationToken
    await foreach(var item in enumerable) { }
}

public void MyMethod(CancellationToken cancellationToken = default) { }

#Bug / Feature requests

If you find a bug or think that a rule can be improved, feel free to open an issue on the GitHub repository: https://github.com/meziantou/Meziantou.Analyzer

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee