<< Home

Chapter 15: Asynchrony and Concurrency

C# provides first class support for asynchronous and parallel programming through the Task Parallel Library and language features that integrate with .NET scheduling and synchronization services. This chapter explains how to use Task, the thread pool, async and await, cancellation, synchronization primitives, parallel loops, and how to avoid common pitfalls when building responsive and scalable applications.

Tasks and the ThreadPool

A Task represents an operation that may complete in the future; it can be awaited, composed, and scheduled efficiently on the .NET ThreadPool. The thread pool manages a set of worker threads and uses work stealing queues to balance load which helps you avoid creating raw threads in most cases.

Creating and running Tasks

You can create tasks from delegates, use Task.Run to offload CPU bound work to the pool, or return tasks directly from asynchronous APIs. For naturally asynchronous I/O prefer methods that already return Task or ValueTask and avoid wrapping with Task.Run unnecessarily.

// CPU bound work moved to the pool
Task<int> primeCount = Task.Run(() => CountPrimes(2, 1_000_000));

// Composing tasks
Task both = Task.WhenAll(primeCount, Task.Delay(100));
int result = await primeCount;

Task states and continuations

A task is created, scheduled, then completes in a RanToCompletion, Faulted, or Canceled state. Continuations attach follow up work; prefer await since it composes continuations safely and clearly. For performance sensitive hot paths consider ValueTask when results are often synchronous.

// Continuations are created for you by await
try
{
  int n = await primeCount;
  Console.WriteLine(n);
}
catch (Exception ex)
{
  Console.WriteLine(ex.Message);
}
💡 Use Environment.ProcessorCount to estimate parallelism for CPU bound work; always test under production like load because scheduling and contention vary by environment.

async and await

The async modifier enables methods to use await which asynchronously waits for a task to complete and resumes without blocking a thread. The compiler transforms the method into a state machine that coordinates continuations with the current SynchronizationContext or with the thread pool when there is none.

Signatures and return types

Use Task for asynchronous methods that do not produce a value, Task<T> for those that return a value, and ValueTask/ValueTask<T> to reduce allocations when completion is often synchronous. Event handlers can use async void because they integrate with UI event pipelines; avoid async void elsewhere.

public async Task<string> FetchAsync(HttpClient http, string url)
{
  using HttpResponseMessage res = await http.GetAsync(url);
  return await res.Content.ReadAsStringAsync();
}

Context capture and ConfigureAwait

By default await captures the current context then continues there. In library code or server code where a specific context is not required you can append .ConfigureAwait(false) to skip capture which reduces overhead and avoids reentry issues. Keep context capture for UI refreshes or when a synchronization context is required.

string body = await http.GetStringAsync(url).ConfigureAwait(false);
⚠️ Do not mix blocking waits such as Task.Wait() or Result with await on the same call chain; this pattern can deadlock in contexts that require marshaling back to a single thread.

CancellationToken and timeouts

Cancellation is cooperative; you pass a CancellationToken to operations that may be canceled and they observe the token periodically. Timeouts are a special case of cancellation which can be implemented with timers or with methods that accept a timeout value in .NET libraries.

Creating and linking tokens

Use CancellationTokenSource to create tokens, call Cancel to signal, and dispose sources when finished. You can link tokens so that canceling any of them propagates to the linked token.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, externalToken);
await DoWorkAsync(linked.Token);

Observing cancellation

Asynchronous methods should accept a CancellationToken, pass it to I/O operations, and check token.ThrowIfCancellationRequested() at sensible points. When canceled, throw OperationCanceledException with the token so callers can distinguish cancellation from faults.

public async Task ProcessAsync(Stream s, CancellationToken token)
{
  byte[] buffer = new byte[8192];
  int read;
  while ((read = await s.ReadAsync(buffer, 0, buffer.Length, token)) != 0)
  {
    token.ThrowIfCancellationRequested();
    await HandleAsync(buffer.AsMemory(0, read), token);
  }
}

Synchronization primitives

Correct concurrent code protects shared state using primitives that coordinate access. C# provides lock which uses Monitor; .NET adds SemaphoreSlim, Mutex, ReaderWriterLockSlim, atomic helpers in Interlocked, and concurrent collections in System.Collections.Concurrent.

lock and Monitor

lock serializes access to a critical section. Always lock on a private object to avoid accidental external locking. Keep critical sections short to reduce contention and avoid calling into user code while holding a lock.

private readonly object _gate = new object();
private int _count;

public void Increment()
{
  lock (_gate)
  {
    _count++;
  }
}

SemaphoreSlim and asynchronous gates

SemaphoreSlim limits concurrent access and supports WaitAsync. This is useful when controlling parallelism for I/O bound work such as HTTP calls or database queries.

private readonly SemaphoreSlim _sem = new SemaphoreSlim(4);
public async Task FetchManyAsync(IEnumerable<string> urls)
{
  foreach (var url in urls)
  {
    await _sem.WaitAsync();
    _ = Task.Run(async () =>
    {
      try { await FetchAsync(url); }
      finally { _sem.Release(); }
    });
  }
}

Interlocked and concurrent collections

Interlocked performs atomic operations such as increment and exchange without locks. For shared dictionaries and queues prefer ConcurrentDictionary<,>, ConcurrentQueue<T>, and BlockingCollection<T> which handle synchronization internally.

int next = Interlocked.Increment(ref _count);
var cache = new ConcurrentDictionary<string, string>();
string value = cache.GetOrAdd(key, k => Compute(k));
Primitive Use for Async friendly
lock/Monitor Short critical sections No
SemaphoreSlim Bounded concurrency Yes
ReaderWriterLockSlim Many readers few writers No
Interlocked Atomic counters and flags Yes
ConcurrentDictionary Shared map with contention Yes

Parallel loops and data parallelism

Data parallelism partitions a collection so that multiple items are processed concurrently. .NET provides Parallel.For, Parallel.ForEach, and PLINQ which distribute work over the thread pool. Prefer these for CPU bound operations where each iteration is independent and side effects are controlled.

Parallel.ForEach with options

ParallelOptions lets you cap parallelism and pass a cancellation token. This helps tune throughput and protect downstream services.

var options = new ParallelOptions
{
  MaxDegreeOfParallelism = Environment.ProcessorCount,
  CancellationToken = token
};

Parallel.ForEach(images, options, img =>
{
  ProcessImage(img);
});

PLINQ and ordering

PLINQ parallelizes LINQ queries with AsParallel(). Use AsOrdered() when you must preserve input order which can reduce performance; omit ordering when possible for better throughput.

var hashes = files
  .AsParallel()
  .WithDegreeOfParallelism(Environment.ProcessorCount)
  .Select(f => HashFile(f))
  .ToList();
💡 For I/O bound fan out patterns prefer async concurrency with Task.WhenAll over Parallel; this avoids occupying worker threads while awaiting I/O.

Concurrency pitfalls and fixes

Concurrency introduces subtle bugs that pass unit tests and fail under load. Recognize common problems and apply targeted fixes that use language and .NET features carefully.

Deadlocks from blocking on async

Blocking on an async method with .Result or .Wait() can deadlock when continuations need the original context. Make the entire call chain async and use await with ConfigureAwait(false) in library code that does not need context.

// Bad
// var text = http.GetStringAsync(url).Result;

// Good
string text = await http.GetStringAsync(url).ConfigureAwait(false);

Race conditions and torn reads

Unsynchronized reads and writes can interleave and produce inconsistent results. Use lock for compound operations, or atomic APIs in Interlocked. For shared flags use Volatile to prevent reordering when simple reads and writes are sufficient.

private int _state;
public void Advance()
{
  int s1 = Interlocked.CompareExchange(ref _state, 1, 0);
  if (s1 == 0)
  {
    Initialize();
  }
}

Starvation and unbounded fan out

Creating too many tasks at once can overwhelm the scheduler and starve important work. Use SemaphoreSlim to bound concurrency, batch work, or use dataflow blocks in System.Threading.Tasks.Dataflow which include built in capacity limits and backpressure.

var gate = new SemaphoreSlim(8);
var tasks = new List<Task>();

foreach (var u in urls)
{
  await gate.WaitAsync();
  tasks.Add(Task.Run(async () =>
  {
    try { await FetchAsync(u); }
    finally { gate.Release(); }
  }));
}

await Task.WhenAll(tasks);
⚠️ Exceptions thrown in fire and forget tasks are observed later by the runtime; attach handlers, store the task for awaiting, or use TaskScheduler.UnobservedTaskException carefully to log failures.

This chapter introduced the core asynchronous and concurrent programming tools in C# and .NET; you can now design responsive services and applications that scale well while remaining correct under load.