Async/Await — ValueTask & IAsyncEnumerable Perf
Key Points
ValueTask<T>is a struct that avoidsTask<T>allocation when the result is commonly available synchronously. Used widely in BCL hot paths (Stream.ReadAsync,PipeReader.ReadAsync).- The "await at most once" rule: a
ValueTaskmay wrap a reusableIValueTaskSource<T>. Awaiting it twice — or storing it for later — can read corrupted state. IValueTaskSource<T>is the underlying mechanism: implementers reuse a single backing instance across calls (with a token to detect misuse).IAsyncEnumerable<T>allocates perMoveNextAsynconly when the next value isn't synchronously available. Batch (yield arrays) to amortize.Taskis fine 95% of the time. Reach forValueTaskwhen (a) you're in a profile-confirmed hot path and (b) sync completion is the common case.- Async pooling in .NET 9+ reduces allocations for state-machine boxes on the cold path. Mostly invisible to user code.
Concepts (deep dive)
Why ValueTask<T>?
public async Task<int> ReadAsync(byte[] buffer)
{
if (_buffered.TryRead(out var value))
return value; // synchronous — yet still allocates a Task<int>
return await _stream.ReadAsync(buffer);
}
Task<T> is a class — every async method that returns Task<T> allocates. For methods that usually complete synchronously (hits a cache, reads a buffered byte), Task<T> is wasteful.
ValueTask<T> is a struct. If the operation completes synchronously, it carries the result directly — zero heap allocation. If it must go async, it wraps a Task<T> or an IValueTaskSource<T>.
public ValueTask<int> ReadAsync(byte[] buffer)
{
if (_buffered.TryRead(out var value))
return new ValueTask<int>(value); // sync path: no allocation
return new ValueTask<int>(_stream.ReadAsync(buffer)); // async path: wraps Task
}
Or with async:
public async ValueTask<int> ReadAsync(byte[] buffer)
{
if (_buffered.TryRead(out var value))
return value; // sync — no allocation
return await _stream.ReadAsync(buffer); // async — back-allocates a Task<int>
}
The "await at most once" rule
ValueTask<int> vt = ReadAsync(buf);
int a = await vt;
int b = await vt; // ❌ undefined behavior — vt's IValueTaskSource may have been recycled
ValueTask is allowed to wrap a reused IValueTaskSource<T>. After the first await, the source may be recycled into the pool — awaiting again could read corrupted state. The rule:
- Await it exactly once, OR
- Convert to
Task<T>via.AsTask()if you need to do something Task-shaped (pass toTask.WhenAll, await multiple times, store).
// ✅ multiple awaits — convert to Task first
ValueTask<int> vt = ReadAsync(buf);
Task<int> t = vt.AsTask();
int a = await t;
int b = await t; // ok
But .AsTask() allocates the Task — partially defeats the purpose. Often you can restructure to await once:
IValueTaskSource<T> for advanced reuse
public sealed class CustomReader : IValueTaskSource<int>
{
private ManualResetValueTaskSourceCore<int> _core;
public ValueTask<int> ReadAsync()
{
StartWork(); // initiates async work; will SetResult later
return new ValueTask<int>(this, _core.Version);
}
// IValueTaskSource impl
public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
public void OnCompleted(Action<object?> c, object? s, short t, ValueTaskSourceOnCompletedFlags f)
=> _core.OnCompleted(c, s, t, f);
public int GetResult(short token) => _core.GetResult(token);
// Internal helper
private void Complete(int result)
{
_core.SetResult(result);
}
public void Reset()
{
_core.Reset(); // bumps version; old ValueTasks become invalid
}
}
ManualResetValueTaskSourceCore<T> is the runtime helper for implementing IValueTaskSource<T>. The "version token" lets it detect awaits from old, recycled instances.
This pattern is in BCL pipelines (PipeReader.ReadAsync), socket I/O, and high-performance frameworks. Don't reach for it in app code.
IAsyncEnumerable<T> performance characteristics
public async IAsyncEnumerable<int> RangeAsync(int n)
{
for (int i = 0; i < n; i++)
{
await Task.Yield();
yield return i;
}
}
Compiler generates a state machine implementing both IAsyncEnumerable<T> and IAsyncEnumerator<T>. Each MoveNextAsync returns ValueTask<bool>:
- If the next item is synchronously available (e.g., already buffered),
MoveNextAsynccompletes synchronously — no allocation. - If async (must
await), the runtime allocates aTask<bool>for the continuation chain.
Hot pipelines see allocation per item. Mitigations:
// Batch: yield arrays to amortize per-await cost
public async IAsyncEnumerable<T[]> BatchedReadAsync(int batchSize)
{
var batch = new T[batchSize];
int n = 0;
while (await SourceAsync().ConfigureAwait(false) is { } item)
{
batch[n++] = item;
if (n == batchSize)
{
yield return batch;
batch = new T[batchSize]; // new array per yielded batch
n = 0;
}
}
if (n > 0) yield return batch.AsSpan(0, n).ToArray();
}
The trade-off: batching adds latency (you wait for batchSize items) but reduces async overhead per item.
ConfigureAwait on IAsyncEnumerable<T>
This applies to every MoveNextAsync and DisposeAsync await. Library code: yes. App code: depends on context.
Choosing between Task<T> and ValueTask<T>
| Scenario | Use |
|---|---|
| API surface returning Task, mostly async | Task<T> |
| Inner BCL-style API where sync completion is common | ValueTask<T> |
Caller needs Task.WhenAll/Task.WhenAny | Task<T> |
| Caller storing the awaitable | Task<T> |
| Library where every allocation counts | Profile, then maybe ValueTask<T> |
Default to Task<T>. Switch to ValueTask<T> only after measurement proves the win.
AsyncTaskMethodBuilder pooling
.NET 9+ pools the boxes of state machines for cold-start scenarios. Combined with task caching (Task with bool result, common int values), allocation profile of async methods improved noticeably between releases.
This is invisible. You don't write code for it. It's why "async on hot paths" got cheaper without you doing anything.
Common allocation surprises
// Closure captures: the delegate is one alloc; the closure-state object is another.
public Task<int> Sum(int[] data)
=> Task.Run(() => data.Sum(x => x));
// LINQ + async = often allocates more than you think
public async Task<List<T>> All()
=> await query.ToListAsync(); // List allocation; per-row allocation; iterator state machine
Use [MemoryDiagnoser] in BenchmarkDotNet to see them. The cure is typically: avoid LINQ in hot paths, use Span<T> / loops, prefer ValueTask<T> if sync completion is common.
How it works under the hood
ValueTask<T> is:
public readonly struct ValueTask<T>
{
private readonly object? _obj; // null, T, Task<T>, or IValueTaskSource<T>
private readonly T _result;
private readonly short _token;
}
- Sync path:
_objis null,_resultcarries the value. No allocation. Taskpath:_objis aTask<T>. Existing Task allocation; ValueTask is just a wrapper.IValueTaskSource<T>path:_objis the source;_tokenvalidates against the source's version.
AsTask() materializes a Task<T> (allocating if necessary). For IValueTaskSource<T>-backed value tasks, AsTask() allocates a Task<T> and registers a continuation to fulfill it.
AsyncValueTaskMethodBuilder<T> manages the await chain for async ValueTask<T> methods. If the method completes synchronously, no Task is allocated; if it goes async, a Task<T> (or pooled equivalent) backs the result.
IAsyncEnumerable<T> state machines use AsyncIteratorMethodBuilder<T> (a custom [AsyncMethodBuilder]). Each yield return becomes a "produce" point; each MoveNextAsync resumes from there.
Code: correct vs wrong
❌ Wrong: awaiting ValueTask<T> twice
✅ Correct: await once, or convert to Task
int result = await ReadAsync(); // single await
Use(result);
// Or, if you genuinely need to keep it:
Task<int> t = ReadAsync().AsTask();
var a = await t;
var b = await t; // ok
❌ Wrong: Task.WhenAll on ValueTask<T>[] directly
✅ Correct: convert each to Task
❌ Wrong: ValueTask in a public API surface for something usually async
public ValueTask<HttpResponse> CallApiAsync() // network call: rarely sync
{
return new ValueTask<HttpResponse>(_http.GetAsync(url));
}
For something inherently async, Task is clearer and there's no allocation savings. ValueTask is for common synchronous completion.
✅ Correct
❌ Wrong: IAsyncEnumerable over a sync source
public async IAsyncEnumerable<int> Items() // ❌ no awaits inside; just slowness
{
foreach (var i in _list) yield return i;
}
✅ Correct: IEnumerable for sync data
Design patterns for this topic
Pattern 1 — "ValueTask for buffered/cached reads"
- Intent: zero allocation when value is in cache; fall back to async otherwise.
- Code sketch:
public ValueTask<int> GetAsync(string key)
{
if (_cache.TryGetValue(key, out var v)) return new ValueTask<int>(v);
return new ValueTask<int>(LoadAsync(key));
}
Pattern 2 — "Await once, or AsTask()"
- Intent: prevent the "await twice" footgun.
- Practice: treat
ValueTaskas a "use it now" handle. If you need to defer or share,.AsTask().
Pattern 3 — "Batch in IAsyncEnumerable"
- Intent: amortize per-item async cost.
- Code sketch: see "batched read" above.
Pattern 4 — "IValueTaskSource<T> for high-frequency stream APIs"
- Intent: zero allocation for streams (e.g., a custom reader).
- When to use it: library code with measured allocation pressure on
MoveNextAsync-style APIs.
Pattern 5 — "Don't reach for ValueTask until profiled"
- Intent: keep
Task<T>as the default for clarity.
Pros & cons / trade-offs
| Feature | Pros | Cons |
|---|---|---|
Task<T> | Familiar; composable; await any time | Allocates per call |
ValueTask<T> | Zero alloc on sync path | "Await once" rule; less composable |
IAsyncEnumerable<T> | Streaming async data | Per-MoveNextAsync allocation if async |
| Batched IAsyncEnumerable | Amortized cost | Higher latency per yielded batch |
IValueTaskSource<T> | Custom pooling for streams | Complex to implement correctly |
When to use / when to avoid
- Default to
Task<T>for return types of public APIs. - Use
ValueTask<T>in identified hot paths where sync completion is the common case. - Use
IAsyncEnumerable<T>for streaming async data; batch when per-item cost matters. - Avoid
ValueTask<T>in APIs where callers commonly compose withTask.WhenAll, store the awaitable, or await multiple times. - Avoid
IValueTaskSource<T>in app code — it's a library-author tool.
Interview Q&A
Q1. Why does ValueTask<T> exist? To avoid Task<T> allocation when async methods complete synchronously most of the time. Task is a class; ValueTask is a struct that can carry the result inline.
Q2. What's the "await at most once" rule and why? A ValueTask may wrap a reusable IValueTaskSource<T>. After the first await, the source can be recycled — awaiting again could read corrupted state. The rule is part of the contract; violating it is undefined behavior.
Q3. When should you NOT use ValueTask<T>? When the operation is inherently async (network call, disk I/O without buffering), when callers need to compose with Task.WhenAll/Task.WhenAny, when callers store the awaitable for deferred use.
Q4. What's .AsTask() for? Materializes a Task<T> from a ValueTask<T>. Lets you keep the result for multiple awaits, pass to Task.WhenAll, etc. Allocates if the underlying source isn't already a Task.
Q5. How does async ValueTask<T> differ from async Task<T>? The compiler-generated state machine uses AsyncValueTaskMethodBuilder<T> instead of AsyncTaskMethodBuilder<T>. The result is a ValueTask<T> — sync path is alloc-free; async path back-allocates a Task<T>.
Q6. Why does IAsyncEnumerable<T> allocate per item? Each MoveNextAsync returns ValueTask<bool>. Sync completion: no alloc. Async completion: a Task<bool> for the continuation chain. In a hot pipeline, these add up.
Q7. How does batching help IAsyncEnumerable<T> performance? Yielding T[] instead of T amortizes the per-MoveNextAsync async cost across many items. Trade-off: higher latency until a batch fills.
Q8. What's IValueTaskSource<T>? The underlying interface that ValueTask<T> can wrap. Implementers reuse a single backing instance across many calls, with a "version token" to detect misuse. Used in BCL hot paths like PipeReader.ReadAsync.
Q9. What's ManualResetValueTaskSourceCore<T>? Helper struct that implements the bulk of IValueTaskSource<T>. Handles version tokens, completion semantics, and continuation scheduling. Use this instead of writing the source from scratch.
Q10. Why might you see allocations in a method that returns ValueTask<T>? The async path back-allocates a Task<T>. To eliminate, implement an IValueTaskSource<T> that pools its backing state. Or restructure so the call is more often sync.
Q11. Can you await a ValueTask returned from a method that's gone async? Yes — ValueTask wraps a Task in that case. Awaiting it once is fine; awaiting twice is the rule violation.
Q12. How does .NET 9 reduce allocations in async without code changes? Pooling state-machine boxes on cold paths; better Task caching for common values (booleans, small ints); JIT improvements that reduce per-await overhead. All transparent to user code.
Gotchas / common mistakes
- ⚠️ Awaiting
ValueTask<T>twice — undefined behavior. - ⚠️ Storing
ValueTask<T>for later — the underlying source may be recycled. - ⚠️
Task.WhenAllonValueTask<T>array — must.AsTask()first; allocates. - ⚠️
async ValueTask<T>for inherently async operations — no sync path means no win. - ⚠️
IAsyncEnumerable<T>over a sync source — wasted overhead; useIEnumerable<T>. - ⚠️ Closures in async hot paths — closure state objects allocate per call.
- ⚠️
ToListAsync()in a tight loop — full list allocation per call; use streaming.