Привет, Хабр!
Сегодня мы будем разбирать интересную вещь в C# ValueTask — штука, которая спасет асинхронные методы от лишних аллокаций.
Если коротко, ValueTask — это структура, которая позволяет вернуть либо Task, либо готовый результат. Она появилась в C# 7.0 для снижения накладных расходов при работе с асинхронным кодом.
Простой пример:
public ValueTask<int> GetMagicNumberAsync()
{
if (DateTime.Now.Second % 2 == 0)
{
// Результат готов — возвращаем синхронно
return new ValueTask<int>(42);
}
// Асинхронный результат
return new ValueTask<int>(Task.Run(() => CalculateMagicNumber()));
}
Без ValueTask вы всегда возвращаете объект Task, который создаётся даже для синхронных случаев. А это лишняя аллокация. С ValueTask вы возвращаете либо готовое значение, либо уже существующий Task, экономя ресурсы.
Когда использовать ValueTask?
Есть три сценария, где ValueTask покажет себя во всей красе:
Синхронный результат в большинстве случаев. Например, чтение из кеша.
Высокая нагрузка. Когда важно минимизировать аллокации и повысить производительность.
Методы с высокой частотой вызовов. Например, в real-time системах.
Пример использования
Представим магазин котиков. У нас есть метод, который ищет данные о котике: если он в кеше — возвращаем сразу, если нет — идём в базу.
public class CatService
{
private readonly Dictionary<int, string> _catCache = new();
public async ValueTask<string> GetCatAsync(int id)
{
if (_catCache.TryGetValue(id, out var name))
{
return name; // Возвращаем синхронно
}
name = await FetchCatFromDatabaseAsync(id); // Эмуляция долгой операции
_catCache[id] = name; // Кэшируем результат
return name;
}
private async Task<string> FetchCatFromDatabaseAsync(int id)
{
await Task.Delay(100); // Считаем, что это запрос в базу
return $"Котик с ID {id}";
}
}
Если котик в кеше, мы возвращаем результат сразу, без лишних аллокаций. Если данных нет, мы идём в базу, а затем кэшируем результат.
Частые грабли
ValueTask — инструмент мощный, но с ним легко наломать дров. Типичные ошибки:
1. Нельзя ждать один и тот же ValueTask дважды
var task = service.GetCatAsync(1);
await task;
await task; // ValueTask больше так не работает.
ValueTask можно "await-ить" только один раз. Если надо несколько — преобразуйте в Task:
var task = service.GetCatAsync(1).AsTask();
await task;
await task; // Теперь работает.
2. Использование .Result до завершения операции
var task = service.GetCatAsync(1);
Console.WriteLine(task.Result); // Исключение: результат ещё не готов.
ValueTask нельзя читать через .Result, пока он не завершён. Это приведёт к ошибке.
3. Неправильное использование конструктора
public ValueTask<int> BrokenTask()
{
return new ValueTask<int>(Task.CompletedTask); // Ошибка: Task не возвращает TResult.
}
Теперь улучшим пример с котиками
Добавим асинхронный кеш, защиту от гонок и немного логики, чтобы не ломать голову, если что-то пойдет не так:
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
public class CatStore
{
private readonly ConcurrentDictionary<int, string> _cache = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public async ValueTask<string> GetCatNameAsync(int id, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(id, out var cachedName))
{
LogInfo($"Котик с ID {id} найден в кеше.");
return cachedName;
}
await _lock.WaitAsync(cancellationToken);
try
{
if (_cache.TryGetValue(id, out cachedName))
{
LogInfo($"Котик с ID {id} найден в кеше после блокировки.");
return cachedName;
}
var fetchedName = await FetchFromDatabaseAsync(id, cancellationToken);
_cache.TryAdd(id, fetchedName);
return fetchedName;
}
catch (Exception ex)
{
LogError($"Ошибка при получении данных о котике с ID {id}: {ex.Message}");
throw;
}
finally
{
_lock.Release();
}
}
private async Task<string> FetchFromDatabaseAsync(int id, CancellationToken cancellationToken)
{
try
{
await Task.Delay(100, cancellationToken);
LogInfo($"Запрос в базу данных для котика с ID {id}.");
return $"Котик с ID {id}";
}
catch (TaskCanceledException)
{
LogWarning($"Запрос в базу данных для котика с ID {id} был отменён.");
throw;
}
catch (Exception ex)
{
LogError($"Ошибка при запросе в базу данных: {ex.Message}");
throw;
}
}
private void LogInfo(string message) => Console.WriteLine($"[INFO]: {message}");
private void LogWarning(string message) => Console.WriteLine($"[WARNING]: {message}");
private void LogError(string message) => Console.WriteLine($"[ERROR]: {message}");
}
Теперь у нас потокобезопасный ConcurrentDictionary, который не сломается от одновременных запросов. Семафор бережет базу от двойных ударов, а логирование говорит, что происходит. Ну и бонус — поддержка отмены операций, если вдруг кто-то решит, что котик не стоит ожидания.
Нюансы (а как без них?)
ValueTaskхорош, если вы избегаете создания новых объектов. Но если вы всё равно преобразуете его вTask, профит теряется.Поскольку
ValueTask— это структура, он копируется при передаче, что может быть дороже, чем работа сTask.С ValueTask надо работать аккуратно.
А если у вас есть собственные кейсы с ValueTask, делитесь в комментариях!
В заключение приглашаем всех начинающих C#-разработчиков на открытые уроки:
13 января: «Логирование и мониторинг работы приложения на C#». Узнать подробнее
22 января: «Классы как основа C#». Узнать подробнее
Комментарии (2)

Politura
13.01.2025 10:19В догонку к предыдущему комментарию, использовать словарь для кэша не самый лучший вариант, ибо память может внезапно закончится когда он забьется неактуальными более данными. Лучше использовать MemoryCache, который тоже потокобезопасен и его вызовы не нужно оборачивать в лок. Ну или самому запилить что-то типа lru кэша.
onyxmaster
Больно смотреть, когда вокруг
ConcurrentDictionary<>берут блокировку. Только что "БД" превратили в однопоточную.