Concurrency & reliability

Async .NET under load: bounded ownership, retries and graceful shutdown

Design admission before allocation, make queue ownership explicit, and test failure, cancellation, idempotency and shutdown with a runnable .NET10 pipeline.

Admission, a finite queue, workers and downstream capacity form one ownership budget. This is a conceptual pipeline, not a throughput benchmark.

Admission, a finite queue, workers and downstream capacity form one ownership budget. This is a conceptual pipeline, not a throughput benchmark. · ImageGen illustration

Async changes waiting, not the capacity of a dependency

An asynchronous API can free a thread while an operation waits, but it does not make the downstream database, connection pool or remote service infinitely fast. If requests arrive faster than work completes, the system must retain, delay, reject or discard something. A robust .NET service makes that choice explicit. Otherwise the overload policy is whatever happens when tasks, sockets and payloads accumulate until a limit is reached.

This guide builds one small producer-consumer design around ownership. Admission comes before large payload allocation; a bounded queue transfers accepted items to workers; every terminal path releases the payload exactly once. We then connect that local mechanism to cancellation, retries, idempotency, shutdown and multiple replicas. The accompanying .NET 10 project is deliberately bounded and executable, with accepted, rejected and returned-lease counts that can be checked.

SharpRune's portable lessons are useful for tracing task composition and resource lifetimes. Real worker scheduling, hosting and cloud message delivery require their own environment and evidence. Keep those boundaries visible. A clean local experiment can establish an ownership invariant, but it cannot by itself establish a production request rate, cloud delivery guarantee or tail-latency objective.

References: Bounded channels

Put admission before expensive state

Suppose a channel holds only two items, but ten thousand callers first allocate a large buffer and then await WriteAsync. The channel capacity is bounded; the retained state of its waiting producers is not. Those tasks still own their buffers outside the channel. A semaphore limiting worker count has a similar limitation if an unbounded number of callers waits while holding expensive state.

Use a finite admission boundary before materializing the payload where the protocol allows it. The boundary can represent the number of accepted owners across active work and the queue. If immediate admission fails, choose a documented response rather than silently allocating and waiting elsewhere. When bounded waiting is allowed, count its callers and retained state too. A reverse proxy or request server may already have accepted some bytes, so include the real upstream boundary in the deployment model.

The example uses a small finite number of permits and tries admission before renting storage. It intentionally rejects excess attempts rather than pretending that an arbitrarily long wait is free. Production policy can differ, but it must still answer who owns the waiting state, how long that ownership lasts and what a caller should do after rejection.

References: Bounded channels

Give the queue an ownership-transfer contract

A successful write can transfer responsibility for an item from the producer to the queue and its consumers. A failed or cancelled write must leave one clearly responsible owner. Document the acceptance point. Returning a success response before durable acceptance is a different contract from accepting work only into an in-memory channel, and a process restart can expose that difference immediately.

For a local in-memory pipeline, keep ownership simple: the producer owns an item until enqueue succeeds, a worker owns it while processing, and a final cleanup path handles accepted items that remain after workers stop. A disposable item wrapper can centralize buffer return and permit release. Make that terminal action idempotent so cleanup paths cannot accidentally return the same array twice.

Channel full modes also have resource consequences. A policy that drops an existing item must release that item's resources through the appropriate callback or surrounding ownership mechanism. A successful write under a dropping policy does not necessarily mean the newly offered item will be processed. Choose Wait, reject or drop behavior according to the business requirement, and test its exact semantics with the payload type you actually use.

References: SemaphoreSlim admission

Bound workers and observe backpressure

Start a fixed set of consumers rather than creating an unbounded task for each queued item. If each consumer immediately starts additional unbounded work, the visible channel bound no longer describes the whole pipeline. Include nested concurrency, dependency connection pools and fan-out in the capacity model. A worker count is useful only when it corresponds to a real bound on owned work.

Backpressure should be observable. Record admission wait, rejection count, queue wait, active work and completion duration as different quantities. A full queue is not automatically a collector failure, and more worker tasks can make a constrained dependency slower through contention. Compare the workload at the service's boundary with the capacity of the slowest required dependency rather than relying only on spare frontend CPU.

The sample holds consumers behind a deliberate test gate long enough to fill the accepted ownership budget. Excess attempts are rejected deterministically. Releasing the gate lets the accepted work finish and its leases return. This is a controlled invariant test, not a synthetic production benchmark. Its small counts make it possible to inspect every ownership transition instead of hiding mistakes inside a large throughput total.

References: SemaphoreSlim admission

Keep a buffer lease around the actual async consumer

A rented array remains owned by the current renter until all consumers have finished using it. Awaiting only task creation is not enough. If an async method returns another task while its finally immediately returns the buffer, the downstream operation can read storage that the pool has already lent to somebody else. The array may remain reachable throughout that defect, so the GC cannot make the protocol correct.

Expose only the logical written region, because a rented array can be larger than requested. Initialize every element that will be read. Choose a clearing policy appropriate to confidentiality and retained references. Do not inspect the array after Return to see whether clearing happened; that would itself violate the ended lease. Produce an independent result while the lease is valid if the caller needs data afterward.

In the downloadable example, the disposable work item owns both its rental and its admission permit. Completing, failing or cancelling work ends that ownership through one terminal action. The design makes a useful review question concrete: for every accepted item, which line returns its buffer and releases its permit, and what happens if execution leaves through an exception before the normal end?

.NET10 SDK / desktop CoreCLR

public void Dispose()
{
    var value = Interlocked.Exchange(ref buffer, null);
    if (value is null) return;
    try { pool.Return(value, clearArray: true); }
    finally { slots.Release(); }
}
// Excerpt from the complete Work owner in the downloadable .NET10 project.

References: Cancellation

Cancellation is a request with a completion boundary

A cancellation token communicates that work should stop where it can do so safely. It does not prove that an external operation did nothing, that a message was removed from every queue, or that all borrowers have stopped touching a buffer. Separate requesting cancellation from observing operation completion and from releasing owned state. Each boundary can require a different action.

Pass the token through operations that support it, but preserve a clear policy for partially completed work. If an outgoing request times out after the server committed a change, retrying it blindly can repeat the effect. If a queue write is cancelled before acceptance, the producer should release its item. If acceptance already occurred, ownership and completion need to be resolved according to that queue's contract rather than inferred from the caller's current token state.

Cancellation callbacks can also retain state and coordinate with concurrent execution. Dispose registrations within a lifecycle that avoids deadlocks with callbacks needing the same locks. A static callback avoids lexical capture, but explicitly supplied state is still owned. Make these details part of the test plan instead of treating cancellation as one optional parameter added after the pipeline already appears to work.

References: Cancellation

Retry only within an explicit budget

Retries can improve resilience to transient failures, but they also multiply load during an outage. A retry policy needs a maximum attempt or elapsed-time budget, delay behavior and a definition of which failures are retryable. Consider the operation's idempotency and the uncertainty of a timeout, not simply whether the client received a response. A connection failure before sending and a response lost after commit are different situations.

Use backoff and appropriate jitter where retries are justified, and coordinate layers so an HTTP client, application service and message handler do not each independently multiply the same operation. Respect server guidance where the protocol provides it. Keep a request's original deadline visible across attempts rather than giving every retry a new unlimited lifetime. Observe attempt counts and final outcomes separately from business operation counts.

Test retry behavior with deliberate failure placement. Fail before the side effect, after the side effect but before acknowledgement, and while cancellation races the response. The interesting correctness question is what the next attempt can safely infer. A retry that makes a dashboard greener while creating duplicate orders or exhausting every connection slot is not a successful recovery policy.

References: Hosted service shutdown

Put idempotency next to the durable effect

An idempotency key is useful only when its scope, request identity, stored outcome and concurrency behavior are defined. If two replicas can process the same key, an in-memory dictionary in one replica is insufficient. Durable coordination must protect the business effect and the recorded result under a transaction or equivalent resource-specific mechanism. The request key is not a magic distributed lock by itself.

Define how the key relates to the payload. Reusing the same key with a different request should not silently return an unrelated earlier result. Store or validate a suitable request identity, handle an in-progress operation deliberately and choose a retention period that matches the business retry window. Expiring deduplication history can make a very late retry a new operation, so retention is part of the externally visible contract.

Distinguish returning the same stored response from guaranteeing exactly-once execution across arbitrary external systems. If a database commit and a message send are separate operations, an interrupted process can leave uncertainty between them. An outbox can store the intended outgoing message with the local transaction, while a dispatcher and consumer still need retry and deduplication policies. Name the guarantee at each boundary rather than borrowing a stronger phrase from another product.

Duplicate request identities need coordination beside the durable effect. The ledger drawing does not imply a universal exactly-once guarantee.
Duplicate request identities need coordination beside the durable effect. The ledger drawing does not imply a universal exactly-once guarantee. · ImageGen illustration

References: Hosted service shutdown

Treat durable messaging as a separate system

A local Channel is an in-process coordination primitive. It is not automatically a durable broker, and process shutdown can lose accepted work unless the application records it elsewhere under the intended contract. Azure Service Bus and other brokers provide their own delivery, lock and settlement semantics. Using a broker changes the ownership and failure model; it does not remove the need to understand it.

When processing a locked message, separate successful business commit from acknowledgement or settlement. A lock can expire, an acknowledgement can fail, and delivery can occur again under the service's rules. Record idempotent effects where required and choose how poison messages become visible for investigation. Avoid a loop that retries a permanently invalid message forever while concealing the reason it cannot succeed.

Map broker concurrency and prefetch settings to memory and dependency capacity. Prefetched or delivered messages can occupy memory before business processing begins. More replicas can increase total outstanding ownership and database pressure. Test under the actual broker configuration and deployment quota; a local bounded-channel exercise establishes a useful pattern but cannot establish the cloud broker's delivery or recovery behavior by analogy alone.

References: Retry pattern

Design shutdown before the first forced termination

Graceful shutdown begins by ending admission, then coordinating accepted work within a bounded deadline. Decide whether the service drains queued items, records them for later recovery or cancels and explicitly releases them. Stopping producers, completing the channel writer, finishing consumers and disposing remaining items are separate steps. Leaving one of them implicit can cause a hang, a leaked rental or a lost accepted operation.

Consumers should observe writer completion and their cancellation policy correctly. If one consumer fails, the coordinator must notice it, stop or redirect acceptance as intended and handle remaining ownership. Do not wait forever for other tasks that cannot finish because a failed component held the only completion signal. Observe task failures through an owned task set rather than discarding them as fire-and-forget work.

A deployment platform can ultimately terminate a process after its allowed window, so durable work requires a recovery contract beyond a best-effort drain. Exercise that boundary in a suitable environment. Record which operations completed, which were retried and which retained durable intent. In the local project, all accepted buffer leases are returned before exit; that narrow invariant is intentionally distinct from a promise that arbitrary production business effects are recovered after a machine failure.

Shutdown ends admission, coordinates accepted work and returns reusable buffers after consumers finish. The return loop represents ownership, not a specific broker implementation.
Shutdown ends admission, coordinates accepted work and returns reusable buffers after consumers finish. The return loop represents ownership, not a specific broker implementation. · ImageGen illustration

References: Retry pattern

Measure the system at comparable workload boundaries

Record the input rate, payload distribution, downstream behavior, concurrency settings, runtime, CPU quota and memory quota for a performance comparison. Warmup policy and observation duration matter. A test that uses tiny cached responses and a healthy local dependency does not establish behavior during large uploads or a slow remote service. Validate result correctness before comparing throughput.

Look at rejected work as well as completed work. A change can lower average response time simply by rejecting more requests. Queue wait and processing time should be visible separately, while dependency saturation and retries explain why capacity changed. Track retained buffers and process memory during a burst and after an idle recovery interval, without confusing a cumulative allocation counter with a current live-set measurement.

Across a cluster, inspect per-instance distributions and the aggregate budget. A hot partition can overload one replica while fleet averages look comfortable. Replicas multiply idle pools and baselines, and overlapping revisions can temporarily increase process count during deployment. The workload calculator helps expose ownership assumptions, but measured latency, fairness and failure recovery require a real representative test rather than extrapolating a small deterministic example into an impressive request-per-second claim.

References: Transactional outbox

Rehearse the failure paths with a small executable contract

Run the included .NET 10 project and check accepted, rejected, processed and returned-rental counts. The test gate makes overload deterministic, so a changed scheduling order should not be used as an excuse for a missing lease. Read the item wrapper and identify the ownership-transfer point. Then inspect its independent failure and cancellation checks rather than relying only on the successful run.

Modify the workload in one way at a time. Change the number of workers, queue slots or attempted items and predict the new bound. Introduce an exception after a rental but before enqueue, then one inside processing. Verify that all accepted ownership is released and that the original failure remains observable. Keep logical payload length separate from rented capacity so the test does not accidentally depend on an ArrayPool bucket size.

Use SharpRune's interview and concurrency lessons to explain the mechanism in plain language. A strong answer identifies the owner, the acceptance boundary, the terminal action and the consequence of uncertainty. It should be possible to translate that answer into a test with a concrete failing condition, rather than a general claim that async makes the service scalable.

References: Transactional outbox

A review that connects code to operations

Before publishing a pipeline change, ask where an unbounded number of owners could still accumulate. Consider callers waiting outside admission, nested tasks, result histories, retries, prefetched broker messages and slow response writers. Verify the behavior of a full queue and a cancelled request from the client's point of view. Bounds that exist only inside one helper method can be defeated by its surrounding call pattern.

Next, inspect recovery identity. Record which image, configuration, broker settings and data contract were tested together. If two revisions run concurrently, confirm that they can both understand the messages and stored results they may encounter. A rollback should preserve accepted durable work and remain compatible with writes made by the newer version. Keeping an old image is necessary evidence, but it is not the whole recovery design.

Finally, preserve the evidence that justified the change: invariant tests, the representative workload definition and the measured tradeoff. Revisit it when payload sizes, quotas or downstream behavior change. The useful outcome is a pipeline whose accepted work has a bounded lifetime and an explainable recovery path, supported by enough measurements to know when the original assumptions no longer hold.

References: Service Bus message settlement

Calculate a first ownership bound

Assume four active workers, eight queued payloads, one MiB of logical input per request and two simultaneously owned copies. Those twelve request owners account for twenty-four MiB of modeled payload before object overhead, pool slack, idle buffers, native allocations or the process baseline are included. Increasing the queue to eight hundred changes that payload term to one thousand six hundred eight MiB while leaving worker service capacity unchanged. A queue can purchase waiting time with memory; it does not create downstream throughput.

Use this arithmetic to challenge the design before choosing a large channel capacity. Then replace assumptions with observations: actual rented capacity, peak simultaneous representations, active consumer count and memory held by waiting callers. If a parser keeps the original body while constructing a second object graph and a serialized output, two copies may itself be an underestimate. If stages borrow the same immutable storage, counting each view as an independent full copy may overestimate it.

A budget is most useful when its terms correspond to identifiable owners. That makes it possible to change one term, such as admission count or the lifetime of an intermediate representation, and test the effect without pretending the arithmetic is a direct measurement of RSS.

References: Service Bus message settlement

Run the complete SDK project

.NET10 SDK / desktop CoreCLR

Download the complete project, read README.md and execute in a separate local SDK process. Cloud hosting and production load behavior require independent qualification.

dotnet run -c Release --project PipelineLab.csproj
Download complete project ZIP

Continue with SharpRune

Sources & further reading

Technical references checked against primary documentation. Browser playground examples target the SharpRune .NET 9 profile; production server guidance describes a separate hosting environment.

Illustration created with ImageGen for SharpRune. Article and examples are educational; validate behavior in your target runtime.
Put an idea into practice