CHT012: async ValueTask wrapper boxes a state machine when it suspends
Cause
An async method returning ValueTask or ValueTask<T> awaits exactly one inner ValueTask and returns it, but the await is wrapped in cleanup — try/catch, try/finally, or a using — so the async machinery cannot simply be removed.
Rule Description
This is CHT011 with something in the way. The cost is identical: every call that suspends boxes a state machine onto the heap. The difference is that the cleanup has to run after the awaited operation settles, and an await boundary is what orders it that way. Removing it takes a redesign rather than a deletion, which is why this rule is informational and ships without a code fix.
The cost is real but conditional:
- A call that completes synchronously allocates nothing. The builder only boxes when the method actually suspends.
- A call that suspends allocates one box, every time.
This is therefore a contended-path cost. A benchmark that measures only the uncontended fast path will report zero allocations and hide the problem entirely.
How to Fix
Option 1: Relocate the cleanup (Recommended)
If the awaited operation can be made responsible for its own cleanup, the wrapper disappears and the inner ValueTask can be returned directly:
// Before - the catch is what forces the async machinery
private async ValueTask<Releaser> LockAsyncImpl(TKey key, CancellationToken ct)
{
Entry entry = AcquireEntry(key);
try
{
return await entry.LockAsync(ct).ConfigureAwait(false);
}
catch
{
ReleaseEntry(entry); // the acquisition never became a hold
throw;
}
}
// After - the entry releases the reference from its own failure paths
private ValueTask<Releaser> LockAsyncImpl(TKey key, CancellationToken ct)
{
Entry entry = AcquireEntry(key);
return entry.LockAsync(ct);
}
Be aware that moving cleanup earlier can expose ordering the await previously hid. In the case above, releasing the reference before the waiter had been reset meant an entry could fall idle while its waiter was still live, which needed a separate guard on the entry-reuse path.
Option 2: Pool the state machine box (code fix available)
PoolingAsyncValueTaskMethodBuilder rents the box from a pool instead of allocating it. This is the fix offered by the IDE, since it is the only remedy that can be applied mechanically — it is deliberately offered as a mitigation, not a cure, and only when the compilation targets a framework that has the builder:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
private async ValueTask<Releaser> LockAsyncImpl(TKey key, CancellationToken ct)
{
// unchanged
}
It only helps when boxes are reused in sequence. A pool cannot reduce peak live objects, so when many callers suspend at the same moment every one of them still needs its own box. Multi-targeted code also keeps the allocation on frameworks older than .NET 6. See Performance Impact for measured numbers before relying on it.
It also opts the method out of runtime async. .NET 11 introduces runtime-native async, where the runtime tracks suspension and resumption instead of the compiler emitting a state-machine class, and where — per the runtime release notes — "the runtime also reuses continuation objects more aggressively and avoids saving unchanged locals, reducing allocation pressure in async-heavy code". That is the same cost this rule is about, addressed generally rather than method by method. But the same notes state that methods which are already pooled opt out of runtime-async, so the attribute excludes the method from those improvements.
This makes Option 1 the better long-term answer even where pooling appears to help today: a method with no async machinery has nothing to pool and nothing to opt out of. Runtime async is opt-in (<Features>runtime-async=on</Features>) and preview as of .NET 11, so this is a reason to prefer the thorough fix rather than an immediate regression.
Applying the attribute also silences this diagnostic on that method: declaring a builder is treated as a deliberate decision about how the state machine is allocated, so the rule stands down rather than continuing to nag.
Option 3: Accept the allocation
If the cleanup genuinely requires the await boundary and the method is not on a hot contended path, one box per suspension is a reasonable price. Suppress with a comment recording that decision.
When to Suppress
Suppress when the cleanup cannot be relocated without changing observable behaviour, or when the method is not contended enough for the box to matter. Because this rule flags a shape that is frequently legitimate, suppression with a justification is a normal outcome rather than a failure.
#pragma warning disable CHT012 // Cleanup must observe completion; box accepted on this cold path
Example
Violating Code
public ValueTask<Lease> RentAsync(string key, CancellationToken ct)
=> RentAsyncImpl(key, ct);
// CHT012: forwards one ValueTask, but the catch keeps the state machine load-bearing
private async ValueTask<Lease> RentAsyncImpl(string key, CancellationToken ct)
{
Slot slot = Reserve(key);
try
{
return await slot.AcquireAsync(ct).ConfigureAwait(false);
}
catch
{
Unreserve(slot);
throw;
}
}
Fixed Code
public ValueTask<Lease> RentAsync(string key, CancellationToken ct)
{
Slot slot = Reserve(key);
// The slot now unreserves itself from its own cancellation and timeout callbacks, so no
// await boundary is needed here to order the cleanup.
return slot.AcquireAsync(ct);
}
Performance Impact
Measured on AsyncKeyedLock<TKey> in this library, with waiters queued behind a single held key.
Relocating the cleanup (Option 1) removed the allocation entirely and was substantially faster, because the box was costing more than its bytes:
| queued waiters | before | after |
|---|---|---|
| 1 | 408 B | 0 B |
| 10 | 250 B | 0 B |
| 100 | 234 B | 0 B |
At 100 queued waiters the same change took the operation from 48,223 ns to 6,616 ns — 7.3x faster.
Pooling the box (Option 2) behaved very differently, and shows why it is not a general substitute:
| queued waiters | before | with pooling builder |
|---|---|---|
| 1 | 408 B | 0 B |
| 10 | 250 B | 193 B |
| 100 | 234 B | 235 B |
Excellent for a wrapper awaited one call at a time; useless for one that fans out, because all 100 boxes are live simultaneously and there is nothing for the pool to recycle.
Technical Details
AsyncValueTaskMethodBuilder<T> allocates nothing while the method runs synchronously — the returned ValueTask wraps the result directly. On first suspension it allocates a state machine box implementing IValueTaskSource<T>, which the returned ValueTask then wraps instead.
async Task and async Task<T> box on suspension in the same way; in modern .NET the box is the returned Task, so it is one object serving both roles. Task is additionally worse on the synchronous path, where it allocates unless the result hits a cached value (true, false, small integers, null, default), and it has no pooling builder equivalent — task pooling was prototyped and abandoned.
These rules are scoped to ValueTask because only there can the wrapper be replaced by returning the inner instance unchanged.
Where runtime async leaves this rule
Under .NET 11's runtime async the compiler no longer emits a state-machine class at all, so the box this rule names ceases to be a per-method concern and becomes something the runtime manages. Two things keep the rule useful even then:
- It is opt-in and preview, enabled per project with
<Features>runtime-async=on</Features>, so a library cannot assume its consumers have it on. - A library that multi-targets frameworks older than .NET 11 keeps compiler-generated state machines on those targets regardless.
Separately, and independent of runtime async, .NET 11 lets async continuations skip ExecutionContext capture and restore when there is nothing to restore, which benefits Task, Task<T>, ValueTask and ValueTask<T> alike. Code that uses ConfigureAwait(false) widely and AsyncLocal<T> sparingly gets that saving on the contended path without changing anything.