Привет, Хабр!
Сегодня мы будем разбирать интересную вещь в 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#». Узнать подробнее
ссылка на оригинал статьи https://habr.com/ru/articles/873128/
Добавить комментарий