Как и зачем использовать ValueTask в C#

6f5b4c032615dbefb00cc38a729156c6.png

Привет, Хабр!

Сегодня мы будем разбирать интересную вещь в C# ValueTask — штука, которая спасет асинхронные методы от лишних аллокаций.

Если коротко, ValueTask — это структура, которая позволяет вернуть либо Task, либо готовый результат. Она появилась в C# 7.0 для снижения накладных расходов при работе с асинхронным кодом.

Простой пример:

public ValueTask GetMagicNumberAsync()
{
    if (DateTime.Now.Second % 2 == 0)
    {
        // Результат готов — возвращаем синхронно
        return new ValueTask(42);
    }

    // Асинхронный результат
    return new ValueTask(Task.Run(() => CalculateMagicNumber()));
}

Без ValueTask вы всегда возвращаете объект Task, который создаётся даже для синхронных случаев. А это лишняя аллокация. С ValueTask вы возвращаете либо готовое значение, либо уже существующий Task, экономя ресурсы.

Когда использовать ValueTask?

Есть три сценария, где ValueTask покажет себя во всей красе:

  1. Синхронный результат в большинстве случаев. Например, чтение из кеша.

  2. Высокая нагрузка. Когда важно минимизировать аллокации и повысить производительность.

  3. Методы с высокой частотой вызовов. Например, в real-time системах.

Пример использования

Представим магазин котиков. У нас есть метод, который ищет данные о котике: если он в кеше — возвращаем сразу, если нет — идём в базу.

public class CatService
{
    private readonly Dictionary _catCache = new();

    public async ValueTask GetCatAsync(int id)
    {
        if (_catCache.TryGetValue(id, out var name))
        {
            return name; // Возвращаем синхронно
        }

        name = await FetchCatFromDatabaseAsync(id); // Эмуляция долгой операции
        _catCache[id] = name; // Кэшируем результат
        return name;
    }

    private async Task 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 BrokenTask()
{
    return new ValueTask(Task.CompletedTask); // Ошибка: Task не возвращает TResult.
}

Теперь улучшим пример с котиками

Добавим асинхронный кеш, защиту от гонок и немного логики, чтобы не ломать голову, если что-то пойдет не так:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

public class CatStore
{
    private readonly ConcurrentDictionary _cache = new();
    private readonly SemaphoreSlim _lock = new(1, 1);

    public async ValueTask 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 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, который не сломается от одновременных запросов. Семафор бережет базу от двойных ударов, а логирование говорит, что происходит. Ну и бонус — поддержка отмены операций, если вдруг кто-то решит, что котик не стоит ожидания.

Нюансы (а как без них?)

  1. ValueTask хорош, если вы избегаете создания новых объектов. Но если вы всё равно преобразуете его в Task, профит теряется.

  2. Поскольку ValueTask — это структура, он копируется при передаче, что может быть дороже, чем работа с Task.

  3. С ValueTask надо работать аккуратно.

А если у вас есть собственные кейсы с ValueTask, делитесь в комментариях!

В заключение приглашаем всех начинающих C#-разработчиков на открытые уроки:

© Habrahabr.ru