JavaRush /Курси /C# SELF /Оптимізація продуктивності:

Оптимізація продуктивності: ValueTask

C# SELF
Рівень 62 , Лекція 2
Відкрита

1. Вступ

Перш ніж занурюватися в код, давайте розберімося простіше. Памʼятайте: просте створення обʼєкта в .NET (а тим паче — Task!) споживає памʼять і трохи часу процесора. Уявіть асинхронний API, який у половині випадків повертає результат миттєво (наприклад, бере його з кешу), а в іншій половині — звертається до бази даних, тож операція стає справді асинхронною й потребує Task. Таке часто трапляється — наприклад, у файлових кешах, мережевих API, пулах обʼєктів та інших «асинхронних, але інколи миттєвих» сценаріях.

Якщо ми завжди повертатимемо Task, навіть за миттєвих результатів доведеться створювати зайві обʼєкти. А що якби можна було віддавати результат без Task, якщо він уже готовий? Саме для цього зʼявився ValueTask.

Факт: стандартні Task.CompletedTask і Task.FromResult(…) справді економлять час виконання, але створюють спільний обʼєкт, який може бути неідеальним для високонавантажених сценаріїв.

Що таке ValueTask

ValueTask — це спеціальна структура-обгортка, яка може представляти або вже готовий результат, або (якщо операція реально асинхронна) сам Task. Простіше кажучи, це «пакет», що може містити або просто результат, або посилання на Task.

Є два основні варіанти:

  • ValueTask — «без значення» (не повертає значення, як Task)
  • ValueTask<TResult> — обгортка для значення (аналог Task<TResult>)

Порівняння Task і ValueTask

Тип Кількість алокацій Може завершитися синхронно Зазвичай використовується
Task
Одна (heap) Так/Ні Майже завжди
ValueTask
Нульова/одна Так/Ні Оптимізація
ValueTask<T>
Нульова/одна Так/Ні Оптимізація

Коли застосовувати ValueTask

Є золоте правило: якщо ви пишете звичайний асинхронний метод, який завжди повертає результат лише після async-операції, використовуйте Task. Це просто, безпечно й зрозуміло всім.

Використовувати ValueTask варто, коли:

  • Результат можна отримати синхронно (наприклад, з кешу, пулу, памʼяті), і ви хочете зекономити на зайвих алокаціях.
  • Асинхронна операція трапляється не надто часто (інакше вигода губиться на тлі ускладнення коду та копіювання структур).

Увага! Якщо ви завжди повертаєте асинхронний результат — сміливо використовуйте Task. Якщо хочете, щоб ваш API був «супероптимальним» для часто трапляних миттєвих результатів — тоді ValueTask.

2. Синхронний чи асинхронний результат

Розгляньмо функцію, що шукає користувача за імʼям. Якщо користувач у кеші — повертаємо його миттєво; якщо ні — асинхронно завантажуємо з «бази»:

// Моделюємо нашого користувача
public class User
{
    public string Name { get; set; }
}

// Наш кеш (дуже простий)
private readonly Dictionary<string, User> _localCache = new();

public async ValueTask<User> FindUserAsync(string name)
{
    // Перевіряємо локальний кеш
    if (_localCache.TryGetValue(name, out var user))
    {
        // Результат миттєвий — алокацій Task немає!
        return user;
    }

    // Тут наче тривала асинхронна операція (наприклад, з бази даних)
    user = await LoadUserFromDbAsync(name);
    // Кладемо в кеш на майбутнє
    _localCache[name] = user;
    return user;
}

private async Task<User> LoadUserFromDbAsync(string name)
{
    // Симулюємо затримку
    await Task.Delay(500);
    return new User { Name = name };
}

Важливо: Коли спрацьовує кеш, ми повертаємо звичайний результат — жодних алокацій Task! Лише коли результат треба «діставати», ми справді створюємо асинхронне завдання.

Як влаштований ValueTask всередині

Всередині ValueTask може лежати або готове значення, або посилання на Task:

ValueTask result = ValueTask.CompletedTask;
ValueTask<int> valueResult = new ValueTask<int>(42);
ValueTask<int> valueResult2 = new ValueTask<int>(Task.Run(() => 42));

Під час використання await компілятор сам розбереться: якщо результат миттєвий, зайві обʼєкти не створяться.

Важливе про await і ValueTask

async-методи добре працюють з await для ValueTask:

public async ValueTask PingAsync()
{
    // ...
    await Task.Delay(10);
}

Але якщо ви зберігаєте екземпляр ValueTask і вирішуєте дочекатися його пізніше, памʼятайте: не можна виконувати await одного й того ж екземпляра більше одного разу. Task можна очікувати багаторазово, а ValueTask — ні.

Помилка: Повторний await ValueTask

ValueTask<int> task = ComputeAsync();

// Це ОК
int a = await task;

// Це помилка! Другий await на тому самому екземплярі ValueTask не дозволений:
// int b = await task; // НЕ МОЖНА!

3. Перепишемо частину нашого консольного застосунку

Припустімо, тепер ми розробляємо мінічиталку книжок: частина текстів уже завантажена в кеш, решта — підвантажується асинхронно. Зробімо оптимізований метод отримання першого рядка книги за допомогою ValueTask:

private readonly Dictionary<string, string> _bookCache = new();

public async ValueTask<string> GetFirstLineOfBookAsync(string title)
{
    if (_bookCache.TryGetValue(title, out var bookText))
    {
        var firstLine = bookText.Split('\n')[0];
        return firstLine;
    }

    // Припустімо, асинхронне завантаження книги
    var downloadedBook = await DownloadBookTextAsync(title);
    _bookCache[title] = downloadedBook;
    return downloadedBook.Split('\n')[0];
}

private async Task<string> DownloadBookTextAsync(string title)
{
    // Симулюємо затримку (наприклад, завантаження з інтернету)
    await Task.Delay(1000);
    return $"Book: {title}\nThis is the first line.\nSecond line...";
}

Ось так ValueTask заощаджує створення Task, коли книжка вже в кеші.

4. Корисні нюанси

Маркер: коли не треба використовувати ValueTask

Якщо ваш API завжди асинхронний (наприклад, це постійно мережеві виклики) — використовуйте Task, це простіше й безпечніше.

Як перетворити ValueTask на Task і навпаки

Буває, що ValueTask «не вписується» в API, якому потрібен Task. Використовуйте .AsTask():

ValueTask<int> valueTask = ComputeAsync();
Task<int> task = valueTask.AsTask();

Якщо треба з Task зробити ValueTask — просто створіть ValueTask з Task:

Task<int> task = ComputeAsyncTask();
ValueTask<int> valueTask = new ValueTask<int>(task);

Порівняння: ValueTask vs Task

Характеристика
Task
ValueTask
Повторний await Дозволений Не дозволений
Алокації при швидких відповідях Є Немає (якщо результат миттєвий)
Інтерфейс сумісності Широко підтримується Потребує додаткових обгорток
Застосовність Повсюдно Лише для оптимізації
Пулінг (Pool) Використовує спільний пул Ні, структура
Простота Просто Складніше

Використання ValueTask у реальному житті

public async ValueTask<string> GetMessageAsync(int id)
{
    if (_messageCache.TryGetValue(id, out var value))
        return value;

    var result = await LoadMessageFromDbAsync(id);
    _messageCache[id] = result;
    return result;
}

Застосування на співбесіді: Якщо вас запитають, як ще сильніше прокачати продуктивність асинхронного API з кешуванням, — скажіть про ValueTask.

Приклади сумісності з LINQ та IAsyncEnumerable<T>

Якщо хочете використовувати ValueTask з асинхронним LINQ (IAsyncEnumerable<T>), це підтримується в .NET (наприклад, метод ToListAsync):

public async ValueTask<List<int>> GetPrimeNumbersAsync()
{
    // Симулюємо, що частину чисел уже обчислено, а частина — асинхронна операція
    // ... (приклад опущено для стислості)
    return new List<int> { 2, 3, 5, 7, 11 };
}

5. Підсумки та особливості реалізації

  1. Використовуйте ValueTask для оптимізації, коли значну частину часу результат готовий миттєво (наприклад, з кешу).
  2. Не використовуйте ValueTask «просто так» — це призведе до ускладнення коду та помилок.
  3. Не виконуйте повторний await для ValueTask. Якщо потрібно — перетворіть його на Task.
  4. Уся логіка сумісності з Task — через .AsTask().
  5. Памʼятайте, що ValueTaskstruct і може поводитися інакше під час копіювання.

6. Типові помилки під час роботи з ValueTask

Помилка №1: повторний await на одному екземплярі ValueTask. ValueTask — структура; її не можна очікувати двічі за допомогою await. Другий await призведе до винятку або некоректної роботи.

Помилка №2: використання ValueTask без явної перевірки сумісності. Деякі сторонні API вимагають Task, і передача ValueTask напряму може спричинити проблеми.

Помилка №3: використання ValueTask без потреби. Якщо результат завжди асинхронний, застосування ValueTask ускладнює код без реальної користі.

Помилка №4: копіювання ValueTask як struct. Скопійована структура може поводитися неочікувано під час await, тож важливо працювати з оригіналом.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ