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.
Практическое применение: где это реально нужно?
- Десктопные приложения: если ваше приложение читает или пишет что-то крупное (лог-файлы, базы данных, видео), асинхронность — must-have. Даже если ваш компьютер "огонь", пользователь может открыть файл по сети, а там скорость — как у черепахи на рабочий обед.
- Бэкенд или веб-приложения: одновременно к серверу могут стучаться десятки (а то и тысячи) пользователей. Если каждый поток блокируется на чтении файлов — все, здравствуй, тормоза и 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 — это мощный инструмент для создания отзывчивых и масштабируемых приложений. Оно позволяет вашему коду эффективно использовать системные ресурсы, не блокируя пользовательский интерфейс или потоки на сервере. Возможно, поначалу это будет казаться немного непривычным, но поверьте, это стоит освоения! В следующих лекциях мы продолжим углубляться в мир асинхронности и параллелизма. До встречи!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ