Start Debugging

Миграция с 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>, который асинхронный метод аллоцирует в куче даже тогда, когда завершается синхронно. Это реальная стоимость на горячих путях, которые почти всегда завершаются без ожидания (попадание в кеш, буферизованное чтение, мемоизированное вычисление). Но тип расплачивается за этот выигрыш контрактом, который легко нарушить, а у большинства кодовых баз, которые к нему тянутся, проблемы аллокации не было вовсе. Конкретные причины вернуться назад:

Если вы взвешиваете обратное направление или ещё решаете, место ли 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, где смена типа возвращаемого значения — бинарный слом. Всё остальное механично.

Предполётный чеклист

Прежде чем тронуть хоть одну сигнатуру:

Шаги миграции

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

  1. Превратите 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 (часто ноль, что само по себе полезная информация).

  2. Измените объявление. Замените тип возвращаемого значения. Тело метода обычно требует одной правки на каждый 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 напрямую.

  3. Обновите объявления интерфейса и базового класса вместе. Если метод пришёл из контракта, меняйте контракт и каждую реализацию за один проход, иначе сборка ломается на полпути.

    // Before
    public interface IUserRepository
    {
        ValueTask<User> GetUserAsync(int id);
    }
    
    // After
    public interface IUserRepository
    {
        Task<User> GetUserAsync(int id);
    }

    Проверить: dotnet build по всему решению, а не только по одному проекту. Пропущенная реализация всплывает как CS0535 (не реализует член интерфейса) или CS0508 (несоответствие типа возвращаемого значения при переопределении).

  4. Удалите обходные решения .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(), сколько было предупреждений.

  5. Замените любую обвязку 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>, и стресс-тест, завершающий операцию тысячи раз, по-прежнему проходит без проблем повторного входа.

Проверка после замены

Прогоните этот чеклист от начала до конца, прежде чем считать миграцию завершённой:

План отката

Эта миграция полностью обратима на уровне исходного кода и малорискованна для отмены: смена 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 с EF Core 11 показывает шаблон в контексте.

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.

< Назад