Перейти к содержанию

12. ValueTask и производительность async

О главе

Цель: понять, из чего сделан ValueTask<T>, когда он выгоден, какие у него правила и почему они такие; увидеть цену реальной приостановки и что даёт пулинг builder'а.

Лабораторная: start/ — бенчмарк из §12.4. final/ — плюс свой IValueTaskSource на ManualResetValueTaskSourceCore, пулинг builder'а, режим misuse (повторный await на разных «основах») и режим cost (размеры и цена без BenchmarkDotNet). Запуск: dotnet run -c Release --project final -- misuse (или -- cost, или -- --filter * для бенчмарков).

Статус: ✅ проверено на стенде Ubuntu 26.04 (2 ядра, рантайм 10.0.12). Перепроверено на Windows 11 (8 ядер): аллокации совпадают, времена разные — вкладки «Linux» и «Windows» в §12.3–12.4.

12.1. Из чего сделан ValueTask<T>

Task<T> — класс: каждый новый экземпляр это аллокация (в нашем замере 72 байта для значения вне кеша малых чисел). Если метод часто завершается синхронно (кеш, буфер, уже прочитанные данные), аллокации лишние. ValueTask<T> — структура. Вот её поля и основные члены из CoreLib 10.0.12:

CoreLib 10.0.12: ValueTask<TResult> (сокращено)
public readonly struct ValueTask<TResult>
{
    internal readonly object _obj;                    // (1)!
    internal readonly TResult _result;                // (2)!
    internal readonly short _token;                   // (3)!
    internal readonly bool _continueOnCapturedContext;// (4)!

    public bool IsCompleted
    {
        get
        {
            object obj = _obj;
            if (obj == null) return true;                                   // (5)!
            if (obj is Task<TResult> task) return task.IsCompleted;         // (6)!
            return Unsafe.As<IValueTaskSource<TResult>>(obj)
                .GetStatus(_token) != ValueTaskSourceStatus.Pending;        // (7)!
        }
    }

    public TResult Result
    {
        get
        {
            object obj = _obj;
            if (obj == null) return _result;
            if (obj is Task<TResult> task)
            {
                TaskAwaiter.ValidateEnd(task);
                return task.ResultOnSuccess;
            }
            return Unsafe.As<IValueTaskSource<TResult>>(obj).GetResult(_token);
        }
    }

    public Task<TResult> AsTask()
    {
        object obj = _obj;
        if (obj == null) return Task.FromResult(_result);                   // (8)!
        if (obj is Task<TResult> result) return result;                     // (9)!
        return GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource<TResult>>(obj));  // (10)!
    }
}
  1. Единственная ссылка. Это одно из трёх: null (результат уже в _result), Task<TResult> или IValueTaskSource<TResult>. Отсюда и «хранит одно из трёх».
  2. Результат синхронного пути лежит прямо в структуре — аллокации нет.
  3. Токен (версия) для IValueTaskSource: чтобы источник мог отличить «свою» операцию от более новой. У Task и у готового значения он 0.
  4. Нужен только ConfigureAwait: ConfigureAwait(false) создаёт копию структуры с false в этом поле.
  5. Готовое значение — завершён всегда.
  6. Обёрнутый Task — отвечает сам Task.
  7. Источник — спрашиваем его, передавая токен.
  8. Из готового значения AsTask() создаёт новый Task: это аллокация, ради которой мы и брали ValueTask.
  9. Из Task — тот же объект, без копий.
  10. Из источника — отдельный ValueTaskSourceAsTask, который подписывается на источник один раз и после этого ведёт себя как обычный Task.

В нашей лаборатории Unsafe.SizeOf<ValueTask<int>>() — 16 байт: ссылка (8) + int (4) + short (2) + bool (1) + выравнивание. Структура передаётся по значению, поэтому для большого T копирование начинает стоить заметных денег.

public ValueTask<int> ReadAsync(Memory<byte> buffer)
{
    if (_buffered > 0) return new ValueTask<int>(CopyFromBuffer(buffer));   // быстрый путь: _obj = null, без Task
    return new ValueTask<int>(ReadFromSocketAsync(buffer));                 // медленный путь: _obj = Task
}

Как await понимает ValueTask

Awaiter у ValueTask<T> — тоже структура и тоже не знает ничего нового: он копирует ValueTask себе в поле и проверяет _obj, как мы только что видели.

CoreLib 10.0.12: ValueTaskAwaiter<TResult> (сокращено)
public readonly struct ValueTaskAwaiter<TResult> : ICriticalNotifyCompletion, INotifyCompletion, IStateMachineBoxAwareAwaiter
{
    private readonly ValueTask<TResult> _value;

    public bool IsCompleted => _value.IsCompleted;              // (1)!
    public TResult GetResult() => _value.Result;                // (2)!

    void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox box)
    {
        object obj = _value._obj;
        if (obj is Task<TResult> task)
        {
            TaskAwaiter.UnsafeOnCompletedInternal(task, box, continueOnCapturedContext: true);   // (3)!
        }
        else if (obj != null)
        {
            Unsafe.As<IValueTaskSource<TResult>>(obj).OnCompleted(
                ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token,
                ValueTaskSourceOnCompletedFlags.UseSchedulingContext);                           // (4)!
        }
        else
        {
            TaskAwaiter.UnsafeOnCompletedInternal(Task.CompletedTask, box, continueOnCapturedContext: true);  // (5)!
        }
    }
}
  1. Если IsCompleted вернул true, машина состояний вообще не приостанавливается: это синхронный путь из главы 2.
  2. GetResult — это Result из листинга выше. Для источника он вызывает GetResult(token) у источника.
  3. Внутри Task — обычное продолжение на задаче (главы 3 и 5).
  4. Внутри источник — подписка на источнике: OnCompleted(callback, state, token, flags). Что он сделает с колбэком, решает сам источник (поэтому у каждого источника своя семантика потока и контекста).
  5. Сюда await не доходит: при _obj == null IsCompleted всегда true. Ветка нужна для ручных вызовов OnCompleted.

AsyncValueTaskMethodBuilder<T>: почему async ValueTask без приостановки не аллоцирует

Для async ValueTask<int> компилятор берёт не AsyncTaskMethodBuilder<int>, а AsyncValueTaskMethodBuilder<int>. Вот его ключевые члены:

CoreLib 10.0.12: AsyncValueTaskMethodBuilder<TResult> (сокращено)
public struct AsyncValueTaskMethodBuilder<TResult>
{
    internal static readonly Task<TResult> s_syncSuccessSentinel = new Task<TResult>(default(TResult));
    private Task<TResult> m_task;
    private TResult _result;                                                    // (1)!

    public ValueTask<TResult> Task
    {
        get
        {
            if (m_task == s_syncSuccessSentinel)
                return new ValueTask<TResult>(_result);                         // (2)!
            return new ValueTask<TResult>(m_task ?? (m_task = new Task<TResult>()));   // (3)!
        }
    }

    public void SetResult(TResult result)
    {
        if (m_task == null)
        {
            _result = result;                                                   // (4)!
            m_task = s_syncSuccessSentinel;
        }
        else
        {
            AsyncTaskMethodBuilder<TResult>.SetExistingTaskResult(m_task, result);   // (5)!
        }
    }
}
  1. Лишнее поле против AsyncTaskMethodBuilder<T>: сюда кладётся результат синхронного завершения.
  2. Метод завершился, не приостановившись: возвращаем ValueTask с готовым значением. Ни Task, ни бокса.
  3. Метод приостановился: значит, понадобится настоящий Task<T> (и бокс машины состояний вокруг него, как в главе 1).
  4. SetResult до того, как кто-то запросил Task: запоминаем значение, а m_task ставим в «сторожевой» объект.
  5. SetResult после приостановки: завершаем настоящий Task.

Из этого следует то, что показывают замеры ниже: async ValueTask<int>, который не приостанавливается, не аллоцирует вообще (даже машина остаётся на стеке, как в главе 2), а async Task<int> с тем же телом платит 72 байта за Task. Но как только метод приостановился, ValueTask ничего не экономит: внутри обычный Task<T> и обычный бокс.

12.2. Правила обращения и почему они такие

Можно Нельзя
await один раз await дважды
сразу await хранить и ожидать из нескольких потоков одновременно
AsTask() если нужно хранить или ждать много раз (один раз!) брать .Result или GetAwaiter().GetResult() до завершения
await ... .ConfigureAwait(false) передавать в Task.WhenAll без AsTask() (не скомпилируется: ValueTask — не Task)

Причина всех запретов одна: в _obj может лежать не Task, а переиспользуемый источник. После GetResult источник возвращается в пул и обслуживает уже другую операцию, а старый ValueTask по-прежнему указывает на него. Версия (_token) нужна, чтобы источник мог заметить подмену, но обязана ли она сработать, решает автор источника.

Свой источник на ManualResetValueTaskSourceCore<T>

final/Program.cs
// Переиспользуемый источник результата: так устроены, например, сокеты в .NET.
public sealed class ReusableSource : IValueTaskSource<int>
{
    private ManualResetValueTaskSourceCore<int> _core;   // изменяемая структура: не readonly!

    public ValueTask<int> Start()
    {
        _core.Reset();                                   // новая операция: Version++
        return new ValueTask<int>(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?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) =>
        _core.OnCompleted(continuation, state, token, flags);
}

Строка 65: ManualResetValueTaskSourceCore<int> — изменяемая структура; в readonly-поле она сломалась бы (копия вместо изменения). Строка 69: Reset() увеличивает Version; этот номер уходит в ValueTask как токен (строка 70). Строки 75–78 — три метода IValueTaskSource<int> просто делегируют ядру. Так устроены источники в BCL: Socket, Channel<T> (глава 11), PipeReader и т. д.

Опыт: что будет при повторном await (dotnet run -c Release --project final -- misuse)

Один и тот же ValueTask<int> ждём дважды; в промежутке источник (если он есть) успевает начать новую операцию.

Ubuntu 26.04, 2 ядра, .NET 10.0.12; три запуска дали одно и то же
готовое значение (_obj = null): 7, затем 7
завершённый Task: 7, затем 7
async-метод, приостановился (Task внутри): 7, затем 7
async-метод с PoolingAsyncValueTaskMethodBuilder: 7, затем InvalidOperationException
ReusableSource, другая операция уже началась: 7, затем InvalidOperationException
ReusableSource: .Result до завершения: InvalidOperationException
AsTask: 42, 42, тип ValueTaskSourceAsTask
AsTask после переиспользования источника: 42

Факт: повторный await «работает» ровно до тех пор, пока внутри Task

Вывод на Windows 11 (8 ядер) в двух запусках совпал с Linux построчно: поведение определяется кодом BCL, а не ОС.

Первые три строки не доказывают, что так можно: они работают потому, что Task и готовое значение неизменяемы. Стоит заменить реализацию метода на пулинговую (строка 4) или источник (строка 5) — и тот же код бросает InvalidOperationException. Поэтому «у меня работает» про ValueTask ничего не значит: правило «один await» — контракт типа, а не наблюдение.

Но и защита не гарантирована. ManualResetValueTaskSourceCore проверяет токен и бросает исключение, потому что так написан. Источник, который токен не проверяет, молча вернёт чужой результат. Подробнее: CA2012.

Факт: .Result на незавершённом ValueTask из источника бросает, а не ждёт

У Task.Result блокировка — часть контракта. У IValueTaskSource.GetResult — наоборот: вызывать можно только после завершения. Наш ManualResetValueTaskSourceCore в этом случае бросает InvalidOperationException (строка 6 вывода). Для ValueTask, внутри которого Task, .Result заблокирует поток, как обычно. То есть поведение зависит от содержимого, которое снаружи не видно, — ещё одна причина не трогать .Result.

Факт: AsTask() — «выход» из правил

AsTask() у ValueTask с источником (строка 7) создаёт ValueTaskSourceAsTask, который вызывает GetResult источника один раз при завершении. После этого получившийся Task можно ждать сколько угодно раз и хранить (строки 7–8: 42, 42), а источник можно сразу отдавать следующей операции. Цена — аллокация Task, ради избавления от которой всё и затевалось. Вызывать AsTask() можно один раз на ValueTask: сам ValueTask после него считается использованным.

12.3. Пулинг самого async-метода

В главе 1 мы видели, что приостановка кладёт машину состояний в бокс (кучу). Для горячих путей можно заставить компилятор использовать другой builder, который берёт бокс из пула:

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<int> HotAsync() { ... }

Как это устроено в CoreLib 10.0.12:

CoreLib 10.0.12: PoolingAsyncValueTaskMethodBuilder<TResult>.StateMachineBox<TStateMachine> (сокращено)
private sealed class StateMachineBox<TStateMachine> : StateMachineBox, IValueTaskSource<TResult>, IValueTaskSource, IAsyncStateMachineBox, IThreadPoolWorkItem
    where TStateMachine : IAsyncStateMachine
{
    [ThreadStatic]
    private static StateMachineBox<TStateMachine> t_tlsCache;                       // (1)!
    private static readonly PaddedReference[] s_perCoreCache = new PaddedReference[Environment.ProcessorCount];   // (2)!

    public TStateMachine StateMachine;

    internal static StateMachineBox<TStateMachine> RentFromCache()
    {
        StateMachineBox<TStateMachine> stateMachineBox = t_tlsCache;
        if (stateMachineBox != null)
        {
            t_tlsCache = null;
        }
        else
        {
            ref StateMachineBox<TStateMachine> perCoreCacheSlot = ref PerCoreCacheSlot;
            if (perCoreCacheSlot == null || (stateMachineBox = Interlocked.Exchange(ref perCoreCacheSlot, null)) == null)
            {
                stateMachineBox = new StateMachineBox<TStateMachine>();             // (3)!
            }
        }
        return stateMachineBox;
    }

    private void ReturnToCache()
    {
        ClearStateUponCompletion();
        _valueTaskSource.Reset();                                                   // (4)!
        if (t_tlsCache == null) { t_tlsCache = this; return; }
        ref StateMachineBox<TStateMachine> perCoreCacheSlot = ref PerCoreCacheSlot;
        if (perCoreCacheSlot == null) Volatile.Write(ref perCoreCacheSlot, this);
    }

    TResult IValueTaskSource<TResult>.GetResult(short token)
    {
        try { return _valueTaskSource.GetResult(token); }
        finally { ReturnToCache(); }                                                // (5)!
    }
}
  1. Сначала бокс ищется в кеше потока: ни блокировок, ни атомарных операций.
  2. Потом в общем кеше на ядро (по одному боксу на ядро).
  3. Не нашли — создаём новый. Значит, пул не растёт без предела: для каждого типа машины состояний в кеше лежит не больше одного бокса на поток и одного на ядро, остальные собирает GC.
  4. Reset() увеличивает версию ManualResetValueTaskSourceCore — именно поэтому устаревший токен после возврата бросит исключение.
  5. Бокс возвращается в кеш в тот момент, когда читатель забрал результат. Это и есть причина правила «await один раз»: к этому моменту бокс уже мог уйти другому вызову.

Бокс здесь сам является IValueTaskSource<TResult>: это тот же класс, что мы писали руками в ReusableSource.

Факт: пулинг убирает аллокацию приостановки, но не саму приостановку

Режим cost (dotnet run -c Release --project final -- cost; трижды): 200 000 вызовов метода, который на каждом делает await Task.Yield(), подряд из одного цикла. Рантайм 10.0.12, цикл прогревается по времени (иначе меряется неоптимизированный код Tier-0), но замеры всё равно шумят, поэтому смотрите на байты:

Вариант Б/вызов нс/вызов (диапазон трёх запусков)
async ValueTask<int> 104 420–680
async ValueTask<int> + PoolingAsyncValueTaskMethodBuilder 0 320–640
async Task<int> 96 380–740
await Task.Yield() в цикле (без вызова метода) 0 220–280
Вариант Б/вызов нс/вызов (диапазон трёх запусков)
async ValueTask<int> 104 660–750
async ValueTask<int> + PoolingAsyncValueTaskMethodBuilder 0 640–720
async Task<int> 96 580–690
await Task.Yield() в цикле (без вызова метода) 0 450–490

Байты одинаковы на обеих ОС (104, 0, 96, 0): это размеры объектов .NET, ОС тут ни при чём. Время на Windows примерно вдвое больше для Task.Yield (≈ 450 против ≈ 250 нс). Причина не выяснена: гипотеза — разный путь пробуждения потока пула (futex на Linux, события/порты на Windows) и разный планировщик ОС; профилем не проверялось. Разница между вариантами и там и там внутри шума.

Выигрыш пулинга — ровно в аллокациях: 104 → 0 байт. Время не выросло (в трёх запусках пулинговый вариант был не медленнее остальных, но разница в пределах шума), и на порядок лучше не стало: основное время тратит сама приостановка и возобновление через пул потоков (глава 13).

Обычный async ValueTask<int> при приостановке весит на 8 байт больше async Task<int> (104 против 96): тот же бокс, но builder больше на поле _result (см. листинг выше), и это поле лежит внутри бокса.

Факт: DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS=1 в .NET 10 ничего не делает

В первой версии курса стояла рекомендация включать пулинг глобально этой переменной окружения. Проверка: в декомпилированных AsyncValueTaskMethodBuilder<T> и PoolingAsyncValueTaskMethodBuilder<T> (CoreLib 10.0.12) нет ни чтения этой переменной, ни переключателя; в cost с DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS=1 обычный async ValueTask<int> по-прежнему аллоцирует 104 байта. Глобального переключателя нет: пулинг включается только атрибутом [AsyncMethodBuilder] на конкретном методе.

Пулинг экономит аллокации, но делает нарушение правил ValueTask ещё опаснее (см. опыт выше: строка 4). Включать только по профилированию.

12.4. Бенчмарк

start/Program.cs
#pragma warning disable CS1998   // async без await: нарочно
[MemoryDiagnoser]
public class AsyncBench
{
    [Benchmark(Baseline = true)]
    public Task<int> TaskFromResult() => Task.FromResult(1000);

    [Benchmark]
    public async Task<int> AsyncTaskSync() => 1000;

    [Benchmark]
    public ValueTask<int> ValueTaskSync() => new ValueTask<int>(1000);

    [Benchmark]
    public async ValueTask<int> AsyncValueTaskSync() => 1000;

    [Benchmark]
    public async Task<int> AsyncTaskRealSuspend() { await Task.Yield(); return 1000; }
}

Запуск: dotnet run -c Release --project start -- --filter *. (BenchmarkDotNet требует Release и не запускается под отладчиком.) Версия final добавляет AsyncValueTaskRealSuspend, PooledValueTaskRealSuspend и ReusableSourceAwait.

Факт: результаты final, --job short, .NET 10.0.12

Метод Mean Allocated
TaskFromResult ≈ 10 нс 72 B
AsyncTaskSync ≈ 22 нс 72 B
ValueTaskSync ≈ 0 нс (неотличимо от пустого метода) 0
AsyncValueTaskSync ≈ 10 нс 0
AsyncTaskRealSuspend ≈ 10–13 мкс 96 B
AsyncValueTaskRealSuspend ≈ 13 мкс 104 B
PooledValueTaskRealSuspend ≈ 9–10 мкс 0
ReusableSourceAwait ≈ 20 нс 0
Метод Mean Allocated
TaskFromResult ≈ 5 нс 72 B
AsyncTaskSync ≈ 11 нс 72 B
ValueTaskSync ≈ 0 нс (неотличимо от пустого метода) 0
AsyncValueTaskSync ≈ 7 нс 0
AsyncTaskRealSuspend ≈ 0,86 мкс 96 B
AsyncValueTaskRealSuspend ≈ 0,99 мкс 104 B
PooledValueTaskRealSuspend ≈ 0,96 мкс 0
ReusableSourceAwait ≈ 23 нс 0

Совпадает на обеих ОС: аллокации (72, 96, 104, 0 байт), соотношение «синхронный путь против реальной приостановки» (на порядки), async Task с синхронным завершением вдвое медленнее Task.FromResult.

Отличается: реальная приостановка на Windows в 10–13 раз быстрее (≈ 0,9 мкс против 10–13 мкс), а синхронные пути быстрее примерно вдвое (процессор стенда Windows просто быстрее). Объяснение для приостановки — в плашке ниже: бенчмарк меряет не async, а пробуждение блокированного потока. Windows просыпается быстрее причина не выяснена: отличаются ядра (8 против 2), процессоры и планировщики ОС; на одинаковом железе не сравнивалось. Сравнивайте варианты внутри одной ОС, а не цифры между ОС.

  • ValueTask, завершённый синхронно: ноль аллокаций, как и обещано; AsyncValueTaskSync дороже ValueTaskSync на машину состояний, но она остаётся на стеке (глава 2).
  • async Task<int> с синхронным завершением в два раза медленнее Task.FromResult (22 против 10 нс), аллокация та же.
  • Объект Task<int> весит 72 байта; «24», как можно было бы предположить, это не про Task.
  • ReusableSourceAwait (наш источник) тоже ноль байт: источник завершён до await, IsCompleted == true, метод не приостанавливается, и работает та же ветка «сторожевого» объекта.
  • Реальная приостановка на три порядка дороже синхронного пути: 10–13 мкс против 10–20 нс.

Под капотом: откуда 10 мкс, если цикл из cost даёт меньше мкс?

В BenchmarkDotNet каждый вызов метода ждёт Task через блокирующий GetResult из основного потока: продолжение уходит в пул потоков, поток пула просыпается, завершает задачу, будит основной поток, который заблокирован. Это две передачи управления между потоками на каждую итерацию. В cost весь цикл крутится в продолжениях, ждущий поток один: цена одной итерации — 0,3–0,7 мкс (в зависимости от прогона). Вывод: «цена приостановки» в бенчмарке с блокирующим ожиданием в основном цена пробуждения потоков, а не async. На Windows этот довесок почти исчезает: бенчмарк даёт ≈ 0,9 мкс на итерацию против 0,45–0,75 мкс в cost, то есть пробуждение ждущего потока там стоит ≈ 0,2–0,4 мкс, а не ≈ 10 мкс, как на нашем Linux-стенде. В первой версии курса на другом стенде было ≈ 1,4 мкс; сравнимые цифры нужно снимать тем же способом на том же стенде.

Это подтверждает §3.4: дорога не async, а реальная приостановка.

12.5. Чек-лист производительности async

  1. Если метод почти всегда синхронен, подумайте о ValueTask. Если он синхронен не почти всегда, выигрыш пропадает: после приостановки внутри всё тот же Task, и платите вы ещё и за 16 байт структуры.
  2. Не оборачивайте синхронное в async без нужды (async + один return await = лишняя машина).
  3. Элизия async: return task; вместо return await task;, если нет using/try и последующей логики. Экономит машину состояний, но меняет момент освобождения ресурсов и стек.
  4. Не держите в локальных переменных крупные объекты через await: они хранятся в боксе, пока метод не закончится (глава 1).
  5. Кешируйте часто используемые завершённые задачи (Task.CompletedTask, свои Task<bool>).
  6. Не вызывайте Task.Run вокруг I/O.
  7. ConfigureAwait(false) в библиотеке — про контекст, а не про скорость (глава 5).
  8. Не создавайте тысячи одновременных задач без ограничения: Parallel.ForEachAsync, SemaphoreSlim, Channel.
  9. Не принимайте ValueTask за «быстрый Task»: если результат нужно ждать несколько раз, хранить в коллекции или объединять WhenAll, вызывайте AsTask() один раз.

Итоги

  • ValueTask<T> — структура из ссылки _obj (null / Task / источник), результата, токена и флага; 16 байт для int.
  • Синхронный путь у async ValueTask не аллоцирует, потому что builder запоминает результат в себе и отдаёт готовый ValueTask.
  • Приостановка стоит одинаково, с ValueTask или без; вместо Task выигрыш даёт только пулинг (104 → 0 байт) и только с оговорками.
  • Все правила ValueTask (один await, не .Result) следуют из того, что источник и пулинговый бокс переиспользуются; защиту обеспечивает токен, а не гарантия языка.
  • Переменная окружения DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS в .NET 10 не работает.

Код лабораторной

Запуск из папки главы: dotnet run -c Release --project start или --project final.

start/Program.cs
// Глава 12, §12.4. Цена async: синхронное завершение против реальной приостановки.
// PREDICT: у каких методов Allocated = 0? Во сколько раз реальная приостановка дороже?
// TODO 1: добавьте async ValueTask<int> с await Task.Yield() — на сколько байт он отличается от async Task<int>?
// TODO 2: добавьте тот же метод с [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] и сравните Allocated.
// Запуск: dotnet run -c Release -- --filter *
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(AsyncBench).Assembly).Run(args);

#pragma warning disable CS1998   // async без await: нарочно
[MemoryDiagnoser]
public class AsyncBench
{
    [Benchmark(Baseline = true)]
    public Task<int> TaskFromResult() => Task.FromResult(1000);

    [Benchmark]
    public async Task<int> AsyncTaskSync() => 1000;

    [Benchmark]
    public ValueTask<int> ValueTaskSync() => new ValueTask<int>(1000);

    [Benchmark]
    public async ValueTask<int> AsyncValueTaskSync() => 1000;

    [Benchmark]
    public async Task<int> AsyncTaskRealSuspend() { await Task.Yield(); return 1000; }
}
final/Program.cs
// Глава 12, итог.
//   dotnet run -c Release -- --filter *   — бенчмарки (§12.4 + свой IValueTaskSource + пулинг builder'а)
//   dotnet run -c Release -- misuse       — что бывает при повторном await одного ValueTask (на разных «основах»)
//   dotnet run -c Release -- cost         — размер структуры, аллокации и цена приостановки без BenchmarkDotNet
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Tasks.Sources;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

switch (args.FirstOrDefault())
{
    case "misuse":
        await Misuse.RunAsync();
        return;
    case "cost":
        await Cost.RunAsync();
        return;
}

BenchmarkSwitcher.FromAssembly(typeof(AsyncBench).Assembly).Run(args);

#pragma warning disable CS1998   // async без await: нарочно
[MemoryDiagnoser]
public class AsyncBench
{
    private readonly ReusableSource _source = new();

    [Benchmark(Baseline = true)]
    public Task<int> TaskFromResult() => Task.FromResult(1000);

    [Benchmark]
    public async Task<int> AsyncTaskSync() => 1000;

    [Benchmark]
    public ValueTask<int> ValueTaskSync() => new ValueTask<int>(1000);

    [Benchmark]
    public async ValueTask<int> AsyncValueTaskSync() => 1000;

    [Benchmark]
    public async Task<int> AsyncTaskRealSuspend() { await Task.Yield(); return 1000; }

    [Benchmark]
    public async ValueTask<int> AsyncValueTaskRealSuspend() { await Task.Yield(); return 1000; }

    // Тот же метод, но бокс машины состояний берётся из пула (§12.3).
    [Benchmark]
    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
    public async ValueTask<int> PooledValueTaskRealSuspend() { await Task.Yield(); return 1000; }

    // ValueTask поверх переиспользуемого источника: ни Task, ни бокса.
    [Benchmark]
    public async ValueTask<int> ReusableSourceAwait()
    {
        ValueTask<int> vt = _source.Start();
        _source.Complete(1000);
        return await vt;
    }
}

// Переиспользуемый источник результата: так устроены, например, сокеты в .NET.
public sealed class ReusableSource : IValueTaskSource<int>
{
    private ManualResetValueTaskSourceCore<int> _core;   // изменяемая структура: не readonly!

    public ValueTask<int> Start()
    {
        _core.Reset();                                   // новая операция: Version++
        return new ValueTask<int>(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?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) =>
        _core.OnCompleted(continuation, state, token, flags);
}

static class Misuse
{
    public static async Task RunAsync()
    {
        // 1. Один и тот же ValueTask ждут дважды. Что случится, зависит от того, что внутри.
        await Twice("готовое значение (_obj = null)", () => new ValueTask<int>(7));
        await Twice("завершённый Task", () => new ValueTask<int>(Task.FromResult(7)));
        await Twice("async-метод, приостановился (Task внутри)", () => Suspending());
        await Twice("async-метод с PoolingAsyncValueTaskMethodBuilder", () => PooledSuspending());

        var source = new ReusableSource();
        await Twice("ReusableSource, другая операция уже началась", () =>
        {
            ValueTask<int> vt = source.Start();
            source.Complete(7);
            return vt;
        }, between: () => source.Start());

        // 2. Результат до завершения: .Result / GetResult().
        var pending = new ReusableSource();
        ValueTask<int> notDone = pending.Start();
        Try("ReusableSource: .Result до завершения", () => notDone.Result);

        // 3. AsTask() один раз превращает источник в обычный Task: его можно ждать сколько угодно.
        var src2 = new ReusableSource();
        ValueTask<int> vt2 = src2.Start();
        Task<int> asTask = vt2.AsTask();
        src2.Complete(42);
        Console.WriteLine($"AsTask: {await asTask}, {await asTask}, тип {asTask.GetType().Name}");
        src2.Start();   // источник свободен для новой операции — Task от этого не пострадал
        Console.WriteLine($"AsTask после переиспользования источника: {await asTask}");
    }

    static async Task Twice(string name, Func<ValueTask<int>> make, Action? between = null)
    {
        ValueTask<int> vt = make();
        int first = await vt;
        between?.Invoke();
        try
        {
            int second = await vt;
            Console.WriteLine($"{name}: {first}, затем {second}");
        }
        catch (Exception e)
        {
            Console.WriteLine($"{name}: {first}, затем {e.GetType().Name}");
        }
    }

    static void Try(string name, Func<int> f)
    {
        try { Console.WriteLine($"{name}: {f()}"); }
        catch (Exception e) { Console.WriteLine($"{name}: {e.GetType().Name}"); }
    }

    static async ValueTask<int> Suspending() { await Task.Yield(); return 7; }

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
    static async ValueTask<int> PooledSuspending() { await Task.Yield(); return 7; }
}

static class Cost
{
    const int N = 200_000;

    public static async Task RunAsync()
    {
        Console.WriteLine($"sizeof(ValueTask<int>) = {Unsafe.SizeOf<ValueTask<int>>()}, sizeof(ValueTask) = {Unsafe.SizeOf<ValueTask>()}");
        Console.WriteLine($"PoolAsyncValueTasks (переменная окружения) = {Environment.GetEnvironmentVariable("DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS") ?? "<не задана>"}");

        // Прогрев по времени: пока JIT не перевёл методы с Tier-0 на оптимизированный код
        // (отсчёт многоуровневой компиляции начинается через ~100 мс бездействия), цифры бессмысленны.
        for (int round = 0; round < 3; round++)
        {
            long until = Stopwatch.GetTimestamp() + Stopwatch.Frequency / 3;
            while (Stopwatch.GetTimestamp() < until) { await Yielding(); await PooledYielding(); await YieldingTask(); }
            await Task.Delay(300);
        }

        // Приостановка без пробуждения потока: Task.Yield отправляет продолжение в очередь пула,
        // а поток пула, дойдя до конца, берёт его же. Ждущий поток один раз в начале и один раз в конце.
        await Measure("Task.Yield в цикле внутри одного метода", async () =>
        {
            for (int i = 0; i < N; i++) await Task.Yield();
        });

        await Measure("async ValueTask, каждый вызов приостанавливается", async () =>
        {
            for (int i = 0; i < N; i++) await Yielding();
        });

        await Measure("то же, PoolingAsyncValueTaskMethodBuilder", async () =>
        {
            for (int i = 0; i < N; i++) await PooledYielding();
        });

        await Measure("то же, async Task", async () =>
        {
            for (int i = 0; i < N; i++) await YieldingTask();
        });
    }

    static async Task Measure(string name, Func<Task> body)
    {
        GC.Collect();
        long before = GC.GetTotalAllocatedBytes(precise: true);
        long t0 = Stopwatch.GetTimestamp();
        await Task.Run(body);
        TimeSpan elapsed = Stopwatch.GetElapsedTime(t0);
        long bytes = GC.GetTotalAllocatedBytes(precise: true) - before;
        Console.WriteLine($"{name}: {elapsed.TotalMilliseconds * 1e6 / N,7:F0} нс/оп, {bytes / (double)N,6:F1} Б/оп");
    }

    static async ValueTask<int> Yielding() { await Task.Yield(); return 1; }
    static async Task<int> YieldingTask() { await Task.Yield(); return 1; }

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
    static async ValueTask<int> PooledYielding() { await Task.Yield(); return 1; }
}