Урок 8.13 — Асинхронность
Синхронный vs асинхронный код. async, await, Task и параллельное выполнение.
Ты уже умеешь писать код, который выполняется строка за строкой — это синхронный код. Но что если программа должна скачать файл из интернета, прочитать большой файл с диска или сделать HTTP-запрос? В синхронном мире программа замрёт на время этой операции. Асинхронность позволяет выполнять долгие операции, не блокируя основной поток. Пока одна задача ждёт ответа от сервера — другая спокойно работает.
Синхронность vs Асинхронность vs Параллельность
Прежде чем погружаться в async/await, нужно чётко различать три концепции. Новички часто путают асинхронность с параллельностью — но это разные вещи.
🧍 Синхронность (Synchronous)
Код выполняется строка за строкой. Пока одна операция не закончится — следующая не начнётся. Как очередь в магазине: сначала один покупатель, потом другой. Если первый долго выбирает — все ждут.
Console.WriteLine("Начало");
Thread.Sleep(3000); // программа замирает на 3 секунды
Console.WriteLine("Конец"); // ждало 3 секунды
🔄 Асинхронность (Asynchronous)
Операция запускается, но не блокирует поток. Пока она выполняется (ждёт ответ от сервера, читает файл), поток может делать другую работу. Как заказ в кафе: ты сделал заказ и отошёл, а когда блюдо готово — тебя зовут. Поток не сидит сложа руки — он обслуживает других клиентов.
⚡ Параллельность (Parallel)
Несколько задач выполняются одновременно на нескольких ядрах процессора. Как несколько касс в супермаркете — каждый кассир обслуживает своего покупателя параллельно. Для параллельности нужно несколько потоков (threads) или ядер CPU.
💡 Главное отличие: асинхронность — про неблокирующие операции (ожидание, I/O). Параллельность — про одновременное выполнение на нескольких ядрах. В C# асинхронность — это async/await/Task, а параллельность — Parallel.For, PLINQ, Task.Run.
Аналогия: пиццерия
Представь пиццерию с одним поваром:
❌ Синхронно (один повар)
Повар берёт заказ, начинает готовить пиццу, стоит и ждёт пока она печётся 15 минут (ничего не делая). Потом принимает следующий заказ. Клиенты ждут в очереди. Ужасно неэффективно.
✅ Асинхронно (тот же повар, но умный)
Повар берёт заказ, ставит пиццу в печь и не ждёт — сразу начинает готовить следующую. Когда пицца готова — печь «сигналит» (как await), повар достаёт её и отдаёт клиенту. Один повар обслуживает много заказов, не простаивая.
⚡ Параллельно (три повара)
Три повара одновременно готовят три пиццы. Каждый на своей плите. Это параллельность — нужны ресурсы (несколько поваров/ядер).
Зачем нужна асинхронность: I/O-bound задачи
В программировании есть два типа задач:
✅ CPU-bound (загрузка процессора)
Задача, которая требует вычислений: сортировка массива, математические расчёты, обработка изображений. Быстрота зависит от скорости процессора. Здесь асинхронность не поможет — нужна параллельность (Task.Run).
✅ I/O-bound (ввод-вывод)
Задача, которая ждёт внешний ресурс: файл на диске, ответ от сервера, запрос к базе данных. Процессор в это время простаивает. Вот здесь асинхронность даёт огромный выигрыш — поток не блокируется и может делать другую работу.
Типичные I/O-bound сценарии:
- HTTP-запросы к API
- Чтение/запись файлов
- Запросы к базе данных
- Работа с сетью
- Ожидание таймера (Task.Delay)
- WebSocket / SignalR
⚠️ Важно: В консольных приложениях асинхронность не так заметна, но в GUI (WPF, WinForms, Blazor, MAUI) она критична. Если сделать синхронный HTTP-запрос в UI-приложении — интерфейс зависнет на всё время запроса. Пользователь подумает, что программа сломалась. Асинхронность решает эту проблему.
Ключевые слова async и await
В C# асинхронность строится на трёх китах: async, await и Task. Давай разберёмся с каждым по порядку.
📝 async — модификатор метода
Ключевое слово async указывается в сигнатуре метода. Оно не делает метод асинхронным само по себе — оно разрешает использовать await внутри этого метода. Компилятор перестраивает метод в state machine (конечный автомат), который умеет приостанавливаться и возобновляться.
⏳ await — оператор ожидания
await применяется к Task (или другой awaitable-операции). Он говорит: «Приостанови выполнение этого метода, пока задача не завершится, но не блокируй поток». Пока задача выполняется, поток возвращается в пул потоков и может обслуживать другие запросы. Когда задача завершается — метод возобновляется с того же места.
📦 Task и Task<T> — обещание результата
Task представляет асинхронную операцию, которая может быть в трёх состояниях: выполняется, завершена успешно, завершена с ошибкой. Task<T> — операция, которая возвращает результат типа T. Это как «трек-номер посылки»: ты не ждёшь у двери курьера, а занимаешься своими делами, а когда посылка приходит — получаешь уведомление.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("Начало: " + DateTime.Now.TimeOfDay);
// await — ждём, но не блокируем поток
await DoSomethingAsync();
Console.WriteLine("Конец: " + DateTime.Now.TimeOfDay);
}
static async Task DoSomethingAsync()
{
Console.WriteLine(" Задача началась");
await Task.Delay(2000); // асинхронная пауза 2 секунды
Console.WriteLine(" Задача завершилась");
}
}
// Вывод:
// Начало: 12:00:00.000
// Задача началась
// Задача завершилась
// Конец: 12:00:02.000
Обрати внимание: Main тоже может быть async Task (начиная с C# 7.1). Это позволяет использовать await прямо в точке входа программы.
Правила сигнатур async-методов
У async-методов есть строгие правила по возвращаемым типам. Вот три возможных варианта:
async Task
Метод не возвращает значение. Аналог void, но асинхронный. Используется для операций, которые не дают результата.
async Task SaveToFileAsync(string text) { ... }
async Task<T>
Метод возвращает значение типа T. Используется, когда нужно получить результат асинхронно.
async Task<string> DownloadAsync(string url) { ... }
async void (⚠️ только для событий)
Используется только для обработчиков событий (button_Click). async void нельзя await-ить, и исключения в нём приводят к падению программы. Избегай async void везде, кроме событий.
Пример с возвратом значения:
using System;
using System.Threading.Tasks;
using System.Net.Http;
class Program
{
static readonly HttpClient client = new HttpClient();
static async Task Main()
{
string content = await DownloadAsync("https://example.com");
Console.WriteLine($"Скачано {content.Length} символов");
}
static async Task<string> DownloadAsync(string url)
{
Console.WriteLine($"Скачиваю {url}...");
string result = await client.GetStringAsync(url);
Console.WriteLine("Готово!");
return result;
}
}
Как работает await изнутри
Когда компилятор видит async метод, он превращает его в конечный автомат (state machine). Вот что происходит под капотом:
- Метод запускается синхронно до первого
await - Встретив
await task, компилятор проверяет: задача уже завершена? Если да — продолжаем синхронно (быстрый путь). - Если задача ещё выполняется — метод приостанавливается и возвращает Task в вызывающий код.
- Поток возвращается в пул и может делать другую работу.
- Когда задача завершается — continuation (продолжение) запускается на любом свободном потоке из пула.
- Метод возобновляется с того же места, где остановился — все локальные переменные сохранены.
💡 Ключевой момент: асинхронность не создаёт новый поток! Она эффективно использует существующий поток, не блокируя его. В отличие от Task.Run(), который действительно запускает код на новом потоке из пула.
Task.Delay() — асинхронная пауза
Task.Delay() — это асинхронный аналог Thread.Sleep(). Разница колоссальная:
❌ Thread.Sleep() — блокирует поток
Поток не может делать ничего другого, пока спит. Если в GUI-приложении вызвать Sleep в UI-потоке — интерфейс зависнет.
✅ Task.Delay() — не блокирует поток
Поток возвращается в пул и может обслуживать другие задачи. Через указанное время выполнение возобновляется.
// ПЛОХО: блокирует поток
static void BadMethod()
{
Thread.Sleep(3000); // поток занят 3 секунды — ничего не делает
}
// ХОРОШО: не блокирует поток
static async Task GoodMethodAsync()
{
await Task.Delay(3000); // поток свободен для других задач
}
// Task.Delay принимает int, TimeSpan, с CancellationToken
await Task.Delay(1000); // 1 секунда
await Task.Delay(TimeSpan.FromMinutes(5)); // 5 минут
await Task.Delay(1000, cancellationToken); // с возможностью отмены
⚠️ Никогда не используй Thread.Sleep() в асинхронном коде. Если нужно сделать паузу — всегда Task.Delay(). Sleep в async-методе — одна из самых частых ошибок новичков.
Task.WhenAll() и Task.WhenAny()
Часто нужно запустить несколько асинхронных операций одновременно. Для этого есть два статических метода:
Task.WhenAll(Task[] tasks)
Возвращает Task, который завершится, когда все задачи завершатся. Если хотя бы одна упадёт с ошибкой — WhenAll тоже упадёт.
Task.WhenAny(Task[] tasks)
Возвращает Task, который завершится, когда первая из задач завершится. Остальные продолжают выполняться в фоне.
static async Task DownloadAllAsync()
{
var httpClient = new HttpClient();
// Запускаем несколько запросов одновременно
Task<string> task1 = httpClient.GetStringAsync("https://example.com");
Task<string> task2 = httpClient.GetStringAsync("https://httpbin.org/get");
Task<string> task3 = httpClient.GetStringAsync("https://jsonplaceholder.typicode.com/posts/1");
// Ждём, пока ВСЕ завершатся
string[] results = await Task.WhenAll(task1, task2, task3);
Console.WriteLine($"Размеры: {results[0].Length}, {results[1].Length}, {results[2].Length}");
Console.WriteLine($"Всего скачано: {results.Sum(r => r.Length)} символов");
}
// Запуск параллельных задач с Task.Run (CPU-bound)
static async Task ProcessInParallelAsync()
{
Task<int> t1 = Task.Run(() => ComputeExpensive(1_000_000));
Task<int> t2 = Task.Run(() => ComputeExpensive(2_000_000));
Task<int> t3 = Task.Run(() => ComputeExpensive(3_000_000));
int[] results = await Task.WhenAll(t1, t2, t3);
Console.WriteLine($"Результаты: {string.Join(", ", results)}");
}
static int ComputeExpensive(int n)
{
int sum = 0;
for (int i = 0; i < n; i++) sum += i;
return sum;
}
WhenAny — первый завершившийся
static async Task<string> GetFirstResponseAsync()
{
var client = new HttpClient();
Task<string> google = client.GetStringAsync("https://google.com");
Task<string> bing = client.GetStringAsync("https://bing.com");
Task<string> duck = client.GetStringAsync("https://duckduckgo.com");
// Ждём только первый ответ
Task<string> firstCompleted = await Task.WhenAny(google, bing, duck);
string result = await firstCompleted; // повторный await — получаем результат
return result;
}
// WhenAny часто используется для:
// 1. Тайм-аутов: WhenAny(задача, Task.Delay(5000))
// 2. Первый ответ от нескольких серверов
// 3. Отмена по истечении времени
// Пример с тайм-аутом:
static async Task<string> DownloadWithTimeoutAsync(string url, int timeoutMs)
{
using var client = new HttpClient();
var download = client.GetStringAsync(url);
var timeout = Task.Delay(timeoutMs);
var completed = await Task.WhenAny(download, timeout);
if (completed == timeout)
{
Console.WriteLine("Тайм-аут!");
return null;
}
return await download; // результат успешной загрузки
}
ConfigureAwait(false) — оптимизация для библиотек
Когда ты делаешь await task, после завершения задачи продолжение (continuation) по умолчанию пытается вернуться на исходный контекст синхронизации (SynchronizationContext). В GUI-приложениях это UI-поток. В ASP.NET Core — контекст запроса. Но в библиотечном коде (не UI) возвращаться в исходный контекст не нужно — это лишние накладные расходы.
// Без ConfigureAwait — продолжение пытается вернуться в исходный контекст
await ReadFileAsync();
// С ConfigureAwait(false) — продолжение выполняется на любом потоке из пула
await ReadFileAsync().ConfigureAwait(false);
// В библиотечном коде (не UI) — всегда используй ConfigureAwait(false):
public async Task<string> ReadDataAsync()
{
using var reader = File.OpenText("data.txt");
string content = await reader.ReadToEndAsync().ConfigureAwait(false);
return content;
}
// ❌ НЕ НУЖНО в UI-коде (WPF, WinForms, Blazor Server):
// Если сделать ConfigureAwait(false) в обработчике кнопки,
// то после await нельзя будет обновить UI напрямую:
private async void Button_Click(object sender, EventArgs e)
{
string data = await DownloadAsync().ConfigureAwait(false);
// ⚠️ Здесь уже не UI-поток!
// textBox.Text = data; // ❌ InvalidOperationException!
}
💡 Правило: в библиотечном коде (классы, которые не работают с UI) всегда используй .ConfigureAwait(false). В UI-коде — не используй (нужен исходный контекст). В ASP.NET Core (начиная с .NET Core 2.1) контекст отсутствует, поэтому ConfigureAwait(false) не даёт выигрыша, но и не вредит.
Когда использовать async/await
Не каждая задача требует async/await. Вот чёткие правила:
✅ ИСПОЛЬЗУЙ async/await когда:
- HTTP-запросы: HttpClient.GetStringAsync, PostAsync
- Работа с файлами: File.ReadAllTextAsync, StreamReader.ReadToEndAsync
- Запросы к БД: Entity Framework .ToListAsync(), .FirstOrDefaultAsync()
- Работа с сетью: WebSocket, SignalR
- Таймеры: Task.Delay
- Любая I/O-bound операция
- UI-приложения: чтобы интерфейс не зависал
❌ НЕ ИСПОЛЬЗУЙ async/await когда:
- Простое вычисление без I/O (async не даст выигрыша)
- CPU-bound задача — используй Task.Run или Parallel.For
- Метод возвращает Task, но не содержит await (warning компилятора)
- Короткая синхронная операция (накладные расходы async больше выгоды)
- Конструкторы и свойства (не могут быть async)
// ✅ ПРАВИЛЬНО: I/O-bound операция
public async Task<string> ReadFileAsync(string path)
{
return await File.ReadAllTextAsync(path);
}
// ❌ НЕПРАВИЛЬНО: CPU-bound с async (нет выигрыша)
// Лучше: Task.Run(() => Compute())
public async Task<int> ComputeAsync(int n)
{
return await Task.Run(() => {
int sum = 0;
for (int i = 0; i < n; i++) sum += i;
return sum;
});
}
// ✅ ПРАВИЛЬНО: CPU-bound через Task.Run (без async/await, просто Task)
public Task<int> ComputeAsync(int n)
{
return Task.Run(() => {
int sum = 0;
for (int i = 0; i < n; i++) sum += i;
return sum;
});
}
// ❌ НЕПРАВИЛЬНО: async без await (предупреждение компилятора)
public async Task DoNothingAsync()
{
Console.WriteLine("Это не асинхронно!");
}
// ✅ ПРАВИЛЬНО: async с await
public async Task DoSomethingAsync()
{
await Task.Delay(100);
Console.WriteLine("Это асинхронно!");
}
💡 Золотое правило: async/await — для I/O-bound (не блокировать поток). Task.Run/Parallel — для CPU-bound (загрузить все ядра). Не путай их.
Типичные ошибки с async/await
❌ Блокировка async-метода через .Result или .Wait()
// ПЛОХО: вызывает deadlock (взаимную блокировку) в UI/ASP.NET Classic!
string result = DownloadAsync().Result;
DownloadAsync().Wait();
// ХОРОШО: используй await
string result = await DownloadAsync();
.Result и .Wait() блокируют текущий поток, ожидая завершения Task. Но Task может ожидать освобождения этого же потока для продолжения — deadlock. Никогда не используй .Result или .Wait() — всегда await.
❌ async void (кроме событий)
// ПЛОХО: async void — нельзя отловить исключение
public async void SaveDataAsync()
{
await File.WriteAllTextAsync("data.txt", text);
// Если здесь исключение — программа упадёт!
}
// ХОРОШО: async Task
public async Task SaveDataAsync()
{
await File.WriteAllTextAsync("data.txt", text);
}
❌ Забытый await
// ПЛОХО: задача запущена, но никто не ждёт результат
// Исключения "проглатываются"
SaveDataAsync(); // предупреждение компилятора CS4014
// ХОРОШО: await ожидает завершения
await SaveDataAsync();
// ИЛИ: если не нужно ждать — сохраняем Task и обрабатываем
Task t = SaveDataAsync();
// ... другая работа ...
await t; // ждём в конце
Полный пример: асинхронный загрузчик
Соберём всё вместе — напишем программу, которая асинхронно загружает несколько веб-страниц и сохраняет их в файлы:
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
string[] urls = {
"https://example.com",
"https://httpbin.org/get",
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/posts/2",
"https://jsonplaceholder.typicode.com/posts/3"
};
Console.WriteLine($"Начинаю загрузку {urls.Length} страниц...\n");
var watch = System.Diagnostics.Stopwatch.StartNew();
// Синхронно (для сравнения)
// await DownloadSyncAsync(urls); // ~5 секунд
// Асинхронно — все сразу
await DownloadAllAsync(urls); // ~1 секунда
watch.Stop();
Console.WriteLine($"\nВсего затрачено: {watch.ElapsedMilliseconds} мс");
}
static async Task DownloadAllAsync(string[] urls)
{
using var client = new HttpClient();
var tasks = new Task<string>[urls.Length];
for (int i = 0; i < urls.Length; i++)
{
string url = urls[i];
int index = i;
// Запускаем все загрузки конкурентно
tasks[i] = DownloadAndSaveAsync(client, url, $"page_{index + 1}.html");
}
// Ждём все загрузки
await Task.WhenAll(tasks);
Console.WriteLine("\nВсе загрузки завершены!");
}
static async Task<string> DownloadAndSaveAsync(HttpClient client, string url, string filename)
{
Console.WriteLine($" Скачиваю: {url}");
string content = await client.GetStringAsync(url);
await File.WriteAllTextAsync(filename, content);
Console.WriteLine($" Сохранено: {filename} ({content.Length} символов)");
return content;
}
static async Task DownloadSyncAsync(string[] urls)
{
using var client = new HttpClient();
foreach (string url in urls)
{
string content = await client.GetStringAsync(url);
// Каждая следующая загрузка ждёт предыдущую
Console.WriteLine($" {url}: {content.Length} символов");
}
}
}
Это реальный паттерн, который используется в продакшене. Когда нужно загрузить много данных — запускай все задачи сразу (через Task[] или списки) и жди их через Task.WhenAll.
Мини-тест
Вопрос 1: Что такое асинхронность?
Правильный ответ: B
Вопрос 2: Что возвращает async-метод без возвращаемого значения?
Правильный ответ: C
Вопрос 3: Почему Thread.Sleep() плох в async-методах?
Правильный ответ: B
Вопрос 4: Что делает Task.WhenAll()?
Правильный ответ: C
Вопрос 5: Для чего нужен ConfigureAwait(false)?
Правильный ответ: C
Вопрос 6: Чем отличается async/await от Task.Run?
Правильный ответ: B
Задание
Асинхронный агрегатор новостей
Создай консольное приложение — агрегатор заголовков новостей:
- Создай массив из 5-10 URL новостных сайтов (можно использовать любые публичные API).
- Напиши асинхронный метод
FetchTitleAsync(string url), который загружает страницу и извлекает текст из тега <title>. - Запусти все загрузки параллельно через Task.WhenAll.
- Выведи таблицу: URL → Заголовок → Длина страницы.
- Добавь тайм-аут 5 секунд: если сайт не отвечает — пиши «Тайм-аут».
- Сохрани результаты в файл titles.txt асинхронно.
Дополнительно:
- Подсчитай общее количество загруженных символов.
- Добавь прогресс-бар в консоль (просто точки по мере завершения задач).
- Попробуй заменить WhenAll на WhenAny и сравни поведение.
Подсказка
Для извлечения <title> из HTML можно использовать простой поиск подстроки: int start = content.IndexOf("<title>") + 7; int end = content.IndexOf("</title>"); string title = content[start..end];
Что важно запомнить
Тест: 8.13: Асинхронность
3 вопроса