When to Use ValueTask vs Task in High-Performance C
This article explores the fundamental differences between
ValueTask and Task in C#, examining how memory
allocation affects application throughput. You will learn the mechanics
behind ValueTask, identify the specific high-performance
scenarios where it should replace Task, and understand the
critical usage constraints necessary to prevent runtime bugs.
Understanding Task vs. ValueTask
In C#, Task and Task<T> are reference
types (class). Every time an asynchronous method returns a
Task, an object is allocated on the managed heap. While the
runtime caches common tasks—such as Task.CompletedTask or
boolean tasks—returning a generic Task<T> with
dynamic results repeatedly incurs garbage collection (GC) overhead.
ValueTask and ValueTask<T> are value
types (struct). A ValueTask<T> can
represent either a finished synchronous result directly without heap
allocation, or wrap an underlying reference type
(Task<T> or an IValueTaskSource<T>
implementation) if the operation must run asynchronously.
The Problem ValueTask Solves
In high-throughput systems, many asynchronous methods complete synchronously most of the time. Common examples include:
- Reading from an in-memory cache before querying a database.
- Reading data from an already populated internal buffer (e.g.,
MemoryStreamorPipeReader). - Validating arguments or short-circuiting operations before initiating I/O.
When using Task<T> for an operation that completes
synchronously, a new Task<T> object is still
allocated on the heap just to return the already available value. By
switching to ValueTask<T>, the method returns a
stack-allocated struct containing the result, completely eliminating
heap allocation and reducing GC pressure.
When to Use ValueTask
Use ValueTask or ValueTask<T> under
the following circumstances:
- High-Frequency Synchronous Completion: The method is invoked frequently in a hot path, and a high percentage of executions (e.g., 90% or more) complete synchronously.
- Buffer and Stream Operations: You are handling
low-level I/O operations where data chunks are frequently returned from
memory buffers, similar to .NET's internal
Stream.ReadAsync(Memory<byte>, CancellationToken)implementation. - Implementing Specific Interfaces: The method
implements framework interfaces that require it, such as
IAsyncDisposable.DisposeAsync(). - Custom Pooling with
IValueTaskSource: You are designing advanced, zero-allocation asynchronous state machines usingIValueTaskSourceto reuse backing objects across operations.
When to Stick with Task
Default to Task and Task<T> for
general application code. Prefer Task in these
scenarios:
- Naturally Asynchronous Workloads: The operation
almost always yields and completes asynchronously (such as external HTTP
requests or network calls). In this case,
ValueTaskadds overhead because it wraps a regularTaskinside a struct, increasing copying costs without saving allocations. - Public API Design:
Taskis easier and safer for consumer consumption because it carries fewer operational caveats. - Combinator Usage: You need to use combinators like
Task.WhenAllorTask.WhenAny.ValueTaskdoes not natively support these without converting toTaskfirst via.AsTask().
Critical Rules for Consuming ValueTask
Because ValueTask is a struct that may wrap a pooled
IValueTaskSource, misusing it can lead to undefined
behavior, race conditions, or memory corruption. Always adhere to these
rules:
- Await only once: Never await a
ValueTaskmultiple times. Once awaited, its underlying source may be reset and returned to an object pool. - Do not await concurrently: A single
ValueTaskinstance must not be awaited by multiple execution paths simultaneously. - Do not call
.Resultor.GetAwaiter().GetResult()before completion: Blocking on an incompleteValueTaskis not supported across all backing implementations. - Convert when necessary: If you must store the
operation, use combinators, or await it multiple times, immediately call
.AsTask()to convert it to a standardTask.