.NET FieldnotesThe concurrency guide
A practical field guide / C# & .NET

.NET concurrency,
made clear.

Understand the work. Control the flow. Build asynchronous systems with clear failure and recovery behavior.

05 CHAPTERSC# EXAMPLESINTERACTIVE LAB
Three tools. Different jobs.
Task<T>One operation. One completion.
Channel<T>A queue. An explicit capacity.
IObservable<T>A stream of notifications.
Compose them around the guarantees
your application actually needs.

Your learning path

Production checklist ↗

For developers familiar with C# and basic async/await. Examples use modern .NET; the full console sample targets .NET 10. Rx examples require System.Reactive.

What are you trying to do?

Wait for a result →

Start with Task and async/await.

Queue work for consumers →

Choose a Channel and an overload policy.

React to a stream of events →

Use observables for push-based composition.

Tasks: Understand the work.

A task represents completion. Async code decides how to get there.

A task is a promise of completion

Use Task for an operation that completes without a result, and Task<T> for one that produces a value. Neither requires a dedicated thread. An I/O operation can be pending while no managed thread is waiting for it.

Calling an async method starts executing its body synchronously. It returns to the caller at the first incomplete await, or when it finishes. Task.Run separately queues a delegate to the ThreadPool; it is useful for CPU work you deliberately want to offload.

Choose an async return type
TypeMeaningPractical default
TaskCompletion onlyUse for most async commands. A completed task can be reused.
Task<T>Completion with a resultUse for most async queries. Some completed results may be cached by the runtime.
ValueTask<T>A result, task, or reusable async sourceConsider after measuring a hot path; follow its stricter consumption rules.

Reference: The meaning of TaskStatus.

What actually happens at await

The compiler transforms an async method into a resumable state machine. If the awaitable has already completed, execution continues immediately. Otherwise, the method saves the state it needs and registers a continuation. The current thread is then free to return to its caller.

Reuse the client; dispose the responseC#
public static async Task<string> GetTextAsync(
    HttpClient client, Uri uri, CancellationToken ct)
{
    using var response = await client.GetAsync(uri, ct)
        .ConfigureAwait(false);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(ct)
        .ConfigureAwait(false);
}

Pass a long-lived client or one managed by IHttpClientFactory. The method owns the response, but does not own the supplied client.

How much does the state machine allocate?

That depends on the compiler, build configuration, runtime, return type, and whether execution suspends. State machines are typically structs in optimized builds; state that survives suspension needs longer-lived storage. It is incorrect to assume that every await allocates a new boxed state machine and delegate. Measure allocations on your actual target runtime.

TaskCompletionSource: bridge a completion signal

TaskCompletionSource<T> exposes a task whose result you set yourself. Use it when adapting a callback or coordinating a one-time signal. Prefer an existing async API when one is available.

A one-shot signal with independently cancelable waitsC#
public sealed class AsyncSignal
{
    private readonly TaskCompletionSource<bool> _completion = new(
        TaskCreationOptions.RunContinuationsAsynchronously);

    public Task WaitAsync(CancellationToken ct = default) =>
        _completion.Task.WaitAsync(ct);

    public bool Signal() => _completion.TrySetResult(true);
}

RunContinuationsAsynchronously prevents registered continuations from running inline as part of completing the source. TrySetResult safely handles competing attempts to signal. Canceling one wait does not cancel the shared signal.

ValueTask: optimize only when measured

ValueTask<T> is a struct that can contain a result, a Task<T>, or an IValueTaskSource<T>. It can avoid a separate task allocation, but “struct” does not mean “always lives on the stack.” It also copies more state than a task reference.

  • Consume each returned instance once, normally with a direct await.
  • For sharing or Task.WhenAll, call AsTask() once and use that task thereafter.
  • Do not block on an incomplete instance or mix different consumption methods.

Reference: ValueTask tradeoffs and consumption rules.

Observe failures, including the whole group

Exceptions from an async Task body are stored in its task and rethrown by await. A non-async method that returns a task may still throw synchronously. Avoid async void except for event handlers, which need their own error boundary.

Task.WhenAll waits for every supplied task. Awaiting it throws an exception if it faults; its Exception property contains the aggregated faults. Cancellation alone leaves that property null.

Inspect grouped failures without losing cancellationC#
public static async Task ObserveAllAsync(
    IEnumerable<Task> tasks, Action<Exception> report)
{
    Task all = Task.WhenAll(tasks);
    try
    {
        await all.ConfigureAwait(false);
    }
    catch
    {
        if (all.Exception is { } failures)
        {
            foreach (var error in failures.Flatten().InnerExceptions)
                report(error); // The reporting callback must not throw.
        }
        throw;
    }
}

Put reporting at the boundary responsible for the operation to avoid duplicate logs. An unobserved-task event is delayed diagnostics, not reliable supervision. WhenAll does not cancel sibling tasks automatically.

Cancellation is a request, not a rollback

Pass cancellation tokens through operations and check them in CPU loops. ThrowIfCancellationRequested() produces cancellation; returning normally instead produces successful completion.

Combine caller cancellation with an operation deadlineC#
public static async Task<string> FetchWithDeadlineAsync(
    HttpClient client, Uri uri, CancellationToken ct)
{
    using var deadline = CancellationTokenSource
        .CreateLinkedTokenSource(ct);
    deadline.CancelAfter(TimeSpan.FromSeconds(10));

    return await client.GetStringAsync(uri, deadline.Token)
        .ConfigureAwait(false);
}

Threads, contexts, and ConfigureAwait

Await does not imply a thread switch. A task await normally respects a captured SynchronizationContext or a non-default TaskScheduler. In a desktop UI, this helps continuations return to the UI thread. ASP.NET Core does not install a custom synchronization context by default.

Use ConfigureAwait(false) in reusable library code that does not need its caller’s context. It does not force a ThreadPool hop or suppress ExecutionContext. UI code that updates bound state should retain or explicitly marshal to the UI context.

Reference: ConfigureAwait FAQ.

Background work needs an owner

A logging continuation does not make detached work reliable. Someone must retain its task, observe failure, keep required dependencies alive, and await it during shutdown.

  • Request-critical work: await it before reporting success.
  • Application background work: use a supervised service with bounded admission and a defined shutdown policy.
  • Work that must survive restart: persist it in a durable queue or outbox before acknowledging acceptance.

Do not capture request-scoped services in an untracked background task. Optional telemetry still needs a documented loss policy.

Channels: Control the flow.

Move work between producers and consumers, with an explicit capacity policy.

An async queue inside your process

Channel<T> separates admission from processing. The writer accepts items; readers remove them. This is useful for smoothing bursts, controlling worker count, and connecting pipeline stages. It stores items in memory, so it provides no crash recovery.

Compared with BlockingCollection<T>, channels provide asynchronous waits and integrate with await foreach. Pick based on the required programming model; blanket throughput comparisons are rarely useful.

Give each component only the end it needs

Pass ChannelWriter<T> to producers and ChannelReader<T> to consumers. Choose a single owner for completion. The owner closes admission when all producers finish.

Bound capacity and state the concurrency contractC#
using System.Threading.Channels;

var channel = Channel.CreateBounded<string>(
    new BoundedChannelOptions(256)
    {
        FullMode = BoundedChannelFullMode.Wait,
        SingleWriter = true,  // Exactly one concurrent writer.
        SingleReader = false, // Workers may compete for items.
        AllowSynchronousContinuations = false
    });

WriteAsync can wait for capacity. TryWrite attempts an immediate write. ReadAllAsync enumerates available items until drained completion or an error. WaitToWriteAsync signals an opportunity; it does not reserve a slot.

Choose what happens when the queue is full

Bounded queues make overload a deliberate decision. Unbounded queues can be appropriate when another mechanism strictly limits outstanding work, but otherwise allow memory growth. Capacity limits buffered items, not total memory: payloads, active workers, and pending writes also count.

A full queue contains A, B, C; the next write is D
ModeBuffer after the writeTradeoff
WaitA, B, C; D waitsProducer waits asynchronously for room.
DropOldestB, C, DA, the oldest buffered item, is discarded.
DropNewestA, B, DC, the newest buffered item, is discarded.
DropWriteA, B, CThe incoming item D is discarded.
Interactive example

What happens to item D?

One producer. Three slots. A full queue.

NEXT WRITE
D
BUFFER · OLDEST → NEWEST
ABC

The queue is full. With Wait, writing D waits until a reader frees a slot.

A step-by-step model of channel behavior; no C# runs in your browser.

References: Channel full modes and drop callbacks; why a dropped write can return true.

Backpressure must reach the producer

Await each write, or bound the number of outstanding writes. Starting a task for every input event merely moves the backlog out of the channel and into pending tasks.

Let admission slow the producerC#
public static async Task ForwardAsync<T>(
    IAsyncEnumerable<T> source,
    ChannelWriter<T> writer,
    CancellationToken ct)
{
    await foreach (var item in source.WithCancellation(ct))
        await writer.WriteAsync(item, ct);
    // The caller owns writer completion and worker supervision.
}

This helper deliberately does not complete a shared writer. Its caller must observe its task, close admission, and handle downstream failure so producers cannot wait forever.

Competing consumers are not broadcast

Multiple readers on one channel distribute work: a removed item goes to one reader. They do not each receive a copy. Multiple writers merge inputs, but concurrent producers do not create a meaningful global source order.

  • Worker pool: one queue, several readers, bounded processing concurrency.
  • Broadcast: one queue per destination, with an overload and delivery policy for each.
  • Pipeline: a queue between stages; each stage’s coordinator completes its output after all its workers finish.

FIFO dequeue order does not guarantee processing completion order with multiple workers. Partition by key when related items must be processed sequentially.

Complete admission, then wait for processing

TryComplete() closes the writer. Readers can still drain buffered items. TryComplete(error) records an upstream error, which readers encounter after buffered items are consumed.

ReadAllAsync may propagate the original completion error. Do not assume all reader failures arrive as ChannelClosedException. Observe worker tasks at the owning boundary.

If a consumer fails, cancel or otherwise release blocked producers. Task.WhenAll alone does not solve that dependency. The complete example demonstrates explicit sibling cancellation.

Measure the workload, not a headline number

SingleReader and SingleWriter describe actual concurrent usage; they are promises, not locks or runtime enforcement. They enable implementation-specific optimizations, but do not imply every bounded channel becomes lock-free.

Measure end-to-end latency, allocations, backlog age, producer wait time, and downstream throughput. Use realistic payload sizes and overload tests. There is no portable “millions of items per second” guarantee.

Observables: Work with event streams.

Use push-based composition without confusing notification with delivery.

The observer contract

IObservable<T> and IObserver<T> are built-in interfaces. Rx.NET adds operators through the System.Reactive package. For each subscription, notifications are serialized: zero or more values, then at most one terminal notification.

OnNext(value)× 0…n →OnCompleted()orOnError(error)

No notifications may follow termination. Notifications can move between threads while remaining serialized. Disposing the returned subscription requests unsubscription; it does not undo side effects or necessarily wait for an in-flight callback.

Reference: Rx.NET key types and notification rules.

Prefer established observable factories

Use Rx factories such as Observable.Defer, FromAsync, and FromEventPattern for common sources. A handwritten source has to coordinate subscription, termination, callback serialization, disposal, and reentrancy.

A fresh HTTP operation per subscription · requires System.ReactiveC#
using System.Reactive.Linq;

public static IObservable<string> FetchOnSubscribe(
    HttpClient client, Uri uri) =>
    Observable.Defer(() => Observable.FromAsync(
        ct => client.GetStringAsync(uri, ct)));

Each subscription starts its own operation. The cancellation-token overload lets disposal request cancellation; cancellation remains cooperative. The caller owns the shared HTTP client.

Choose operators by their semantics

Common Rx.NET operators
OperatorWhat it meansWatch for
Where / SelectFilter or transform each value.A predicate or selector failure normally terminates the sequence.
BufferGroup values by count or time.Batches add memory and latency; terminal error behavior needs a policy.
ThrottleEmit after a quiet period (debounce).A continuously busy source can suppress values until it goes quiet.
SampleObserve the latest available value at sampling times.Intermediate values are intentionally omitted.

A leading-edge rate limiter is a different policy from Rx.NET’s Throttle. Do not use sampling or debouncing when every event represents a required business action.

If you need a custom operator

Specify terminal behavior, validate arguments, release the upstream subscription, and test concurrent and reentrant notifications. Catching an exception from a downstream observer and sending it OnError is not a general recovery strategy. Prefer composing established operators before implementing a new one.

Reference: Rx.NET time-based operators.

Serialization and scheduling solve different problems

When integrating a source that can call concurrently, arrange serialization at the boundary. Rx provides Synchronize for synchronization; ObserveOn chooses where downstream callbacks execute, while SubscribeOn affects subscription work.

Moving notifications to another scheduler can introduce a queue. It does not establish bounded backpressure. A lock may exclude concurrent threads while still allowing reentrant calls on the same thread, so a simple lock wrapper is not a complete observable implementation.

Hot and cold describe source lifetime

A cold observable usually starts work for each subscriber. A hot source exists independently of individual subscriptions, so late subscribers generally miss earlier values unless replay is configured.

Cold does not mean durable or infallible: the source can fail, be canceled, or read changing external data. Replay retains values according to its configured limits; an in-memory replay buffer still disappears with the process.

A terminal error ends that subscription

OnError ends a sequence. Catch can switch to another sequence; Retry resubscribes. Neither resumes the exact failed operation automatically, and resubscribing can repeat side effects.

Represent expected per-item failures as data when the stream should continue: for example, a result containing either the processed value or its validation error. Let unexpected infrastructure failures reach a supervisor. Subscriber callbacks should not throw.

03 / ObservablesNext: Reliability →

Reliability: Define the guarantee.

A reliable pipeline makes acceptance, failure, and recovery explicit.

Accepted is not processed. Processed is not durable.

A successful channel write only confirms admission under that channel’s policy. It does not confirm that a worker succeeded or that data reached durable storage. In-memory queues, acknowledgments, and exception handlers cannot survive a process crash.

Name the boundary you are promising
BoundaryEvidenceFailure window
AdmittedThe write completed.Items may still be buffered or intentionally discarded in a drop mode.
ProcessedThe worker completed the required operation.A timeout or lost reply can make the outcome uncertain.
Durably recordedThe storage or broker commit was confirmed.Recovery depends on the storage, replication, and acknowledgment policy.

Reference: Transactional outbox design.

Recover selectively; escalate when recovery fails

A per-item error boundary helps only when it produces a real outcome. Isolate known validation failures or transient dependency failures according to policy. Do not catch every exception, log it, and report success.

  1. Distinguish requested cancellation from an unexpected failure.
  2. Preserve the original payload and its stable ID.
  3. Confirm retry scheduling or durable dead-letter storage before treating the failure as handled.
  4. If recovery storage fails, stop or fail the worker and alert its supervisor.

Dropping an item is acceptable only where the product explicitly allows it. Make that a counted outcome rather than an accidental consequence of an ignored TryWrite result.

Acknowledgment must have a precise meaning

A TaskCompletionSource in an envelope can let a caller await a worker’s result. The worker must settle the acknowledgment on success, failure, and cancellation. Shutdown must also account for queued envelopes that no worker will process.

An in-memory acknowledgment coordinates callers; it is not a durable receipt. If a caller times out after the operation commits, retrying can duplicate the effect. Define an idempotency key and an atomic deduplication strategy alongside the side effect.

A dead-letter queue is a recovery workflow

Record the event ID, original payload, schema version, first and last failure time, attempt count, and a useful error category. Protect sensitive payloads and apply retention and access controls.

Separate transient retries from events that need operator review. Preserve attempt counts across redelivery, cap retries, and monitor dead-letter growth and oldest age. Define who investigates and how corrected events are replayed.

Retry a transient failure, not every failure

Retry only failures your application classifies as transient and only when repeating the operation is safe. Use capped exponential backoff with jitter, respect cancellation and server retry guidance, and set an overall time budget.

A circuit breaker pauses calls to an unhealthy dependency. A complete implementation needs closed, open, and half-open states, synchronized transitions, and limited recovery probes. A shared boolean and failure counter are insufficient under concurrency.

Closedfailures →Opencooldown →Half-openprobe → recover or reopen

Use an established resilience implementation where appropriate, configure it for the dependency, and avoid multiplying retries across multiple layers.

Reference: Circuit breaker pattern.

Drain in dependency order

  1. Stop admission. Stop or unsubscribe sources, and wait for in-flight producers to settle.
  2. Complete the first writer. Keep downstream consumers running while accepted work drains.
  3. Complete stage outputs. Do so only after all workers in each stage have finished writing.
  4. Await final workers. Include pending storage and recovery writes.
  5. Enforce a deadline. On expiry, request cancellation, observe remaining tasks, and report unfinished work.

Use separate signals for “stop receiving” and “abort processing.” Passing the host’s already-canceled stopping token through the drain path can abandon work immediately. Cancellation cannot forcibly terminate code that ignores it; durable recovery must cover that case.

04 / ReliabilityNext: Integration →

Integration: Put the pieces together.

Make each boundary explicit, then test both the normal path and the failure path.

The observable-to-channel boundary

OnNext is synchronous, so it cannot await channel capacity. Calling WriteAsync and discarding its result creates untracked pending work. Blocking inside the callback risks stalling the source or deadlocking its scheduler.

Choose a boundary the source can support: pause or acknowledge upstream, expose an asynchronous pull API, persist to a durable ingress system, or explicitly allow loss. A channel alone cannot make an unpausable source lossless with finite memory.

An explicitly lossy adapter · BCL onlyC#
public sealed class LossyObserver<T> : IObserver<T>
{
    private readonly ChannelWriter<T> _writer;
    private long _rejected;

    // Use a Wait-mode channel. This adapter owns writer completion.
    // The source must serialize notifications and honor termination.
    public LossyObserver(ChannelWriter<T> writer) => _writer = writer;

    public long Rejected => Interlocked.Read(ref _rejected);

    public void OnNext(T value)
    {
        if (!_writer.TryWrite(value))
            Interlocked.Increment(ref _rejected);
    }

    public void OnError(Exception error) => _writer.TryComplete(error);
    public void OnCompleted() => _writer.TryComplete();
}

The owner retains and disposes the subscription, observes consumer tasks, and completes the writer when unsubscribing early. Rejected includes any failed admission, including a closed writer. This adapter is for data whose loss is acceptable, such as disposable display updates.

Fan-out, fan-in, and ordering

For a broadcast to storage and a live dashboard, give each destination its own queue. Storage may require durable acceptance; the dashboard may allow latest-value-wins. Define what happens if one destination succeeds and the other fails.

For multi-stage worker pools, give each stage a coordinator. After all enrichment workers finish, that coordinator completes the storage writer. If storage fails, cancel blocked enrichment writes and upstream production.

Optimize with a latency and memory budget

Start with bounded concurrency and a measured baseline. Batch when it reduces the cost of your actual sink, such as a database round trip. Thousands of events per second do not automatically require custom batching or throttling.

  • Size and time limits: flush when a batch fills or the maximum delay expires.
  • Partial batches: flush on normal completion; explicitly decide what happens on error or forced cancellation.
  • Timer ownership: serialize timer and producer flushes, and await active callbacks before teardown.
  • Failed writes: retain or durably recover a failed batch; logging and clearing it loses data.

Estimate a burst buffer with max(0, arrival rate − service rate) × burst duration, then validate against measured payload memory and latency. No finite capacity absorbs a sustained rate mismatch indefinitely.

A complete, supervised worker pool

This console example creates twenty readings, admits them to a bounded queue, and processes them with three workers. Normal completion drains the queue. A worker failure cancels its peers and unblocks the producer; the originating failure is then rethrown.

Run it: create a console project with the .NET 10 SDK, replace Program.cs with the code below, then run dotnet run. It uses the BCL and implicit usings; no extra package is required. The simulated sink can complete out of order.

Program.cs · complete runnable exampleC#
using System.Runtime.ExceptionServices;
using System.Threading.Channels;

await RunPipelineAsync(async (reading, ct) =>
{
    await Task.Delay(25, ct); // Simulate an asynchronous sink.
    Console.WriteLine($"Stored {reading.Id}: {reading.Value}");
}, CancellationToken.None);

static async Task RunPipelineAsync(
    Func<SensorReading, CancellationToken, Task> storeAsync,
    CancellationToken ct)
{
    using var abort = CancellationTokenSource
        .CreateLinkedTokenSource(ct);
    CancellationToken token = abort.Token;
    Exception? firstFailure = null;

    var queue = Channel.CreateBounded<SensorReading>(
        new BoundedChannelOptions(8)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleWriter = true,
            SingleReader = false,
            AllowSynchronousContinuations = false
        });

    async Task SuperviseAsync(Func<Task> action)
    {
        try
        {
            await action();
        }
        catch (Exception error)
        {
            // Preserve the originating fault before canceling peers.
            if (!(error is OperationCanceledException &&
                  token.IsCancellationRequested))
            {
                Interlocked.CompareExchange(
                    ref firstFailure, error, null);
            }
            abort.Cancel();
            throw;
        }
    }

    Task producer = SuperviseAsync(async () =>
    {
        try
        {
            for (int id = 1; id <= 20; id++)
            {
                token.ThrowIfCancellationRequested();
                await queue.Writer.WriteAsync(
                    new SensorReading(id, id * 0.5), token);
            }
        }
        finally
        {
            // Only this producer owns completion.
            queue.Writer.TryComplete();
        }
    });

    Task[] consumers = Enumerable.Range(0, 3)
        .Select(_ => SuperviseAsync(async () =>
        {
            await foreach (var reading in queue.Reader.ReadAllAsync(token))
            {
                token.ThrowIfCancellationRequested();
                await storeAsync(reading, token);
            }
        }))
        .ToArray();

    Task all = Task.WhenAll(consumers.Append(producer));
    try
    {
        await all; // Wait for the workers, not only the empty queue.
    }
    catch
    {
        if (firstFailure is { } error)
            ExceptionDispatchInfo.Capture(error).Throw();
        throw; // Preserve external cancellation when there is no fault.
    }
}

public sealed record SensorReading(int Id, double Value);
Exercise the failure path

Inside the sink delegate, throw new InvalidOperationException("Sink unavailable") when reading.Id == 5. The pipeline should finish by throwing that error, rather than hang with a producer waiting for capacity. Also test external cancellation and a slow sink.

A production sink must honor the cancellation token and enforce its own I/O timeouts. If it ignores cancellation indefinitely, awaiting all workers can also wait indefinitely. For data that must survive failure, use durable ingress and acknowledgment after commit.

Before you ship

Use these questions to review the behavior your application actually needs. Checked items record your review; they are not an automated audit.

0 of 12 reviewed

With JavaScript enabled, checklist selections are saved in this browser when local storage is available.

05 / IntegrationExplore the sources →

Go to the source.

Primary documentation and the Rx.NET project’s companion book. These links open online; the guide itself needs no external assets.

  1. Microsoft · Async/await FAQ — State machines, exception propagation, and execution.
  2. Microsoft · ConfigureAwait FAQ — Synchronization contexts and continuation behavior.
  3. Microsoft · Understanding ValueTask — Allocation tradeoffs and correct consumption.
  4. Microsoft Learn · System.Threading.Channels — Channel APIs, full modes, and drop notification.
  5. Introduction to Rx.NET · Key types — Observable contracts and subscription lifetime.
  6. Introduction to Rx.NET · Time-based sequences — Throttle, Sample, and timing behavior.
  7. Microsoft · Transactional outbox — A concrete durable messaging design.
  8. Microsoft · Circuit breaker pattern — Recovery probes, concurrency, and failure policy.

Examples teach individual contracts. Production behavior depends on your runtime, dependencies, storage guarantees, and failure policy.

Find it in the guide

↓ to results · Tab to navigate · Esc to close