JavaRush /Курси /C# SELF /Асинхронне читання та запис файлів (

Асинхронне читання та запис файлів ( ReadAsync/ WriteAsync)

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

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 читання або запис по одному байту за раз і далі вкрай неефективні. Асинхронність знімає блокування, але не магічно пришвидшує обробку кожного байта. Хороші стартові розміри буфера — 40968192 байт, для великих файлів має сенс спробувати 65536 або 131072.

«Асинхронність має бути всюди» (Async All The Way Down): якщо ви почали використовувати async/await в одному місці, зазвичай варто протягнути цей підхід по всьому ланцюжку викликів: C виконує асинхронну операцію — отже, C — це async Task, B теж async Task, і Aasync Task. Інакше можливі блокування й навіть взаємні блокування в UI‑застосунках.

Обробка винятків: в асинхронному коді використовуйте звичайні trycatch. Часто трапляються OperationCanceledException і IOException — обробляйте їх явно.

Звільнення ресурсів (await using): для потоків та інших IDisposable‑обʼєктів коректно звільняйте ресурси. Якщо тип реалізує IAsyncDisposable, то await using викличе DisposeAsync(); якщо лише IDisposable — буде викликано Dispose().

Що відбувається під капотом (коротко): під час await компілятор перетворює метод на кінцевий автомат: операція запускається, метод «ставиться на паузу», керування повертається викликаючому коду. Коли результат готовий, SynchronizationContext (в UI) або ThreadPool (у консолі/на сервері) відновлює виконання з місця зупинки. Це дає змогу одному потоку обслуговувати безліч «призупинених» завдань без блокувань.

На завершення: асинхронне програмування з async і await — це потужний інструмент для створення відгукливих і масштабованих застосунків. Воно дозволяє вашому коду ефективно використовувати системні ресурси, не блокуючи користувацький інтерфейс або потоки на сервері. Можливо, спершу це здаватиметься трохи незвичним, але повірте — це варто опанувати! У наступних лекціях ми продовжимо занурюватися у світ асинхронності та паралелізму. До зустрічі!

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