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.
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
Type
Meaning
Practical default
Task
Completion only
Use for most async commands. A completed task can be reused.
Task<T>
Completion with a result
Use for most async queries. Some completed results may be cached by the runtime.
ValueTask<T>
A result, task, or reusable async source
Consider after measuring a hot path; follow its stricter consumption rules.
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.
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<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#
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<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.
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#
publicstaticasync 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.
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#
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.
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.
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.
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.
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
Mode
Buffer after the write
Tradeoff
Wait
A, B, C; D waits
Producer waits asynchronously for room.
DropOldest
B, C, D
A, the oldest buffered item, is discarded.
DropNewest
A, B, D
C, the newest buffered item, is discarded.
DropWrite
A, B, C
The 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.
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#
publicstaticasync Task ForwardAsync<T>(
IAsyncEnumerable<T> source,
ChannelWriter<T> writer,
CancellationToken ct)
{
awaitforeach (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.
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.
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.
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.
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.
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;
publicstatic 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.
A predicate or selector failure normally terminates the sequence.
Buffer
Group values by count or time.
Batches add memory and latency; terminal error behavior needs a policy.
Throttle
Emit after a quiet period (debounce).
A continuously busy source can suppress values until it goes quiet.
Sample
Observe 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.
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.
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.
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.
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
Boundary
Evidence
Failure window
Admitted
The write completed.
Items may still be buffered or intentionally discarded in a drop mode.
Processed
The worker completed the required operation.
A timeout or lost reply can make the outcome uncertain.
Durably recorded
The storage or broker commit was confirmed.
Recovery depends on the storage, replication, and acknowledgment policy.
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.
Distinguish requested cancellation from an unexpected failure.
Preserve the original payload and its stable ID.
Confirm retry scheduling or durable dead-letter storage before treating the failure as handled.
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.
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.
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 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.
Stop admission. Stop or unsubscribe sources, and wait for in-flight producers to settle.
Complete the first writer. Keep downstream consumers running while accepted work drains.
Complete stage outputs. Do so only after all workers in each stage have finished writing.
Await final workers. Include pending storage and recovery writes.
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.
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#
publicsealedclass LossyObserver<T> : IObserver<T>
{
privatereadonly ChannelWriter<T> _writer;
privatelong _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;
publiclong Rejected => Interlocked.Read(ref _rejected);
publicvoid OnNext(T value)
{
if (!_writer.TryWrite(value))
Interlocked.Increment(ref _rejected);
}
publicvoid OnError(Exception error) => _writer.TryComplete(error);
publicvoid 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.
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.
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.
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);
staticasync Task RunPipelineAsync(
Func<SensorReading, CancellationToken, Task> storeAsync,
CancellationToken ct)
{
usingvar 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 () =>
{
awaitforeach (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.
}
}
publicsealedrecord 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.