Завдання проти ValueTask у C#

Теперішнє розроблення додатків вимагає високої відгукності, і побудова відгукних додатків значною мірою залежить від асинхронного програмування (asynchronous programming), особливо коли йдеться про завдання, пов'язані з I/O. Якщо ви працюєте з базами даних, маєте справу з файлами або здійснюєте API виклики, асинхронне програмування забезпечує швидкість і зручність вашого додатка. Якщо ви працювали з async/await у .NET, напевно, ви використовували Task, щоб представляти асинхронні операції. Але чи знали ви, що є ще одна альтернатива?

pic

У цьому блозі я хочу познайомити вас із ValueTask — легковажною альтернативою Task, розробленою для сценаріїв, де важливі ефективність роботи та ресурси. Хоча обидва типи мають однакову мету, вони призначені для різних випадків. Розуміння різниці між Task і ValueTask допоможе вам писати більш ефективний і підтримуваний код.

Давайте розглянемо ці типи, щоб зрозуміти їх відмінності та визначити, коли кожен з них варто використовувати.

Що таке Task?

Task представляє асинхронну операцію в .NET. Коли ви викликаєте асинхронний метод, зазвичай він повертає Task, який виконує операцію у фоновому режимі та зрештою надає результат.

Простота Task робить його основним вибором для більшості асинхронних методів. Ось кілька важливих фактів про Task:

  • Важкий: Task — це клас, що означає більші вимоги до пам'яті. При створенні він виділяє місце в хіпі.
  • Гнучкий у використанні: Після завершення Task можна повторно використовувати в різних контекстах, що робить його дуже гнучким для різних сценаріїв.
  • Обробка помилок: Task підтримує обробку виключень, що допомагає ловити помилки під час асинхронних операцій.

Наприклад, читання або запис файлів — це зазвичай довготривала операція, пов'язана з I/O, яку слід виконувати асинхронно. Оскільки читання з файлу є відносно повільним і навряд чи завершиться синхронно, Task ідеально підходить для цього випадку.

public async Task ReadFileAsync(string filePath)  
{  
 using (var reader = new StreamReader(filePath))  
 {  
 return await reader.ReadToEndAsync();  
 }  
}

У цьому випадку кожен виклик пов'язаний з великими витратами (читання з диска) і навряд чи буде виконаний миттєво.

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

Task також сумісний з обробкою помилок, є багаторазовим і природно підтримує асинхронне продовження.

Ось чому Task є гарним вибором для операцій з файловим I/O.

Що таке ValueTask?

ValueTask — це легковажна альтернатива Task. Він був введений в .NET, щоб підвищити продуктивність у специфічних випадках, коли створення повноцінного Task є непотрібним. Це особливо корисно, коли ви очікуєте, що результат асинхронної операції буде доступний швидко або навіть синхронно в деяких випадках.

Основні характеристики ValueTask:

  • Ефективність пам'яті: На відміну від Task, ValueTask є структурою. Це означає, що його можна зберігати в стосі, що зменшує виділення пам'яті в хіпі і підвищує ефективність пам'яті.
  • Умовне використання: ValueTask слід використовувати лише в тих випадках, коли ви знаєте, що асинхронний метод може завершитись синхронно або дуже швидко. Він ідеально підходить для таких сценаріїв, як операції кешування або невеликі фонові завдання.
  • Одноразове використання: На відміну від Task, ValueTask не можна використовувати повторно. Після того, як його чекають, не слід чекати його знову, оскільки він призначений для одноразового використання.
  • Без блокувань: ValueTask не можна заблокувати, тобто не можна синхронно чекати на його завершення (наприклад, за допомогою .Wait()).
    Завжди споживайте ValueTask за допомогою await, щоб забезпечити правильну поведінку.

Наприклад, розглянемо метод, який отримує дані з кешу в пам'яті. Якщо дані доступні в пам'яті, їх можна повернути одразу (синхронно). В іншому випадку, вони будуть отримані асинхронно з бази даних. Тут ValueTask дозволяє уникнути непотрібного створення Task, коли результат доступний негайно.

private readonly Dictionary _cache = new();  

public async ValueTask GetCachedValueAsync(string key)  
{  
 if (_cache.TryGetValue(key, out int cachedValue))  
 {  
 return cachedValue; // Синхронне завершення  
 }  

 // Якщо немає в кеші, симулюємо асинхронний виклик до бази даних  
 int dbValue = await FetchFromDatabaseAsync(key);  
 _cache[key] = dbValue; // Кешуємо результат  
 return dbValue;  
}  

private async Task FetchFromDatabaseAsync(string key)  
{  
 await Task.Delay(100); // Симуляція затримки при доступі до бази даних  
 return new Random().Next(1, 100); // Симульовані дані  
}

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

Примітка: Пам'ятайте, що ValueTask не слід очікувати кілька разів. Після того, як результат отримано з GetCachedValueAsync, уникайте повторного використання await.

Насправді, хоча ви технічно можете блокувати або повторно використовувати ValueTask, це суперечить рекомендованій практиці асинхронного програмування і підриває переваги продуктивності та гарантії коректності, що надаються ValueTask. Коротше кажучи, основна перевага зникає. Ось чому вам слід завжди використовувати await для обробки операцій ValueTask.

Однак, якщо вам необхідно блокувати з якоїсь причини або потрібно очікувати більше ніж один раз, ви можете зберегти ValueTask, перетворивши його на Task за допомогою методів .AsTask() або .Preserve(). Це створює стандартний екземпляр Task, який можна безпечно чекати кілька разів.

// Правильне використання  
var cachedValue = await GetCachedValueAsync("key");  

// Перетворити на Task:  
var task = GetCachedValueAsync("key").AsTask();  

// Або зберегти:  
var preservedTask = GetCachedValueAsync("key").Preserve();  

// Лише у випадку крайньої необхідності, але все одно не рекомендується.  
task.Wait();  
preservedTask.Wait();

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

// Небезпечне: Очікування ValueTask кілька разів  
ValueTask valueTask = GetCachedValueAsync("key");  

// Результати НЕ гарантують однакове значення.  
int value1 = await valueTask;  
int value2 = await valueTask; // Помилка: ValueTask можна очікувати лише один раз!  

// Безпечне: Зберігаємо, перетворюючи в Task  
Task preservedTask = valueTask.AsTask();  

// Результати завжди будуть однакові.   
int value3 = await preservedTask;  
int value4 = await preservedTask; // Безпечно: Повертає вже обчислений результат.

Пам'ятайте, що повторне використання ValueTask не гарантує однакове значення. Оскільки, коли ви повторно використовуєте ValueTask, якщо він підтримується синхронним значенням (наприклад, літерал або результат), повторне використання може постійно давати однакове значення. В іншому випадку, він підтримується станом асинхронної операції (наприклад, I/O виклик або async метод), і кожне використання може викликати нове обчислення, що потенційно призведе до різних результатів.

Коли використовувати кожен

У більшості випадків, Task повинен бути вашим стандартним вибором для асинхронних операцій у .NET. Це просто у використанні, широко підтримується і чудово підходить для більшості асинхронних сценаріїв.
Розглядайте використання ValueTask тільки в конкретних, високопродуктивних випадках, коли важливо мінімізувати алокації пам'яті та максимізувати ефективність.

Ось швидка інструкція для прийняття рішення:

Використовуйте Task, якщо:

  • Операція є тривалою або за своєю природою асинхронною, наприклад, мережевий або диск I/O.
  • Результат задачі буде очікуватися (awaited) кілька разів.
  • Ви розробляєте публічний API, де споживачі можуть потребувати повторного використання.
  • Не потрібно оптимізувати алокації пам'яті спеціально.

Використовуйте ValueTask, якщо:

  • Операція може завершитися синхронно, наприклад, результат із кешу.
  • Метод викликається в швидкому циклі, де алокація пам'яті є дорогою.
  • Ви перевірили за допомогою профілювання, що алокації об'єктів Task спричиняють проблеми з продуктивністю.

Отже, як Task, так і ValueTask мають свої переваги, і вибір правильного типу може допомогти покращити продуктивність.

Важливі моменти, які слід пам'ятати

  • Повторне використання: ValueTask не можна використовувати повторно, тому віддавайте перевагу Task, якщо вам потрібна гнучкість для повторного використання результату.
  • Обробка помилок: І Task, і ValueTask підтримують виключення, але Task зазвичай використовується та краще підтримується в сценаріях, що передбачають складну обробку помилок.
  • Сумісність: Багато бібліотек і фреймворків .NET побудовано з урахуванням Task. Використання ValueTask може викликати проблеми сумісності при інтеграції з існуючими кодовими базами або API, що очікують Task.

Підсумок

У більшості випадків використання Task є простішим і суміснішим з існуючим кодом. Однак, якщо ви працюєте в області високої продуктивності, де важливо зменшити алокацію пам'яті, ValueTask може бути відмінним варіантом.

Використання Task для загальних асинхронних методів і зарезервування ValueTask для конкретних оптимізованих випадків є відмінним підходом.

Дякую за прочитане!

Якщо ви вважаєте цей пост корисним і хочете підтримати, не забудьте поставити лайк та поділитися ним з іншими, хто може з цього скористатися.👏👏👏👏👏

Бажаю успішного навчання 📈, і сподіваюся скоро поділитися новими статтями з вами.

Слідкуйте за мною в Twitter, Github для більше контенту.
Залишайтеся на зв'язку в LinkedIn.

Перекладено з: Task vs. ValueTask in C#

Leave a Reply

Your email address will not be published. Required fields are marked *