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
asyncandawait. 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, orTask.Runto 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), neverThread.Sleep, which blocks the thread. For something that repeats on a schedule,PeriodicTimer(.NET 6 and later) is cleaner than a loop aroundTask.Delay. - Use
await usingfor objects that implementIAsyncDisposable, such asDbContext, streams andUtf8JsonWriter, so they can release resources asynchronously. - A method that only passes a task through can return it directly without
asyncandawait, for example=> httpClient.GetStringAsync(url, ct). Don’t skip them inside ausingortryblock, 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. AValueTaskmust be awaited exactly once and never read with.Resultbefore it completes. When in doubt, returnTask. - 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 inTask.Rundoesn’t make it scale better either, because it trades one thread pool thread for another. UseTask.Runto 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 withawait 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
CancellationTokenin every async method that does I/O, and pass it to every call that accepts one. CheckingThrowIfCancellationRequested()once and then callingGetStringAsync(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.CancellationTokenSourceis 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 writeusing CancellationTokenRegistration registration = ct.Register(() => legacyClient.Abort());. A registration you never dispose keeps its callback alive for as long as the token lives.- An
OperationCanceledExceptionafter 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.ForandParallel.ForEachparallelize CPU-bound loops.Parallel.Invokeruns a few independent methods at once. Pass aParallelOptionsto setMaxDegreeOfParallelismand aCancellationToken.- PLINQ (
.AsParallel(), an extension method on anyIEnumerable) parallelizes a LINQ query. It pays off for expensive work per item, and it’s pure overhead for something liken % 2 == 0over a thousand integers. - Never pass an async lambda to
Parallel.ForEach. It takes anAction<T>, so the lambda becomesasync void, and the loop returns before any of the work has finished. UseParallel.ForEachAsyncinstead:
// 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 theParallel.ForEachoverload withlocalInitandlocalFinally. 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 noParallel.Reducemethod 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.Abortisn’t supported on .NET Core or .NET 5 and later; it throwsPlatformNotSupportedException. 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:
Interlockedhandles single numeric updates without a lock:Interlocked.Increment(ref _requestCount).lockprotects a short critical section. Lock on a private readonly object, never onthisor a type. In .NET 9 and later, use the newSystem.Threading.Locktype, which thelockstatement 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
awaitinside alock, because the compiler won’t allow it. UseSemaphoreSlimwithWaitAsyncinstead. 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();
}
}
ReaderWriterLockSlimlets many readers in at once, or one writer. Only use it when reads far outnumber writes and you’ve measured that a plainlockis a bottleneck. It doesn’t work withawait, and it must be disposed.SpinLockis for very short critical sections in code you’ve profiled. It’s a struct, so copying it (for example, storing it in areadonlyfield) silently breaks it. Most code should uselock.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.
ConcurrentDictionarycovers most shared caches, but note thatGetOrAddcan run its value factory more than once under contention, so wrap expensive values inLazy<T>.ConcurrentQueueandConcurrentStackare thread-safe FIFO and LIFO collections.ConcurrentBagis 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,FrozenDictionaryandFrozenSet(.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)
