Start Debugging

Что такое ValueTask<T> и когда он того стоит?

ValueTask и ValueTask<T> -- это структуры, которые позволяют асинхронному методу вернуть результат без выделения Task в куче, когда он завершается синхронно. Выигрыш -- одно выделение памяти меньше на горячих путях, которые обычно завершаются без ожидания. Цена -- строгий контракт ожидания ровно один раз. Вот что этот тип представляет собой на самом деле, как он работает и тот узкий набор случаев, где он оправдывает себя.

ValueTask и ValueTask<T> — это ожидаемые (awaitable) сущности значимого типа (структуры), которые асинхронный метод может вернуть вместо Task или Task<T>. Их единственное назначение — избежать выделения в куче, которое влечёт за собой Task<T>, когда асинхронный метод завершается синхронно, а это распространённый случай при попадании в кеш, буферизованном чтении или мемоизированном вычислении. Когда метод завершается, ни разу не ожидая, ValueTask<T> несёт результат прямо на стеке и ничего не выделяет; и только когда ему действительно приходится ждать настоящей асинхронной работы, он откатывается к обёртыванию Task. Загвоздка в том, что эта экономия идёт в комплекте с контрактом: вы можете ожидать ValueTask ровно один раз, и вы не должны блокироваться на нём, ожидать его дважды или прятать его в поле, чтобы ожидать позже. Из-за этого контракта типом возврата по умолчанию для асинхронного метода по-прежнему остаётся Task / Task<T>. ValueTask — это оптимизация горячего пути, обусловленная профилировщиком, а не улучшенный Task.

Всё здесь ориентировано на .NET 11 (SDK 11.0.100) и C# 14, актуальные по состоянию на июнь 2026 года, но контракт ValueTask стабилен с момента его появления в .NET Core 2.1, поэтому механика применима к каждой версии начиная с 2.1.

Выделение памяти, ради устранения которого существует ValueTask

Начнём с того, чего стоит Task<T>. Когда вы пишете async-метод, возвращающий Task<T>, компилятор C# строит конечный автомат и в тот момент, когда метод действительно приостанавливается или ему нужно вернуть незавершённую операцию, он выделяет объект Task<T> в куче (24 байта на 64-битной платформе под заголовок объекта плюс поля, ещё до какого-либо состояния продолжения). Среда выполнения кеширует несколько распространённых результатов: Task.FromResult(true), Task.FromResult(false) и небольшие упакованные целые числа переиспользуют одиночные (singleton) задачи. Но для произвольного ссылочного типа вроде вашего User каждый вызов, возвращающий Task<User>, — это новое выделение памяти, даже когда у метода ответ уже лежал в словаре и он ничего не ожидал.

Для метода, вызываемого несколько тысяч раз в секунду, это выделение незаметно. Для метода на по-настоящему горячем пути, который почти всегда завершается синхронно, эти объекты Task<T> становятся нагрузкой на сборщик мусора, которая проявляется как перетряхивание gen-0 в профилировщике. ValueTask<T> был спроектирован именно под такую форму: метод, который обычно возвращает уже имеющееся у него значение, и лишь изредка вынужден уходить в асинхронность.

Вот канонический мотивирующий пример — кеш с медленным запасным вариантом:

// .NET 11, C# 14
// Returns Task<User>: allocates a Task<User> even on the cache-hit fast path.
public Task<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return Task.FromResult(user);   // heap allocation on every cache hit

    return LoadFromDbAsync(id);          // genuinely async, allocates anyway
}

Строка Task.FromResult(user) выделяет Task<User> при каждом попадании в кеш. Если ваш процент попаданий составляет 99 процентов, а метод выполняется миллионы раз, вы выделяете миллионы короткоживущих объектов задач, чтобы обернуть значение, которое уже было у вас на стеке.

Что такое ValueTask на самом деле

ValueTask<T> — это readonly struct, которая хранит внутри одно из трёх: непосредственно TResult, Task<TResult> или IValueTaskSource<TResult> (об этом ниже). Когда метод завершается синхронно, структура несёт результат напрямую и никакой Task вообще не создаётся. Когда методу приходится ожидать настоящую работу, структура оборачивает Task<T>, который произвела асинхронная машинерия. Тот же пример, переписанный:

// .NET 11, C# 14
// Returns ValueTask<User>: zero allocation on the cache-hit fast path.
public ValueTask<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return new ValueTask<User>(user);     // no allocation, result is inline

    return new ValueTask<User>(LoadFromDbAsync(id)); // wraps the real Task
}

При попадании в кеш new ValueTask<User>(user) конструирует структуру на стеке с пользователем внутри неё. Ничто не попадает в кучу. При промахе она оборачивает Task<User> из LoadFromDbAsync, поэтому асинхронный путь стоит ровно столько же, сколько и раньше. Вы также можете использовать ключевое слово async напрямую, и компилятор сам выполнит обёртывание за вас:

// .NET 11, C# 14
// The async keyword builds a state machine that returns a ValueTask<User>.
// Synchronous completion still avoids the Task<User> allocation via a pooled
// state machine box when possible.
public async ValueTask<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return user;                     // completes synchronously

    return await LoadFromDbAsync(id);    // suspends, goes async
}

Неуниверсальный (non-generic) ValueTask существует по той же причине для методов, не возвращающих значения (async ValueTask DoWorkAsync()), и он избегает выделения почти-одиночного (singleton-ish) завершённого Task в тех случаях, где даже это имеет значение.

Контракт: ожидать один раз, никогда не блокироваться, никогда не сохранять

Всё, что делает ValueTask дешевле, делает его и более опасным, и опасность целиком в том, как его потребляет вызывающая сторона. Task<T> — это долговечный объект, который вы можете ожидать сколько угодно раз, из скольких угодно потоков, хранить в поле и блокироваться на нём через .Result. ValueTask<T> не гарантирует ничего из этого. Согласно официальной документации по ValueTask<TResult>, правила таковы:

Если вам нужно сделать что-либо из этого, вызовите .AsTask() один раз, чтобы материализовать настоящий Task<T>, и затем используйте его:

// .NET 11, C# 14
// Need to await twice or fan out? Convert exactly once, then treat as a Task.
ValueTask<User> vt = repo.GetUserAsync(id);
Task<User> task = vt.AsTask();   // materialize the Task once
var a = await task;
var b = await task;              // safe: Task<T> is awaitable repeatedly

Этот вызов .AsTask() выделяет тот самый Task<T>, которого вы пытались избежать, и в этом весь смысл: если вашему месту вызова нужна семантика Task, вы никогда и не получили бы экономии, а ValueTask был чистым риском. То же трение возникает с комбинаторами. Task.WhenAll и Task.WhenAny принимают Task, поэтому API, возвращающий ValueTask, вынуждает каждого вызывающего, который раздаёт работу веером (fan out), сначала написать .AsTask(), выделяя память на каждый элемент и стирая выгоду.

Анализатор, который ловит нарушения

Вам не нужно следить за соблюдением контракта на глаз. .NET SDK поставляет CA2012 (“Use ValueTasks correctly”), который помечает двойные ожидания, сохранённые value task и прямое обращение к .Result. По умолчанию он включён как подсказка (suggestion) в .NET 10 и более поздних версиях. Повысьте его до предупреждения, чтобы сборка падала при неправильном использовании:

# .editorconfig - .NET 11 SDK, CA2012 ships in the built-in analyzers
[*.cs]
dotnet_diagnostic.CA2012.severity = warning

Если вы где-либо применяете ValueTask, превращение CA2012 в предупреждение — не опционально. Это ограждение, которое делает тип безопасным для использования в команде, где не все читали правила Стивена Тоуба. Кодовая база, которая возвращает ValueTask без повышения CA2012, находится в одном небрежном двойном ожидании от хейзенбага, который проявляется только при конкурентности.

IValueTaskSource: пулинг, который заставляет его по-настоящему окупаться

Третье, что может оборачивать ValueTask<T>, — это IValueTaskSource<T>. Это продвинутый случай и тот, который даёт самый большой выигрыш: единственный поддерживающий (backing) объект, реализующий IValueTaskSource<T>, может переиспользоваться во множестве операций, поэтому даже асинхронный путь ничего не выделяет на каждый вызов. Среда выполнения использует это внутренне для Socket, NetworkStream и машинерии System.IO.Pipelines, где соединение выполняет миллионы чтений и вы не можете позволить себе Task на каждое чтение.

Вы редко пишете такой объект вручную. Когда вы это делаете, ManualResetValueTaskSourceCore<T> — это помощник, который реализует сложные части (планирование продолжений, версионирование токенов для обеспечения ожидания ровно один раз):

// .NET 11, C# 14
// A reusable async signal: one backing source serves many awaits over its
// lifetime, allocation-free per operation. ManualResetValueTaskSourceCore
// handles version tokens so a stale await throws instead of silently aliasing.
public sealed class Signaller : IValueTaskSource<int>
{
    private ManualResetValueTaskSourceCore<int> _core;

    public ValueTask<int> WaitAsync() => new(this, _core.Version);

    public void Complete(int value) => _core.SetResult(value);

    public int GetResult(short token) => _core.GetResult(token);
    public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
    public void OnCompleted(Action<object?> cont, object? state, short token,
        ValueTaskSourceOnCompletedFlags flags)
        => _core.OnCompleted(cont, state, token, flags);
}

Это единственный контекст, где ValueTask надёжно превосходит Task и на асинхронном пути тоже, а не только на синхронном быстром пути. Если вы не пулируете источник подобным образом, асинхронная ветвь вашего метода всё равно выделяет Task, и единственное, что сэкономил вам ValueTask, — это выделение при синхронном завершении.

Когда он того стоит, конкретно

Тянитесь к ValueTask<T> только когда выполняется всё перечисленное:

  1. Профилировщик показывает, что выделение Task<T> — это реальная цена на этом пути. Не “может быть”, а строка gen-0 в трассировке памяти. ValueTask — это оптимизация, которую вы применяете после измерения, ровно так же, как вы не стали бы тянуться к Span<T> или Native AOT без причины.
  2. Синхронное завершение — распространённый случай. Попадания в кеш, буферизованные чтения, мемоизированные результаты. Если метод обычно ожидает настоящий ввод-вывод, вы всё равно выделяете поддерживающий Task при большинстве вызовов и ничего не выигрываете.
  3. Места вызова — простые ожидания. Единственный await у каждого потребителя, без раздачи веером (fan-out), без кеширования ожидаемого, без блокировки. В тот момент, когда вызывающему нужен .AsTask(), экономия исчезает.
  4. Вы повысили CA2012 до предупреждения. Чтобы будущий участник не смог молча нарушить контракт.

Машинерия асинхронных потоков (async streams) — единственное место, где ValueTask корректен по умолчанию, а не по результатам измерения: IAsyncEnumerator<T>.MoveNextAsync возвращает ValueTask<bool>, а DisposeAsync возвращает ValueTask, именно потому что перечислитель переиспользует один поддерживающий источник во всех итерациях. Если вы вообще работаете с потоками, использование IAsyncEnumerable с EF Core 11 показывает этот паттерн в контексте, и вам никогда не следует “откатывать” эти сигнатуры обратно к Task.

Когда оставаться на Task

Для подавляющего большинства асинхронных методов возвращайте Task / Task<T>. Каноническая статья Стивена Тоуба Understanding the Whys, Whats, and Whens of ValueTask формулирует это прямо: “выбор по умолчанию по-прежнему Task / Task<TResult>”. Конкретные признаки того, что вы тянетесь к ValueTask рефлекторно, а не по доказательствам:

Если у вас уже есть ValueTask в кодовой базе, и данные его не оправдывают, обратная операция — безопасная, механическая правка: миграция ValueTask обратно на Task проходит по полному чек-листу, включая то, что делать с любым написанным вручную IValueTaskSource<T>. И какой бы тип вы ни возвращали, правила того, как не блокировать пул потоков, одинаковы: смотрите как отменить долго выполняющуюся Task без взаимной блокировки и всё ещё актуальный вопрос о том, имеет ли значение ConfigureAwait(false) в .NET 11.

Решение в одну строку

ValueTask<T> убирает выделение Task<T> на пути синхронного завершения ценой контракта ожидания ровно один раз, который должна соблюдать вся ваша команда. Используйте его, когда профилировщик доказывает, что это выделение имеет значение, а синхронное завершение — распространённый случай, опирайтесь на IValueTaskSource<T>, если можете пулировать поддерживающий источник, превратите CA2012 в предупреждение и держите его на асинхронных потоках, где он корректен по замыслу. Везде в остальных местах возвращайте Task<T> и тратьте бюджет осторожности на то, что действительно сдвигает какое-то число.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад