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
| Тип | Кількість алокацій | Може завершитися синхронно | Зазвичай використовується |
|---|---|---|---|
|
Одна (heap) | Так/Ні | Майже завжди |
|
Нульова/одна | Так/Ні | Оптимізація |
|
Нульова/одна | Так/Ні | Оптимізація |
Коли застосовувати 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
| Характеристика | |
|
|---|---|---|
| Повторний 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. Підсумки та особливості реалізації
- Використовуйте ValueTask для оптимізації, коли значну частину часу результат готовий миттєво (наприклад, з кешу).
- Не використовуйте ValueTask «просто так» — це призведе до ускладнення коду та помилок.
- Не виконуйте повторний await для ValueTask. Якщо потрібно — перетворіть його на Task.
- Уся логіка сумісності з Task — через .AsTask().
- Памʼятайте, що ValueTask — struct і може поводитися інакше під час копіювання.
6. Типові помилки під час роботи з ValueTask
Помилка №1: повторний await на одному екземплярі ValueTask. ValueTask — структура; її не можна очікувати двічі за допомогою await. Другий await призведе до винятку або некоректної роботи.
Помилка №2: використання ValueTask без явної перевірки сумісності. Деякі сторонні API вимагають Task, і передача ValueTask напряму може спричинити проблеми.
Помилка №3: використання ValueTask без потреби. Якщо результат завжди асинхронний, застосування ValueTask ускладнює код без реальної користі.
Помилка №4: копіювання ValueTask як struct. Скопійована структура може поводитися неочікувано під час await, тож важливо працювати з оригіналом.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ