1. Вступ
Уявіть, що ви — диригент оркестру (вашого застосунку). Якщо щоразу, коли скрипаль налаштовує свою скрипку (тривала операція введення‑виведення, I/O), ви чекаєте його, перш ніж перейти до інших інструментів, увесь оркестр стоятиме. А якщо скрипаль каже: «Я налаштуюся, а ви поки продовжуйте, подам знак, коли буду готовий», — це асинхронність!
У світі C# і .NET 9 для такої «багатозадачності без зависань» є спеціальні інструменти. Головні герої сьогодні — асинхронні версії методів Read і Write, які називаються ReadAsync і WriteAsync.
Вони дають змогу ініціювати операцію читання або запису й одразу «відпустити» поточний потік виконання, щоб він міг займатися чимось іншим. Коли введення‑виведення завершиться (наприклад, дані буде прочитано з диска або записано на нього), ваш код «прокинеться» й продовжить роботу з того місця, де зупинився.
Щоб користуватися цими методами, нам знадобляться два «чарівні» слова, які прийшли в C# ще у 2012 році з версією 5.0 (а зараз, у C# 14, вони вже як рідні!):
- async: це модифікатор, який ви додаєте до методу, щоб повідомити компілятор: «Усередині цього методу можуть бути асинхронні операції, і я використовуватиму await».
- await: це оператор, який ви ставите перед викликом асинхронної операції (на кшталт ReadAsync або WriteAsync). Він означає: «Запусти цю операцію, але не чекай її завершення тут. Передай керування назад викликаючому коду й повернися, коли операція буде готова».
Не переймайтеся, якщо ці поняття поки що здаються трохи туманними. У нас буде окремий рівень, присвячений async і await (Рівень 58), де розберемо їх до гвинтика. Зараз важливо зрозуміти, що вони допомагають нам не блокувати основний потік.
2. ReadAsync: читаємо без поспіху
Метод ReadAsync дає змогу асинхронно читати дані з потоку. Замість того щоб чекати, поки байти буде зчитано з диска, ви починаєте читання і відразу переключаєтеся на інші завдання.
Ось як виглядає його основна сигнатура для читання в буфер:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Або, що частіше використовують у сучасному C# (і .NET 9), — із використанням Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Розберімо параметри:
- buffer: це масив byte (або Memory<byte>), куди буде прочитано дані. Памʼятаєте, ми говорили про буфери для оптимізації? Тут вони використовуються так само, але вже для асинхронних операцій.
- offset: зсув у buffer, звідки почати запис прочитаних байтів.
- count: максимальна кількість байтів для читання.
- CancellationToken cancellationToken: дуже корисний параметр, що дає змогу скасувати операцію, якщо вона більше не потрібна (наприклад, користувач закрив застосунок або натиснув кнопку «Скасувати»).
- ValueTask<int>: це «обіцянка» того, що коли операція завершиться, вона поверне ціле число (int), яке дорівнюватиме кількості прочитаних байтів. ValueTask — оптимізована версія Task для сценаріїв, коли результат може бути доступний синхронно або асинхронно.
Приклад 1: асинхронне читання файлу
Уявімо, що в нас є великий текстовий файл, і ми хочемо читати його, не блокуючи основний потік. Ось базовий приклад:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Асинхронна функція для читання файлу й підрахунку кількості рядків
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Асинхронне відкриття файлу
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"У файлі {filename} рядків: {count}");
}
}
Коментарі до коду:
- Зверніть увагу на параметр useAsync: true у FileStream. Це важливо для справжньої асинхронності.
- Ми використовуємо await перед ReadLineAsync, щоб не блокувати потік, поки читається рядок.
- Метод Main тепер асинхронний (C# 7+ дозволяє це).
Якби це був графічний застосунок, то під час читання файлу (поки ReadAsync чекає дані з диска) користувач міг би натискати кнопки, гортати й робити інші дії, адже основний потік UI не був би заблокований. У консольному застосунку це менш помітно, але принцип той самий.
3. WriteAsync: пишемо без зволікань
Аналогічно до ReadAsync, метод WriteAsync дає змогу асинхронно записувати дані в потік. Це дуже корисно, коли вам потрібно записати багато даних, не змушуючи застосунок чекати завершення запису на диск.
Основні сигнатури:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
І з використанням ReadOnlyMemory<byte> (для запису ми не модифікуємо буфер):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
Параметри подібні до ReadAsync:
- buffer: масив byte (або ReadOnlyMemory<byte>), що містить дані для запису.
- offset: зсув у buffer, звідки почати читання даних для запису.
- count: кількість байтів для запису.
- CancellationToken cancellationToken: для скасування операції.
- ValueTask: поверненого значення немає, адже кількість байтів для запису задається параметром count.
Приклад 2: асинхронний запис файлу
А тепер запишімо щось у файл — також асинхронно.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Рядок номер {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"Запис {filename} завершено.");
}
}
Тут цикл записує 10000 рядків, а основний потік не блокується: якби це був GUI‑застосунок, інтерфейс не зависав би.
Завдяки async і await консольний застосунок може виконувати, наприклад, операцію копіювання файлу і при цьому залишатися «відгукливим» до вводу користувача (зокрема — до натискання Enter для скасування). Це фундаментальний принцип створення сучасних, продуктивних і відгукливих застосунків на C#.
4. Корисні нюанси
Візуалізація: як працює асинхронне читання/запис
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Твій код (UI/логіка)│ ─────────────────────→ │ OS/E/S: асинхронна операція │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(робить щось іще) │(читає файл, чекає диск)
│<────────────────────────────────────────→│
└─ Waits for Task, gets result ←──────────┘
Приблизна схема: поки диск працює повільно, код може робити щось інше. Лише коли дані потрібні, виконання дочікується Task.
Практичне застосування: де це справді потрібно?
- Десктопні застосунки: якщо ваш застосунок читає або пише щось велике (лог‑файли, бази даних, відео), асинхронність — практично обовʼязкова. Навіть якщо компʼютер «вогонь», користувач може відкрити файл по мережі, а там швидкість — як у черепахи на обідній перерві.
- Бекенд або веб‑застосунки: одночасно до сервера можуть звертатися десятки, а то й тисячі користувачів. Якщо кожен потік блокується на читанні файлів — у підсумку маємо гальмування і 502 Bad Gateway.
- Мобільні застосунки: якщо відкрити файл або записати дані займає час — користувач побачить лаг. Використовуйте асинхронність!
- Будь‑яка масова обробка файлів: будь‑який застосунок, що працює з колекціями файлів (архіватори, парсери, аналізатори), виграє від асинхронного I/O.
Синхронне vs асинхронне читання/запис
| Метод | Блокує потік? | Простий у реалізації? | Найкраща продуктивність? | Зручно для UI/серверів |
|---|---|---|---|---|
| Синхронний (Read/Write) | Так | Так | Ні | Ні |
| Асинхронний (ReadAsync) | Ні | Майже | Так | Так |
5. Нюанси та найкращі практики
Буферизація все ще важлива: навіть із ReadAsync і WriteAsync читання або запис по одному байту за раз і далі вкрай неефективні. Асинхронність знімає блокування, але не магічно пришвидшує обробку кожного байта. Хороші стартові розміри буфера — 4096–8192 байт, для великих файлів має сенс спробувати 65536 або 131072.
«Асинхронність має бути всюди» (Async All The Way Down): якщо ви почали використовувати async/await в одному місці, зазвичай варто протягнути цей підхід по всьому ланцюжку викликів: C виконує асинхронну операцію — отже, C — це async Task, B теж async Task, і A — async Task. Інакше можливі блокування й навіть взаємні блокування в UI‑застосунках.
Обробка винятків: в асинхронному коді використовуйте звичайні try‑catch. Часто трапляються OperationCanceledException і IOException — обробляйте їх явно.
Звільнення ресурсів (await using): для потоків та інших IDisposable‑обʼєктів коректно звільняйте ресурси. Якщо тип реалізує IAsyncDisposable, то await using викличе DisposeAsync(); якщо лише IDisposable — буде викликано Dispose().
Що відбувається під капотом (коротко): під час await компілятор перетворює метод на кінцевий автомат: операція запускається, метод «ставиться на паузу», керування повертається викликаючому коду. Коли результат готовий, SynchronizationContext (в UI) або ThreadPool (у консолі/на сервері) відновлює виконання з місця зупинки. Це дає змогу одному потоку обслуговувати безліч «призупинених» завдань без блокувань.
На завершення: асинхронне програмування з async і await — це потужний інструмент для створення відгукливих і масштабованих застосунків. Воно дозволяє вашому коду ефективно використовувати системні ресурси, не блокуючи користувацький інтерфейс або потоки на сервері. Можливо, спершу це здаватиметься трохи незвичним, але повірте — це варто опанувати! У наступних лекціях ми продовжимо занурюватися у світ асинхронності та паралелізму. До зустрічі!
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ