Async and concurrency
C# has language-level support for asynchronous programming through async/await, parallel programming through the Task Parallel Library, and conventional thread-based concurrency through System.Threading. The conventional unit of asynchronous work is Task (for void-returning) or Task<T> (for value-returning); the conventional pattern is to mark a method async and await other tasks within it, letting the compiler synthesise the underlying state machine. The mechanism, introduced in C# 5 and refined across subsequent revisions, is one of the most distinctive features of the language and has been extensively imitated.
This page covers the principal concurrency primitives — async/await and tasks; synchronisation through lock and Monitor; atomics; cancellation; the thread pool; parallel algorithms — and the conventions for using each.
async/await and Task<T>
A method declared async may use await expressions to await the completion of asynchronous operations:
public async Task<string> FetchAsync(string url) {
using var client = new HttpClient();
HttpResponseMessage response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
string result = await FetchAsync("https://example.com");
The construct admits writing asynchronous code that resembles synchronous code: the await suspends the method until the awaited task completes; the rest of the method runs as a continuation. The compiler synthesises a state machine — a class that holds the local state of the method and resumes execution at each await point.
The state machine
The principal effect of async:
- The method body is rewritten into a state machine.
- Each
await exprbecomes “ifexpris not yet complete, register a continuation and return; otherwise, continue”. - The method’s return value is a
Task(orTask<T>) that the caller can await.
The mechanism is cooperative: the method runs on the calling thread until it awaits an incomplete task, at which point the thread is freed and the continuation is scheduled to run when the task completes (typically on a thread-pool thread).
Return types
An async method may return:
| Return type | Use |
|---|---|
Task | Asynchronous void method. |
Task<T> | Asynchronous method returning T. |
ValueTask / ValueTask<T> | Asynchronous method whose synchronous completion is the common case. |
IAsyncEnumerable<T> | Asynchronous iterator. |
void | Event handler only; no awaiting; exceptions become process-level. |
The conventional choice:
Task<T>for general-purpose async methods.ValueTask<T>for hot paths that often complete synchronously (cache hits, fast paths).IAsyncEnumerable<T>for asynchronous streams.async voidonly for event handlers; otherwise preferasync Task.
Task and Task<T>
A Task represents an asynchronous operation. Tasks may be created in several ways:
// From an async method:
Task<string> t1 = FetchAsync(url);
// From Task.Run (queued to the thread pool):
Task<int> t2 = Task.Run(() => Compute());
// From an existing value (already complete):
Task<int> t3 = Task.FromResult(42);
// From an existing exception (already faulted):
Task<int> t4 = Task.FromException<int>(new InvalidOperationException());
The principal task operations:
| Operation | Effect |
|---|---|
await task | Suspend until the task completes; return the result or rethrow the exception. |
task.Result | Block until completion; return the result. (Avoid in async code; deadlock risk.) |
task.Wait() | Block until completion. |
task.ContinueWith(fn) | Register a continuation. (Lower-level; await is preferred.) |
Task.WhenAll(tasks) | Returns a task that completes when all tasks complete. |
Task.WhenAny(tasks) | Returns a task that completes when any task completes. |
Task.Delay(timespan) | Creates a task that completes after the timespan. |
// Run multiple tasks in parallel and await all:
var tasks = urls.Select(FetchAsync);
string[] results = await Task.WhenAll(tasks);
// Race tasks, take the first to complete:
Task<int> first = await Task.WhenAny(t1, t2, t3);
int result = await first;
// Timeout pattern:
Task<int> work = SomeWorkAsync();
Task timeout = Task.Delay(TimeSpan.FromSeconds(5));
if (await Task.WhenAny(work, timeout) == work) {
int result = await work;
} else {
throw new TimeoutException();
}
Task.Run
Task.Run queues a piece of synchronous work to the thread pool and returns a task representing its completion:
Task<int> t = Task.Run(() => {
// CPU-bound work
return Compute();
});
int result = await t;
The conventional uses:
- Wrapping CPU-bound work to admit
awaitfrom an async method. - Off-loading blocking calls from a UI thread.
- Parallelising independent computations.
The discipline:
- Do not use
Task.Runto wrap already-asynchronous methods; the wrapping is unnecessary and adds overhead. - Do not use
Task.Runin library code unless the work is genuinely CPU-bound; library callers should choose where to off-load.
ValueTask<T>
ValueTask<T> is a value-typed alternative to Task<T>, designed for cases where the operation often completes synchronously:
public async ValueTask<int> GetCachedOrFetchAsync(int key) {
if (cache.TryGetValue(key, out var value)) {
return value; // synchronous: no allocation
}
return await FetchAsync(key);
}
The mechanism avoids allocating a Task<T> for the common synchronous-completion case. The trade-offs:
ValueTask<T>is more restrictive: it may be awaited only once; storing it in a field or array requires care.ValueTask<T>is appropriate only when the synchronous case is likely; otherwiseTask<T>is simpler and equally fast.
The conventional discipline is to use Task<T> by default and ValueTask<T> in measured hot paths.
Cancellation: CancellationToken
Cancellation in C# is cooperative: the consumer creates a CancellationTokenSource and passes its Token to async methods; methods periodically check the token and throw OperationCanceledException if cancellation is requested:
public async Task<string> FetchAsync(string url, CancellationToken cancellationToken) {
using var client = new HttpClient();
HttpResponseMessage response = await client.GetAsync(url, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
// Caller:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try {
string result = await FetchAsync(url, cts.Token);
} catch (OperationCanceledException) {
// cancelled, perhaps due to timeout
}
The conventions:
- Public async methods take a
CancellationTokenas the last parameter (often defaulted todefault). - Library methods pass the token down to the lowest-level operations they call.
- Long-running loops periodically call
cancellationToken.ThrowIfCancellationRequested().
The CancellationTokenSource admits cancellation by user action, by timeout, or by a parent cancellation source:
var cts1 = new CancellationTokenSource();
var cts2 = new CancellationTokenSource();
var linked = CancellationTokenSource.CreateLinkedTokenSource(cts1.Token, cts2.Token);
// linked.Token is cancelled when either cts1 or cts2 is cancelled.
IAsyncEnumerable<T> and await foreach
C# 8 introduced asynchronous streams:
public async IAsyncEnumerable<string> ReadLinesAsync(
string path,
[EnumeratorCancellation] CancellationToken cancellationToken = default
) {
using var reader = new StreamReader(path);
while (await reader.ReadLineAsync(cancellationToken) is { } line) {
yield return line;
}
}
await foreach (var line in ReadLinesAsync("data.txt").WithCancellation(token)) {
ProcessLine(line);
}
The producer combines yield return with await; the consumer combines await with foreach. The mechanism is the conventional way to express asynchronous streams (paginated APIs, large file reads, network responses).
Synchronisation: lock, Monitor, SemaphoreSlim
For shared mutable state across threads, C# provides several synchronisation primitives.
lock
The lock statement is the conventional mutual-exclusion primitive:
private readonly object syncRoot = new object();
private int counter = 0;
public void Increment() {
lock (syncRoot) {
counter++;
}
}
lock (obj) acquires the monitor on obj for the duration of the block. The compiler emits a try/finally that releases the monitor on every exit path, including exceptions.
The conventional discipline:
- Lock on a private object (not on
thisor ontypeof(T)); locking on a publicly-visible object admits external interference. - Hold locks briefly; never call user code (or any code that may itself lock) while holding a lock.
- Acquire multiple locks in a fixed order to prevent deadlock.
Monitor
Monitor.Enter/Monitor.Exit are the underlying primitives that lock compiles to:
Monitor.Enter(syncRoot);
try {
counter++;
} finally {
Monitor.Exit(syncRoot);
}
The lower-level form is rarely used directly; Monitor.TryEnter (with a timeout) is occasionally useful for non-blocking acquisition.
SemaphoreSlim
SemaphoreSlim is a counting semaphore with async support:
private static readonly SemaphoreSlim throttle = new SemaphoreSlim(initialCount: 5);
public async Task<string> FetchAsync(string url) {
await throttle.WaitAsync();
try {
return await DoFetch(url);
} finally {
throttle.Release();
}
}
The pattern admits limiting concurrent operations (the example throttles to five concurrent fetches).
Mutex
Mutex is a process-wide mutex:
using var mutex = new Mutex(initiallyOwned: false, name: "Global\\MyApp");
if (!mutex.WaitOne(TimeSpan.FromSeconds(5))) {
// another instance is already running
return;
}
try {
// ...
} finally {
mutex.ReleaseMutex();
}
The principal use is preventing multiple instances of an application from running simultaneously.
Atomics: <atomic> and Interlocked
For single-variable atomic operations, the Interlocked class provides:
private long counter = 0;
public void Increment() {
Interlocked.Increment(ref counter);
}
public long Read() {
return Interlocked.Read(ref counter);
}
public bool TrySetState(int expected, int newState) {
return Interlocked.CompareExchange(ref state, newState, expected) == expected;
}
The methods are atomic at the hardware level; they are substantially faster than lock-based protection for individual-value updates.
For more elaborate atomic types, System.Threading.Volatile and the volatile field modifier admit the relaxed-memory-ordering operations.
Concurrent collections
System.Collections.Concurrent provides thread-safe collections:
| Type | Purpose |
|---|---|
ConcurrentDictionary<TKey, TValue> | Thread-safe dictionary. |
ConcurrentQueue<T> | Thread-safe FIFO. |
ConcurrentStack<T> | Thread-safe LIFO. |
ConcurrentBag<T> | Thread-safe unordered collection. |
BlockingCollection<T> | Bounded, blocking producer-consumer queue. |
var counts = new ConcurrentDictionary<string, int>();
counts.AddOrUpdate(
key: "request",
addValue: 1,
updateValueFactory: (_, current) => current + 1
);
The conventional uses are producer-consumer patterns and shared caches.
Parallel.For, Parallel.ForEach, PLINQ
The Parallel class admits parallel loops:
Parallel.For(0, 1000, i => {
Compute(i);
});
Parallel.ForEach(items, item => {
Process(item);
});
The work is partitioned across the thread pool. The principal use is CPU-bound work that can be parallelised.
PLINQ is the parallel variant of LINQ:
var results = items.AsParallel()
.Where(IsValid)
.Select(Transform)
.ToList();
The .AsParallel() call admits the use of parallel threads in the LINQ pipeline. The trade-off is non-determinism in element order and the overhead of thread coordination; for small inputs, parallel LINQ is slower than sequential LINQ.
Thread-local storage
ThreadLocal<T> admits per-thread storage:
private static readonly ThreadLocal<Random> rng = new(() => new Random());
public int NextRandom() {
return rng.Value!.Next();
}
For most cases, [ThreadStatic]-marked static fields suffice:
[ThreadStatic]
private static int callCount;
public void Track() => callCount++;
The mechanism admits per-thread state for caches, error states, and similar data that should not be shared across threads.
The thread pool
The .NET thread pool manages a pool of worker threads. Task.Run, Task.Delay continuations, async-method continuations, and many other asynchronous operations execute on thread-pool threads.
The conventional discipline:
- Do not block thread-pool threads (no
Thread.Sleep,Wait(),.Result); blocking exhausts the pool. - For long-running operations, use
Task.Factory.StartNewwithTaskCreationOptions.LongRunning, which creates a dedicated thread. - For UI work (in WPF, WinForms, MAUI), the synchronisation context returns continuations to the UI thread.
Common patterns and pitfalls
Configure await
await SomeAsync().ConfigureAwait(false);
ConfigureAwait(false) indicates that the continuation does not need to run on the original synchronisation context (typically the UI thread). The conventional use:
- Library code uses
ConfigureAwait(false)everywhere to avoid contention with caller’s contexts. - Application code (especially UI code) typically does not need it because the continuations conventionally do need the UI thread.
In .NET 8 and later, the convention has shifted: ConfigureAwait(false) is less universally recommended in library code, but the principle remains for performance-sensitive paths.
Async all the way
A method that calls async code should itself be async:
// Correct:
public async Task ProcessAsync() {
var data = await FetchAsync();
Process(data);
}
// Incorrect (deadlock risk):
public void ProcessSync() {
var data = FetchAsync().Result; // blocks; deadlocks in UI threads
Process(data);
}
The conventional rule: async propagates upward through the call stack. Wait() and .Result are appropriate only at the very top (the program’s entry point or in test code).
Cancellation tokens are passed, not stored
public class MyService {
public async Task DoAsync(CancellationToken token) {
// pass token to all async calls
}
}
A service should not store a CancellationToken as a field; it should accept one per operation. The exception is for long-running services (background services, hosted services) where the cancellation is service-lifetime.
Test for cancellation cooperatively
public async Task Process(IEnumerable<Item> items, CancellationToken token) {
foreach (var item in items) {
token.ThrowIfCancellationRequested();
await ProcessItemAsync(item, token);
}
}
The pattern is the conventional way to make a long-running loop cancellable.
A note on choosing concurrency primitives
The conventional progression for new concurrent code:
- Avoid concurrency where possible. Single-threaded code is simpler.
- Prefer task-based abstractions —
async/await,Task.WhenAll, parallel LINQ — over manual thread management. - Use thread-safe collections for shared state.
- Use
lockfor non-concurrent shared state. Brief, well-understood. - Use
Interlockedfor atomic single-variable operations. - Use lock-free structures only where measurement justifies them.
The combination — async/await, Task<T>, lock, CancellationToken, the concurrent collections, and the parallel facilities — covers the substantial majority of concurrency needs in C#. The conventional discipline of marking async methods async, propagating cancellation tokens, and using using for resources produces code that is correct, readable, and reasonably performant.