Миграция с ValueTask<T> обратно на Task<T>: когда и почему (.NET 11, C# 14)
Практический чеклист для возврата типов возвращаемого значения ValueTask и ValueTask<T> к Task и Task<T>: что ломается в точках вызова, как проверить каждое изменение и как понять, стоила ли замена усилий.
Возврат API с ValueTask<T> обратно к Task<T> обычно занимает полдня и почти всегда безопасен, потому что Task<T> — более снисходительный тип: всё, что компилировалось с ValueTask<T>, продолжит компилироваться, а несколько скрытых ошибок в ваших вызывающих местах исчезают в момент замены. Время отнимает не само изменение типа возвращаемого значения, а аудит точек вызова, которые опирались на семантику ValueTask: метод, ожидаемый дважды, результат, закешированный в поле, .GetAwaiter().GetResult() на горячем пути. Это руководство охватывает, когда возврат — правильное решение, точные правки в объявлении и в каждом вызывающем месте, как проверить каждый шаг и как подтвердить потом, что вы не вызвали регрессию профиля аллокаций, ради исправления которого изначально и приняли ValueTask.
Это ориентировано на .NET 11 и C# 14, актуальные на июнь 2026 года, но ничего из этого не зависит от версии: контракт ValueTask стабилен с момента выхода в .NET Core 2.1. Совет следует каноническому руководству Стивена Тоуба Understanding the Whys, Whats, and Whens of ValueTask, вывод которого прямолинеен: “выбор по умолчанию по-прежнему Task / Task<TResult>.” Если вы приняли ValueTask без того, чтобы профайлер вам это посоветовал, это та статья, которая возвращает вас назад.
Зачем вообще возвращаться
ValueTask<T> существует, чтобы избежать одной конкретной аллокации: объекта Task<T>, который асинхронный метод аллоцирует в куче даже тогда, когда завершается синхронно. Это реальная стоимость на горячих путях, которые почти всегда завершаются без ожидания (попадание в кеш, буферизованное чтение, мемоизированное вычисление). Но тип расплачивается за этот выигрыш контрактом, который легко нарушить, а у большинства кодовых баз, которые к нему тянутся, проблемы аллокации не было вовсе. Конкретные причины вернуться назад:
- Точки вызова продолжают срабатывать на CA2012. Если ваша команда раз за разом ожидает один и тот же
ValueTaskдважды, хранит его в поле или блокируется на нём, тип активно стоит вам корректности.Task<T>делает все эти операции легальными. - Вы никогда не измеряли выигрыш.
ValueTask— это оптимизация, управляемая профайлером. Если вы приняли его рефлекторно, а бенчмарк не показывает разницы в аллокациях, добавленная осторожность — чистые накладные расходы. - Метод теперь обычно завершается асинхронно.
ValueTaskокупается, только когда синхронное завершение — частый случай. Если метод начал ожидать реальный ввод-вывод в большинстве вызовов, вы всё равно аллоцируете подложечный объект и вдобавок несёте ограничения структуры впустую. - Вам нужна эргономика
WhenAll/WhenAny. Комбинаторы принимаютTask, поэтому API, возвращающийValueTask, заставляет каждое вызывающее место писать.AsTask()перед распараллеливанием. Возврат убирает это трение.
Если вы взвешиваете обратное направление или ещё решаете, место ли ValueTask в вашем коде вообще, правила ниже служат и подспорьем для решения.
Что ломается (спойлер: очень немногое)
| Область | Изменение | Серьёзность |
|---|---|---|
| Объявление метода | ValueTask<T> становится Task<T>; ValueTask становится Task | низкая |
Точки вызова с прямым await | Изменения не нужны; оба типа ожидаемы | нет |
Вызовы .AsTask() | Теперь избыточны; удалите их | низкая |
Реализации IValueTaskSource<T> | Должны быть заменены реальным источником Task или TaskCompletionSource<T> | высокая |
| Возвраты синхронного быстрого пути | return new ValueTask<T>(value) становится return Task.FromResult(value) | средняя |
| Сигнатуры интерфейса / базового класса | Каждый реализующий и переопределяющий должен меняться вместе | средняя |
| Поверхность публичного API | Бинарно несовместимое изменение для внешних потребителей | высокая |
Единственные по-настоящему сложные случаи — это написанный вручную IValueTaskSource<T> (редкость, и если он у вас есть, вы приняли ValueTask намеренно, так что подумайте дважды) и публичная поверхность NuGet, где смена типа возвращаемого значения — бинарный слом. Всё остальное механично.
Предполётный чеклист
Прежде чем тронуть хоть одну сигнатуру:
- Убедитесь, что анализатор включён. Правило корректности
ValueTaskCA2012 включено как подсказка по умолчанию в .NET 10 и новее. Повысьте его до предупреждения, чтобы компилятор показал вам ровно те точки вызова, которые опирались на семантикуValueTask: добавьтеdotnet_diagnostic.CA2012.severity = warningв ваш.editorconfig. - Снимите базовую линию. Если аллокация когда-либо была причиной
ValueTask, прогоните сейчас проходBenchmarkDotNetс[MemoryDiagnoser]по горячему пути, чтобы было с чем сравнить потом. - Определите границу контракта. Если метод реализует интерфейс или переопределяет базовый член, каждое связанное объявление меняется в одном коммите. Сначала найдите имя метода по всему решению.
- Проверьте публичную поверхность. Если этот тип поставляется в пакете NuGet, смена типа возвращаемого значения ломает бинарную совместимость, даже будучи совместимой на уровне исходного кода. Запланируйте повышение мажорной версии.
Шаги миграции
Каждый шаг ниже — это отдельное изменение со строкой проверки. Выполняйте их по порядку; ожидайте промежуточных состояний, в которых сборка красная, пока контракт не обновлён везде.
-
Превратите CA2012 в предупреждение и соберите. Сделайте анализатор громким прежде, чем что-либо менять, чтобы сборка показала каждый рискованный шаблон потребления, пока сигнатура
ValueTaskещё на месте.# .editorconfig - .NET 11 SDK, CA2012 ships in the built-in analyzers [*.cs] dotnet_diagnostic.CA2012.severity = warningЗапустите
dotnet build. Каждое предупреждение CA2012 — это точка вызова, которая ожидала дважды, сохраняла или блокировалась наValueTask, ровно тот код, что становится тривиально корректным после возврата. Запишите каждую; обходные решения вы удалите на шаге 4. Проверить: сборка завершается, и у вас есть письменный список срабатываний CA2012 (часто ноль, что само по себе полезная информация). -
Измените объявление. Замените тип возвращаемого значения. Тело метода обычно требует одной правки на каждый
returnматериализованного значения.// Before: .NET 11, C# 14 public ValueTask<User> GetUserAsync(int id) { if (_cache.TryGetValue(id, out var user)) return new ValueTask<User>(user); // synchronous fast path return new ValueTask<User>(LoadFromDbAsync(id)); // wraps a Task } // After: .NET 11, C# 14 public Task<User> GetUserAsync(int id) { if (_cache.TryGetValue(id, out var user)) return Task.FromResult(user); // synchronous fast path return LoadFromDbAsync(id); // already a Task<User> }Для метода с ключевым словом
asyncменяется только сигнатура; остальное переписывает компилятор:// Before public async ValueTask<int> CountAsync(CancellationToken ct) { await Task.Delay(5, ct); return 42; } // After: only the return type changed public async Task<int> CountAsync(CancellationToken ct) { await Task.Delay(5, ct); return 42; }Проверить: проект компилируется. Форме с ключевым словом
asyncправки больше не нужны; ручной форме нужно, чтобы каждыйnew ValueTask<T>(...)был переписан какTask.FromResult(...)или возвращал внутреннийTaskнапрямую. -
Обновите объявления интерфейса и базового класса вместе. Если метод пришёл из контракта, меняйте контракт и каждую реализацию за один проход, иначе сборка ломается на полпути.
// Before public interface IUserRepository { ValueTask<User> GetUserAsync(int id); } // After public interface IUserRepository { Task<User> GetUserAsync(int id); }Проверить:
dotnet buildпо всему решению, а не только по одному проекту. Пропущенная реализация всплывает какCS0535(не реализует член интерфейса) илиCS0508(несоответствие типа возвращаемого значения при переопределении). -
Удалите обходные решения
.AsTask()и исправления двойного await. Вот где возврат окупается. Везде, где вызывающее место защищалось от правила единственного await уValueTask, защита теперь — мёртвый код.// Before: caller had to convert because it awaited twice / fanned out ValueTask<User> vt = repo.GetUserAsync(id); Task<User> safe = vt.AsTask(); // required for ValueTask var a = await safe; var b = await safe; // After: Task is awaitable repeatedly; no conversion needed Task<User> t = repo.GetUserAsync(id); var a = await t; var b = await t;Task.WhenAllиTask.WhenAnyтеперь принимают результаты напрямую:// Before: each ValueTask needed .AsTask() before combining await Task.WhenAll(ids.Select(id => repo.GetUserAsync(id).AsTask())); // After await Task.WhenAll(ids.Select(id => repo.GetUserAsync(id)));Проверить: каждое предупреждение CA2012 из шага 1 исчезло, и вы удалили как минимум столько вызовов
.AsTask(), сколько было предупреждений. -
Замените любую обвязку
IValueTaskSource<T>. Если метод возвращал пулированныйValueTask<T>, опирающийся на пользовательскийIValueTaskSource<T>(шаблон, который включаетManualResetValueTaskSourceCore<T>), прямой замены нет. Вы отказываетесь от пулинга, поэтому используйте вместо негоTaskCompletionSource<T>и примите аллокацию, которую сознательно возвращаете.// After: a Task source replaces the pooled IValueTaskSource<T> private readonly TaskCompletionSource<int> _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task<int> WaitForValueAsync() => _tcs.Task; public void Complete(int value) => _tcs.TrySetResult(value);Флаг
RunContinuationsAsynchronouslyважен: без негоTrySetResultвыполняет продолжение встроенно на завершающем потоке, что может привести к взаимной блокировке или истощить пул потоков так же, как синхронная блокировка. Это единственный шаг, на котором возврат действительно чего-то вам стоит, поэтому делайте его, только если пулинг никогда не был оправдан бенчмарком. Проверить: тип больше не реализуетIValueTaskSource<T>, и стресс-тест, завершающий операцию тысячи раз, по-прежнему проходит без проблем повторного входа.
Проверка после замены
Прогоните этот чеклист от начала до конца, прежде чем считать миграцию завершённой:
dotnet buildчист, CA2012 на уровнеwarningи ноль срабатываний.dotnet testпроходит без новых сбоев, особенно вокруг кода, который раньше кешировал ожидаемое.- Проход
BenchmarkDotNetс[MemoryDiagnoser]из предполёта показывает ожидаемую дельту аллокаций. Если синхронно завершающийся горячий путь теперь аллоцирует одинTask<T>на вызов (24 байта на 64 битах), и этот путь выполняется миллионы раз в секунду, у вас есть доказательство, чтоValueTaskзаслуживал своего места, и возврат был ошибкой. Если цифры ровные, возврат был корректностью даром. - Прочешите дифф на любые оставшиеся
new ValueTask,.AsTask()илиValueTask<, которые вы пропустили.
План отката
Эта миграция полностью обратима на уровне исходного кода и малорискованна для отмены: смена Task<T> обратно на ValueTask<T> — та же механическая правка в обратную сторону. Единственная оговорка — случай публичного API. Если вы поставили версию с Task<T> в выпущенном пакете NuGet, возврат к ValueTask<T> — ещё одно бинарно несовместимое изменение, так что внешние потребители перекомпилируются дважды. У внутреннего кода такого ограничения нет; держите миграцию в ветке и откатите коммит, если бенчмарк говорит, что вы вызвали регрессию.
Подводные камни, на которые мы наткнулись
Task.FromResult не бесплатен для ссылочных типов, которые вы и так аллоцируете. Task.FromResult(value) всё равно аллоцирует Task<T> для произвольного значения. Среда выполнения кеширует задачи для Task.FromResult(true), false и небольших целых чисел, но не для вашего User. Если вы возвращаетесь именно потому, что метод теперь редко завершается синхронно, это неважно; если он по-прежнему завершается синхронно в большинстве случаев, эта аллокация — ровно то, чего избегал ValueTask. Измеряйте, прежде чем считать возврат безвредным.
async поверх синхронного тела заново вводит конечный автомат. Переписывание return new ValueTask<T>(cachedValue) как async-метода, возвращающего cachedValue, добавляет аллокацию конечного автомата поверх Task<T>. Держите быстрый путь без async: возвращайте Task.FromResult(...) из обычного метода, ровно как в шаге 2. То же рассуждение, из-за которого ConfigureAwait по-прежнему важен в .NET 11, применимо и здесь: самый дешёвый асинхронный путь — тот, что никогда не строит конечный автомат.
Семантика отмены не меняется, но проверьте её всё равно. И Task<T>, и ValueTask<T> выставляют отмену как сбойное/отменённое ожидаемое; возврат не меняет того, как протекает CancellationToken. Тем не менее перепроверьте свои пути отмены, потому что переписывание затрагивает каждый оператор возврата. Если обработка отмены и так была хрупкой, см. как отменить долго выполняющуюся Task без взаимной блокировки.
IAsyncEnumerable<T> — единственное место, где стоит оставить ValueTask. IAsyncEnumerator<T>.MoveNextAsync возвращает ValueTask<bool> по замыслу, а DisposeAsync возвращает ValueTask. Не “возвращайте” их обратно; механизм async-потоков построен на переиспользовании подложечного источника между итерациями, и это хрестоматийный случай, где ValueTask окупается. Если вы работаете с потоками, использование IAsyncEnumerable
Fire-and-forget скрывал двойное потребление. Шаблон, который мы нашли при возврате: ValueTask присваивался полю и наблюдался позже, что нелегально и тихо портило результаты под нагрузкой. Переход на Task<T> сделал это легальным, но правильным исправлением было вовсе отказаться от fire-and-forget. Если вы это видите, прочтите как безопасно выполнять fire-and-forget-работу, прежде чем замазывать сменой типа, и следите за ObjectDisposedException на освобождённом контексте, которую тот же шаблон fire-and-forget склонен порождать.
Честное резюме: возврат ValueTask<T> к Task<T> — правильный выбор по умолчанию для кода, который принял структуру без профайлера в руках. Вы меняете микрооптимизацию, которой вы, вероятно, и не получали, на тип, которым вся ваша команда может пользоваться, не читая список правил. Держите ValueTask ровно там, где данные говорят, что он заслуживает своего места (горячие пути синхронного завершения и async-потоки), и пусть Task<T> несёт всё остальное.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.