$ sudo teach IT
Модуль 8 · Функции

Урок 8.13 — Асинхронность

Синхронный vs асинхронный код. async, await, Task и параллельное выполнение.

Ты уже умеешь писать код, который выполняется строка за строкой — это синхронный код. Но что если программа должна скачать файл из интернета, прочитать большой файл с диска или сделать HTTP-запрос? В синхронном мире программа замрёт на время этой операции. Асинхронность позволяет выполнять долгие операции, не блокируя основной поток. Пока одна задача ждёт ответа от сервера — другая спокойно работает.

⚖️

Синхронность vs Асинхронность vs Параллельность

Прежде чем погружаться в async/await, нужно чётко различать три концепции. Новички часто путают асинхронность с параллельностью — но это разные вещи.

🧍 Синхронность (Synchronous)

Код выполняется строка за строкой. Пока одна операция не закончится — следующая не начнётся. Как очередь в магазине: сначала один покупатель, потом другой. Если первый долго выбирает — все ждут.

C#
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. Это как «трек-номер посылки»: ты не ждёшь у двери курьера, а занимаешься своими делами, а когда посылка приходит — получаешь уведомление.

C# · Простейший асинхронный метод
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 везде, кроме событий.

Пример с возвратом значения:

C# · Task<T> с возвратом значения
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;
    }
}
💡 HttpClient — один из главных кандидатов на асинхронность. GetStringAsync, PostAsync, SendAsync — все методы HttpClient асинхронные.
⚙️

Как работает await изнутри

Когда компилятор видит async метод, он превращает его в конечный автомат (state machine). Вот что происходит под капотом:

  1. Метод запускается синхронно до первого await
  2. Встретив await task, компилятор проверяет: задача уже завершена? Если да — продолжаем синхронно (быстрый путь).
  3. Если задача ещё выполняется — метод приостанавливается и возвращает Task в вызывающий код.
  4. Поток возвращается в пул и может делать другую работу.
  5. Когда задача завершается — continuation (продолжение) запускается на любом свободном потоке из пула.
  6. Метод возобновляется с того же места, где остановился — все локальные переменные сохранены.

💡 Ключевой момент: асинхронность не создаёт новый поток! Она эффективно использует существующий поток, не блокируя его. В отличие от Task.Run(), который действительно запускает код на новом потоке из пула.

⏰

Task.Delay() — асинхронная пауза

Task.Delay() — это асинхронный аналог Thread.Sleep(). Разница колоссальная:

❌ Thread.Sleep() — блокирует поток

Поток не может делать ничего другого, пока спит. Если в GUI-приложении вызвать Sleep в UI-потоке — интерфейс зависнет.

✅ Task.Delay() — не блокирует поток

Поток возвращается в пул и может обслуживать другие задачи. Через указанное время выполнение возобновляется.

C# · Сравнение Sleep vs 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, который завершится, когда первая из задач завершится. Остальные продолжают выполняться в фоне.

C# · Когда все задачи завершатся
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 — первый завершившийся

C# · 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;  // результат успешной загрузки
}
💡 WhenAll запускает задачи конкурентно, но не обязательно параллельно. Для I/O-bound задач это идеально — они не нагружают CPU. Для CPU-bound задач используй Task.Run в комбинации с WhenAll.
⚡

ConfigureAwait(false) — оптимизация для библиотек

Когда ты делаешь await task, после завершения задачи продолжение (continuation) по умолчанию пытается вернуться на исходный контекст синхронизации (SynchronizationContext). В GUI-приложениях это UI-поток. В ASP.NET Core — контекст запроса. Но в библиотечном коде (не UI) возвращаться в исходный контекст не нужно — это лишние накладные расходы.

C# · ConfigureAwait(false)
// Без 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)
C# · Примеры правильного и неправильного использования
// ✅ ПРАВИЛЬНО: 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;  // ждём в конце
📦

Полный пример: асинхронный загрузчик

Соберём всё вместе — напишем программу, которая асинхронно загружает несколько веб-страниц и сохраняет их в файлы:

C# · Асинхронный загрузчик
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: Что такое асинхронность?

A) Выполнение кода на нескольких ядрах процессора
B) Выполнение I/O-операций без блокировки потока
C) Ускорение работы процессора
D) Создание новых потоков для каждой задачи

Правильный ответ: B

Вопрос 2: Что возвращает async-метод без возвращаемого значения?

A) void
B) null
C) Task
D) Task<void>

Правильный ответ: C

Вопрос 3: Почему Thread.Sleep() плох в async-методах?

A) Он медленнее Task.Delay
B) Он блокирует поток, а не просто приостанавливает метод
C) Он не работает с Task
D) Он выбрасывает исключение

Правильный ответ: B

Вопрос 4: Что делает Task.WhenAll()?

A) Запускает задачи по очереди
B) Возвращает Task, который завершается при завершении любой из задач
C) Возвращает Task, который завершается при завершении всех задач
D) Отменяет все задачи

Правильный ответ: C

Вопрос 5: Для чего нужен ConfigureAwait(false)?

A) Для отмены задачи
B) Для принудительного выполнения на UI-потоке
C) Для отключения возврата в исходный контекст синхронизации
D) Для увеличения приоритета задачи

Правильный ответ: C

Вопрос 6: Чем отличается async/await от Task.Run?

A) Это одно и то же
B) async/await — для I/O-bound без нового потока, Task.Run — для CPU-bound с новым потоком
C) async/await создаёт потоки, Task.Run — нет
D) async/await быстрее Task.Run

Правильный ответ: B

🧪

Задание

Асинхронный агрегатор новостей

Создай консольное приложение — агрегатор заголовков новостей:

  1. Создай массив из 5-10 URL новостных сайтов (можно использовать любые публичные API).
  2. Напиши асинхронный метод FetchTitleAsync(string url), который загружает страницу и извлекает текст из тега <title>.
  3. Запусти все загрузки параллельно через Task.WhenAll.
  4. Выведи таблицу: URL → Заголовок → Длина страницы.
  5. Добавь тайм-аут 5 секунд: если сайт не отвечает — пиши «Тайм-аут».
  6. Сохрани результаты в файл titles.txt асинхронно.

Дополнительно:

  • Подсчитай общее количество загруженных символов.
  • Добавь прогресс-бар в консоль (просто точки по мере завершения задач).
  • Попробуй заменить WhenAll на WhenAny и сравни поведение.

Подсказка

Для извлечения <title> из HTML можно использовать простой поиск подстроки: int start = content.IndexOf("<title>") + 7; int end = content.IndexOf("</title>"); string title = content[start..end];

📌

Что важно запомнить

1️⃣
Асинхронность ≠ параллельность. Асинхронность — неблокирующие I/O-операции (один поток). Параллельность — одновременное выполнение (много потоков/ядер).
2️⃣
async в сигнатуре разрешает await внутри метода. await приостанавливает метод, не блокируя поток. Task представляет асинхронную операцию.
3️⃣
Task.Delay() — асинхронная пауза. Thread.Sleep() — блокирующая. В async-коде используй только Task.Delay.
4️⃣
Task.WhenAll() — ждёт все задачи. Task.WhenAny() — ждёт первую. Используй для параллельного запуска I/O-операций.
5️⃣
ConfigureAwait(false) — для библиотечного кода, чтобы не возвращаться в исходный контекст. async void — только для событий. .Result/.Wait() — никогда (deadlock).

Тест: 8.13: Асинхронность

3 вопроса

Асинхронность

Premium