Skip to content

Async/Await — ValueTask & IAsyncEnumerable Perf

Key Points

  • ValueTask<T> is a struct that avoids Task<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 ValueTask may wrap a reusable IValueTaskSource<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 per MoveNextAsync only when the next value isn't synchronously available. Batch (yield arrays) to amortize.
  • Task is fine 95% of the time. Reach for ValueTask when (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:

  1. Await it exactly once, OR
  2. Convert to Task<T> via .AsTask() if you need to do something Task-shaped (pass to Task.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:

int result = await ReadAsync(buf);
Use(result, result);

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), MoveNextAsync completes synchronously — no allocation.
  • If async (must await), the runtime allocates a Task<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>

await foreach (var x in source.ConfigureAwait(false))
    Process(x);

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: _obj is null, _result carries the value. No allocation.
  • Task path: _obj is a Task<T>. Existing Task allocation; ValueTask is just a wrapper.
  • IValueTaskSource<T> path: _obj is the source; _token validates 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

ValueTask<int> vt = ReadAsync();
var a = await vt;
var b = await vt;   // ❌ may read corrupted state

✅ 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

ValueTask<int>[] vts = ;
await Task.WhenAll(vts);   // ❌ doesn't compile — Task.WhenAll wants Task[]

✅ Correct: convert each to Task

var tasks = vts.Select(vt => vt.AsTask()).ToArray();
await Task.WhenAll(tasks);

❌ 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

public Task<HttpResponse> CallApiAsync() => _http.GetAsync(url);

❌ 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

public IEnumerable<int> Items() => _list;

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 ValueTask as 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 with Task.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.WhenAll on ValueTask<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; use IEnumerable<T>.
  • ⚠️ Closures in async hot paths — closure state objects allocate per call.
  • ⚠️ ToListAsync() in a tight loop — full list allocation per call; use streaming.

Further reading