ValueTask and ConfigureAwait
ValueTask skips the Task allocation when a call usually finishes immediately, and ConfigureAwait(false) drops the captured context.
ValueTask<TResult> GetAsync(string key) // await it exactly onceTask is a class, so every call that returns one allocates an object on the heap. For a method called millions of times that almost always has the answer in hand, such as a cache read, that allocation is pure cost.
ValueTask
ValueTask and ValueTask<T> are structs: they can hold the finished value directly, with no allocation, and fall back to wrapping a real Task on the slow path. The rule that comes with them is strict. A ValueTask may be awaited once, and its Result must not be read before it completes. Awaiting one twice is undefined behaviour, not an exception. If you need the value more than once, call AsTask() or Preserve() first.
The default choice stays Task. Use ValueTask when measurement shows the allocation matters.
ConfigureAwait
A synchronization context is a rule that continuations run on one particular thread, such as the single UI thread in a desktop app. ConfigureAwait(false) says this continuation does not need it, so resume on any thread. Library code should use it, because a library cannot know what hosts it. ASP.NET Core installs no synchronization context, so in an app it changes nothing and is not worth the noise.
.NET 8 added an options overload: ConfigureAwait(ConfigureAwaitOptions.None) matches false, and ForceYielding makes even a finished task yield.
Example
Cache cache = new Cache();
// First call misses, so it takes the slow path and really waits.
int first = await cache.GetAsync("alpha");
// Second call hits the dictionary and finishes without allocating a Task.
int second = await cache.GetAsync("alpha");
Console.WriteLine($"first: {first}");
Console.WriteLine($"second: {second}");
// A hit is already complete before anything awaits it.
Console.WriteLine($"hit is synchronous: {cache.GetAsync("alpha").IsCompletedSuccessfully}");
internal sealed class Cache
{
private readonly Dictionary<string, int> _values = new();
public ValueTask<int> GetAsync(string key)
{
if (_values.TryGetValue(key, out int cached))
{
// Finished before it started: no Task object is created.
return new ValueTask<int>(cached);
}
// Slow path: wrap the Task from the ordinary async method.
return new ValueTask<int>(LoadAsync(key));
}
private async Task<int> LoadAsync(string key)
{
// Library-style code: the continuation needs no captured context.
await Task.Delay(50).ConfigureAwait(false);
int value = key.Length + 10;
_values[key] = value;
return value;
}
}
// Output:
// first: 15
// second: 15
// hit is synchronous: TrueWhen to use it
- Returning a cached value from a hot path without allocating a Task per call.
- Adding ConfigureAwait(false) through a NuGet package that desktop apps also consume.
- Fixing a rarely reproducible wrong-value bug caused by a ValueTask awaited twice.
More examples
Awaiting twice, and the two fixes
The broken version compiles, passes a quick test and then misbehaves under load, because a reused ValueTask may be backed by a pooled object that has already been handed to someone else. Reach for AsTask when a ValueTask must be stored, passed to Task.WhenAll, or awaited more than once; otherwise await it immediately.
public sealed class Reader
{
private readonly Cache _cache;
public Reader(Cache cache)
{
_cache = cache;
}
public async Task<int> BrokenAsync(string key)
{
ValueTask<int> pending = _cache.GetAsync(key);
int a = await pending;
int b = await pending; // undefined behaviour: awaited twice
return a + b;
}
public async Task<int> FixedWithTaskAsync(string key)
{
// AsTask converts to a Task, which may be awaited any number of
// times. It allocates, which is the cost of the convenience.
Task<int> pending = _cache.GetAsync(key).AsTask();
int a = await pending;
int b = await pending;
return a + b;
}
public async Task<int> FixedWithValueAsync(string key)
{
// Simplest fix: await once and keep the value.
int value = await _cache.GetAsync(key);
return value + value;
}
}ConfigureAwait in a library versus in an app
This is the whole of the ConfigureAwait decision: who might host this code. Published libraries and shared internal packages use ConfigureAwait(false) consistently, including on the awaits inside loops. Code that only ever runs in your ASP.NET Core service does not need it, and adding it everywhere makes every await harder to read for no gain.
// Library code: it has no idea what will host it, so it never assumes a
// context is worth returning to. ConfigureAwait(false) on every await.
public sealed class PricingClient
{
private readonly HttpClient _http;
public PricingClient(HttpClient http)
{
_http = http;
}
public async Task<string> GetQuoteAsync(string symbol, CancellationToken cancellationToken)
{
string body = await _http
.GetStringAsync($"/quotes/{symbol}", cancellationToken)
.ConfigureAwait(false);
return body.Trim();
}
}
// Application code in ASP.NET Core: there is no synchronization context
// to capture, so ConfigureAwait(false) would do nothing. Leave it out.
public sealed class QuoteEndpoint
{
private readonly PricingClient _client;
public QuoteEndpoint(PricingClient client)
{
_client = client;
}
public async Task<string> HandleAsync(string symbol, CancellationToken cancellationToken)
{
return await _client.GetQuoteAsync(symbol, cancellationToken);
}
}The options overload, added in .NET 8
The flags enum replaces a boolean that could only say one thing, and the two new options solve real problems: ForceYielding guarantees you do not run a long continuation inline on the caller's thread, and SuppressThrowing waits for a fire-and-forget task to settle without wrapping the await in try/catch. Combine them with a bitwise or, since ConfigureAwaitOptions carries [Flags].
// ConfigureAwaitOptions.None is the same as ConfigureAwait(false):
// do not try to resume on the captured context.
await Task.Delay(10).ConfigureAwait(ConfigureAwaitOptions.None);
Console.WriteLine("one");
// ForceYielding makes an already finished task behave as if it were not,
// so the method always gives up its thread at this point.
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
Console.WriteLine("two");
// SuppressThrowing awaits a plain Task without rethrowing its failure.
// The task is still faulted; this only stops the await from throwing.
Task failing = FailAsync();
await failing.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
Console.WriteLine($"faulted, not thrown: {failing.IsFaulted}");
static async Task FailAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("nope");
}
// Output:
// one
// two
// faulted, not thrown: True
Discussion