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:
- Единственная ссылка. Это одно из трёх:
null(результат уже в_result),Task<TResult>илиIValueTaskSource<TResult>. Отсюда и «хранит одно из трёх». - Результат синхронного пути лежит прямо в структуре — аллокации нет.
- Токен (версия) для
IValueTaskSource: чтобы источник мог отличить «свою» операцию от более новой. УTaskи у готового значения он 0. - Нужен только
ConfigureAwait:ConfigureAwait(false)создаёт копию структуры сfalseв этом поле. - Готовое значение — завершён всегда.
- Обёрнутый
Task— отвечает самTask. - Источник — спрашиваем его, передавая токен.
- Из готового значения
AsTask()создаёт новыйTask: это аллокация, ради которой мы и бралиValueTask. - Из
Task— тот же объект, без копий. - Из источника — отдельный
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, как мы только что видели.
- Если
IsCompletedвернулtrue, машина состояний вообще не приостанавливается: это синхронный путь из главы 2. GetResult— этоResultиз листинга выше. Для источника он вызываетGetResult(token)у источника.- Внутри
Task— обычное продолжение на задаче (главы 3 и 5). - Внутри источник — подписка на источнике:
OnCompleted(callback, state, token, flags). Что он сделает с колбэком, решает сам источник (поэтому у каждого источника своя семантика потока и контекста). - Сюда
awaitне доходит: при_obj == nullIsCompletedвсегдаtrue. Ветка нужна для ручных вызововOnCompleted.
AsyncValueTaskMethodBuilder<T>: почему async ValueTask без приостановки не аллоцирует¶
Для async ValueTask<int> компилятор берёт не AsyncTaskMethodBuilder<int>, а AsyncValueTaskMethodBuilder<int>. Вот его ключевые члены:
- Лишнее поле против
AsyncTaskMethodBuilder<T>: сюда кладётся результат синхронного завершения. - Метод завершился, не приостановившись: возвращаем
ValueTaskс готовым значением. НиTask, ни бокса. - Метод приостановился: значит, понадобится настоящий
Task<T>(и бокс машины состояний вокруг него, как в главе 1). SetResultдо того, как кто-то запросилTask: запоминаем значение, аm_taskставим в «сторожевой» объект.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>¶
Строка 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> ждём дважды; в промежутке источник (если он есть) успевает начать новую операцию.
готовое значение (_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:
- Сначала бокс ищется в кеше потока: ни блокировок, ни атомарных операций.
- Потом в общем кеше на ядро (по одному боксу на ядро).
- Не нашли — создаём новый. Значит, пул не растёт без предела: для каждого типа машины состояний в кеше лежит не больше одного бокса на поток и одного на ядро, остальные собирает GC.
Reset()увеличивает версиюManualResetValueTaskSourceCore— именно поэтому устаревший токен после возврата бросит исключение.- Бокс возвращается в кеш в тот момент, когда читатель забрал результат. Это и есть причина правила «
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. Бенчмарк¶
Запуск: 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¶
- Если метод почти всегда синхронен, подумайте о
ValueTask. Если он синхронен не почти всегда, выигрыш пропадает: после приостановки внутри всё тот жеTask, и платите вы ещё и за 16 байт структуры. - Не оборачивайте синхронное в
asyncбез нужды (async+ одинreturn await= лишняя машина). - Элизия async:
return task;вместоreturn await task;, если нетusing/tryи последующей логики. Экономит машину состояний, но меняет момент освобождения ресурсов и стек. - Не держите в локальных переменных крупные объекты через
await: они хранятся в боксе, пока метод не закончится (глава 1). - Кешируйте часто используемые завершённые задачи (
Task.CompletedTask, своиTask<bool>). - Не вызывайте
Task.Runвокруг I/O. ConfigureAwait(false)в библиотеке — про контекст, а не про скорость (глава 5).- Не создавайте тысячи одновременных задач без ограничения:
Parallel.ForEachAsync,SemaphoreSlim,Channel. - Не принимайте
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.
| final/Program.cs | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | |