C# Async and Parallel Programming Best Practices

I’ve written about asynchronous and parallel programming before, in the seven most common async mistakes and what SynchronizationContext is. Few topics in C# produce as many subtle bugs. Deadlocks only show up on the UI thread, exceptions disappear from async void methods, and race conditions never happen on your machine and happen every day in production. Those earlier posts are long and go deep into specific topics. This one is the short version: rules you can review in a few minutes, each with a small example, updated for current .NET.

Pick the right tool first

Concurrency and parallelism are different things. Concurrency means several tasks making progress during overlapping periods of time. Parallelism means several tasks running at the same instant on different cores. Which one you need depends on what the work is waiting for:

  • I/O-bound work, such as HTTP calls, database queries or file reads, spends most of its time waiting. Use async and await. While the operation is in flight, no thread is tied up. Stephen Cleary’s There Is No Thread explains why.
  • CPU-bound work, such as image processing, number crunching or parsing a large file, needs cores. Use Parallel, PLINQ, or Task.Run to get it off a UI thread.

Async doesn’t mean multithreaded. An async method runs synchronously until its first await on something that hasn’t finished yet, and it doesn’t start a new thread on its own.

Async/await rules

Go async all the way

Blocking on async code with .Result, .Wait() or GetAwaiter().GetResult() ties up a thread while it waits. On a UI thread or in classic ASP.NET, it can also deadlock. The blocked thread holds the context that the awaited code needs in order to resume. ASP.NET Core has no synchronization context, so it won’t deadlock there, but under load blocked threads lead to thread pool starvation instead. Once a call chain goes async, keep it async up to the top:

// Deadlocks on a UI thread or in classic ASP.NET
string page = fetcher.GetPageAsync(url).Result;

// Async all the way
string page = await fetcher.GetPageAsync(url);

Don’t create an HttpClient per call

Many async examples, including the ones in older versions of this post, wrap new HttpClient() in a using block. Under load, that exhausts sockets. Inject the client through IHttpClientFactory, and pass the cancellation token all the way down:

public class PageFetcher(HttpClient httpClient)
{
    public async Task<string> GetPageAsync(string url, CancellationToken ct = default)
    {
        return await httpClient.GetStringAsync(url, ct);
    }
}

// Program.cs
builder.Services.AddHttpClient<PageFetcher>();

async void is only for event handlers

An async void method can’t be awaited, and any exception it throws goes straight to the synchronization context or the thread pool. That usually crashes the process. Return Task or Task<T> from every async method except event handlers, and wrap the body of an async event handler in a try-catch:

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    try
    {
        string page = await fetcher.GetPageAsync("https://www.example.com");
        PageText.Text = page;
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Loading the page failed");
        MessageBox.Show("The page couldn't be loaded.");
    }
}

ConfigureAwait(false) belongs in library code

By default, await resumes on the context it was called from. In a UI app that’s the UI thread, which is exactly what you want when the next line updates a control. In a library, you don’t know who will call you or how, so add .ConfigureAwait(false) to every await. The library then never needs the caller’s context, which also protects callers who block on it. In ASP.NET Core application code there’s no context to return to, so it makes no difference there. Since .NET 8, ConfigureAwaitOptions adds options such as SuppressThrowing. Stephen Toub’s ConfigureAwait FAQ covers the details.

Smaller rules

  • In async code, use await Task.Delay(delay, ct), never Thread.Sleep, which blocks the thread. For something that repeats on a schedule, PeriodicTimer (.NET 6 and later) is cleaner than a loop around Task.Delay.
  • Use await using for objects that implement IAsyncDisposable, such as DbContext, streams and Utf8JsonWriter, so they can release resources asynchronously.
  • A method that only passes a task through can return it directly without async and await, for example => httpClient.GetStringAsync(url, ct). Don’t skip them inside a using or try block, though. The resource would be disposed, or the catch block would exit, before the task finishes.
  • Use ValueTask<T> only for hot methods that usually complete synchronously, such as a cache hit. A ValueTask must be awaited exactly once and never read with .Result before it completes. When in doubt, return Task.
  • Don’t wrap async calls in Task.Run. Task.Run(() => DoSomethingAsync()) just moves the start of the method to another thread. In ASP.NET Core, wrapping synchronous work in Task.Run doesn’t make it scale better either, because it trades one thread pool thread for another. Use Task.Run to keep CPU-bound work off a UI thread. Cleary’s Task.Run etiquette post explains why.
  • To process items as they arrive, return IAsyncEnumerable<T> and consume it with await foreach.

Running async operations together

Start the tasks first, then await them together. Task.WhenAll returns an array:

public async Task<string[]> GetPagesAsync(IEnumerable<string> urls, CancellationToken ct)
{
    IEnumerable<Task<string>> downloads = urls.Select(url => fetcher.GetPageAsync(url, ct));
    return await Task.WhenAll(downloads);
}

That starts every download at once. That’s fine for five URLs, but for five thousand it will overwhelm the remote server. Parallel.ForEachAsync (.NET 6 and later) caps how many run at the same time:

var pages = new ConcurrentDictionary<string, string>();

await Parallel.ForEachAsync(urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (url, token) =>
    {
        pages[url] = await fetcher.GetPageAsync(url, token);
    });

Task.WhenAny returns the first task to finish, even if it failed, and it leaves the others running. When you only want the fastest result, cancel the rest:

public async Task<string> GetFromFastestMirrorAsync(IEnumerable<string> mirrors, CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    List<Task<string>> downloads = mirrors.Select(url => fetcher.GetPageAsync(url, cts.Token)).ToList();

    Task<string> first = await Task.WhenAny(downloads);
    cts.Cancel();         // stop the slower downloads
    return await first;   // rethrows if the first task to finish had failed
}

To handle each result as it completes, .NET 9 added Task.WhenEach, which returns an IAsyncEnumerable of the tasks in the order they finish: await foreach (Task<string> download in Task.WhenEach(downloads)).

Cancellation and timeouts

A CancellationTokenSource creates a token and signals it when you call Cancel(). Code that receives the token either passes it to the APIs it calls or checks it in its own loops. Cancellation is cooperative, so nothing stops unless the code checks the token. A few rules:

  • Accept a CancellationToken in every async method that does I/O, and pass it to every call that accepts one. Checking ThrowIfCancellationRequested() once and then calling GetStringAsync(url) without the token cancels nothing.
  • In CPU-bound loops, call ct.ThrowIfCancellationRequested() on each iteration.
  • For a timeout, link a new source to the caller’s token and call CancelAfter. The operation then stops on whichever happens first. CancellationTokenSource is disposable, so dispose of it:
public async Task<string> GetPageWithTimeoutAsync(string url, TimeSpan timeout, CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(timeout);
    return await fetcher.GetPageAsync(url, cts.Token);
}
  • task.WaitAsync(timeout, ct) (.NET 6 and later) stops waiting after the timeout, but the operation keeps running. Use it only for operations that don’t accept a token.
  • ct.Register(callback) lets you connect cancellation to an API that has its own abort method. The registration is disposable, so write using CancellationTokenRegistration registration = ct.Register(() => legacyClient.Abort());. A registration you never dispose keeps its callback alive for as long as the token lives.
  • An OperationCanceledException after the caller cancelled is the expected result, not an error. Let it propagate, or catch it and log at Debug level.

SynchronizationContext in a nutshell

SynchronizationContext answers one question: where should the code after an await run? When you await an incomplete task, the runtime captures SynchronizationContext.Current and posts the rest of the method back to it when the task completes. Different app models have different contexts:

  • WinForms, WPF and MAUI have a context tied to the single UI thread. That’s what lets you update a control right after an await.
  • Classic ASP.NET had a request context that only one thread could use at a time.
  • ASP.NET Core, console apps and worker services have no context. Continuations run on any thread pool thread.

That explains the deadlock. When code blocks the single UI thread with .Result, the continuation it’s waiting for is queued to that same blocked thread and can never run. The context’s Post method queues work asynchronously, and Send runs it synchronously, but you’ll rarely call either directly, because await does it for you. My longer post on SynchronizationContext goes into the internals.

Parallel programming

Parallel code helps only when there’s real CPU work to spread across cores. Every item costs scheduling and coordination overhead, so a small loop over cheap work often runs slower in parallel than sequentially. Measure before and after. Use the Task Parallel Library (Parallel, PLINQ, Task.Run) instead of new Thread or ThreadPool.QueueUserWorkItem. The TPL runs on the same thread pool, but it handles partitioning, exceptions and cancellation for you.

  • Parallel.For and Parallel.ForEach parallelize CPU-bound loops. Parallel.Invoke runs a few independent methods at once. Pass a ParallelOptions to set MaxDegreeOfParallelism and a CancellationToken.
  • PLINQ (.AsParallel(), an extension method on any IEnumerable) parallelizes a LINQ query. It pays off for expensive work per item, and it’s pure overhead for something like n % 2 == 0 over a thousand integers.
  • Never pass an async lambda to Parallel.ForEach. It takes an Action<T>, so the lambda becomes async void, and the loop returns before any of the work has finished. Use Parallel.ForEachAsync instead:
// Bug: each lambda is async void, so ForEach returns before any download finishes
Parallel.ForEach(urls, async url => await fetcher.GetPageAsync(url));
  • To combine results, let PLINQ do it (orders.AsParallel().Sum(o => o.TotalCents)), or use the Parallel.ForEach overload with localInit and localFinally. That overload gives each thread its own subtotal and merges them once at the end, instead of taking a lock on every item. (There’s no Parallel.Reduce method in .NET.)
  • When one task needs another’s output, await the first before starting the second, or build a pipeline with TPL Dataflow or Channels (see below).
  • Thread.Abort isn’t supported on .NET Core or .NET 5 and later; it throws PlatformNotSupportedException. Stop work by cancelling a token and letting the code clean up after itself.

Shared state, locks and thread-safe collections

A race condition happens when two threads use the same data, at least one of them writes, and the result depends on timing. Even count++ is three steps (read, add, write), and two threads can interleave them. The best fix is not to share mutable state at all. Give each task its own data and combine the results at the end. When you can’t avoid sharing, use the simplest tool that works:

  • Interlocked handles single numeric updates without a lock: Interlocked.Increment(ref _requestCount).
  • lock protects a short critical section. Lock on a private readonly object, never on this or a type. In .NET 9 and later, use the new System.Threading.Lock type, which the lock statement recognizes and which is faster:
private readonly Lock _gate = new();   // before .NET 9: private readonly object _gate = new();
private decimal _balance;

public void Deposit(decimal amount)
{
    lock (_gate)
    {
        _balance += amount;
    }
}
  • You can’t await inside a lock, because the compiler won’t allow it. Use SemaphoreSlim with WaitAsync instead. With a count of 1 it works as an async lock, and with a count of N it limits how many callers run a section at the same time:
private readonly SemaphoreSlim _refreshGate = new(1, 1);

public async Task RefreshTokenAsync(CancellationToken ct)
{
    await _refreshGate.WaitAsync(ct);
    try
    {
        await RenewAsync(ct);
    }
    finally
    {
        _refreshGate.Release();
    }
}
  • ReaderWriterLockSlim lets many readers in at once, or one writer. Only use it when reads far outnumber writes and you’ve measured that a plain lock is a bottleneck. It doesn’t work with await, and it must be disposed.
  • SpinLock is for very short critical sections in code you’ve profiled. It’s a struct, so copying it (for example, storing it in a readonly field) silently breaks it. Most code should use lock. Barrier, which makes several threads wait for each other at the end of each phase of an algorithm, is similarly specialized.
  • Concurrent collections handle the locking for you. ConcurrentDictionary covers most shared caches, but note that GetOrAdd can run its value factory more than once under contention, so wrap expensive values in Lazy<T>. ConcurrentQueue and ConcurrentStack are thread-safe FIFO and LIFO collections. ConcurrentBag is optimized for the case where the same threads add and take items, and it’s slower than a queue when they don’t.
  • Immutable collections (ImmutableList, ImmutableDictionary) return a new copy with every change, so readers never see a half-finished update. For lookup data that never changes after startup, FrozenDictionary and FrozenSet (.NET 8 and later) are faster still.

A deadlock happens when two threads each hold something the other needs. To avoid them, keep locks short, always take multiple locks in the same order, never call unknown code (events, callbacks) while holding a lock, and never block on async code.

Producer/consumer pipelines

BlockingCollection<T> blocks threads while it waits. For new code, System.Threading.Channels does the same job asynchronously. A bounded channel also applies back-pressure: when consumers fall behind, producers wait instead of filling memory:

Channel<Order> channel = Channel.CreateBounded<Order>(capacity: 100);

// Producer
await channel.Writer.WriteAsync(order, ct);

// Consumer
await foreach (Order order in channel.Reader.ReadAllAsync(ct))
{
    await ProcessAsync(order, ct);
}

For multi-stage pipelines with branching, batching or a separate degree of parallelism for each stage, TPL Dataflow (System.Threading.Tasks.Dataflow) builds them out of linked blocks.

Resources and further reading

Asynchronous programming scenarios | C# | Microsoft Learn

Async/Await: Best Practices in Asynchronous Programming by Stephen Cleary

Async guidance for ASP.NET Core by David Fowler

ConfigureAwait FAQ by Stephen Toub

Parallel programming in .NET | Microsoft Learn

System.Threading.Channels | Microsoft Learn

What is SynchronizationContext? (hamidmosalla.com)

Top 7 Common Async Mistakes (hamidmosalla.com)

Stephen Cleary’s blog

Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

Leave a Reply

Your email address will not be published. Required fields are marked *