Перейти к содержанию
Educora
Продвинутый23 мин14 / 16

Асинхронное и параллельное программирование

Различай работу с ожиданием и вычислительную работу: асинхронные потоки, токены отмены, `Parallel` и PLINQ, `ConfigureAwait` и ловушки блокировки.

Проверь себя
В этом уроке ты узнаешь
  • Выбирать подходящий инструмент для работы с вводом-выводом и для вычислений
  • Писать IAsyncEnumerable<T>, читать его через await foreach и останавливать работу с помощью CancellationToken
  • Распараллеливать вычисления с Parallel и PLINQ и избегать ловушек блокировки

В прошлом модуле ты научился выполнять работу с ожиданием с помощью async/await и Task.WhenAll. Теперь пора различать две разные задачи. Работа с вводом-выводом (сеть, файлы, базы данных) большую часть времени ждёт — ей нужен async/await. Вычислительная работа (расчёты, обработка изображений) загружает процессор — делить её между ядрами помогают Parallel и PLINQ. В этом уроке ты изучишь и то и другое, а также асинхронные потоки и отмену.

Вид работыПримерИнструмент
Ввод-вывод: ожиданиеHTTP-запрос, файл, SQLasync/await, Task.WhenAll
Данные, приходящие частямипостраничный API, большой файлIAsyncEnumerable<T>, await foreach
Процессор: вычисленияпростые числа, отчёты, изображенияParallel.For, AsParallel()
Долгая или уже ненужная работапользователь нажал «Отмена»CancellationToken

Асинхронные потоки: IAsyncEnumerable

Task<List<T>> даёт результат, только когда готов весь список. Если данные приходят частями (постраничный API, большой файл), лучше обрабатывать каждый элемент сразу по прибытии. Асинхронный поток — IAsyncEnumerable<T> — объединяет yield return и await, а потребитель читает его через await foreach. В .NET 10 методы LINQ (Where, Select, ToListAsync) доступны и для асинхронных потоков без дополнительного пакета.

C#
await foreach (int score in LoadScoresAsync())
    Console.WriteLine($"Received {score}");

List<int> high = await LoadScoresAsync().Where(s => s >= 80).ToListAsync();
Console.WriteLine($"80 and above: {string.Join(", ", high)}");

async IAsyncEnumerable<int> LoadScoresAsync()
{
    int[] fromServer = [95, 78, 88];
    foreach (int score in fromServer)
    {
        await Task.Delay(200);            // each item arrives from the network
        yield return score;
    }
}
Ожидаемый результат
Received 95
Received 78
Received 88
80 and above: 95, 88
Каждый балл печатается сразу по прибытии; во второй части к асинхронному потоку применён LINQ (.NET 10)

Отмена: CancellationToken

Пользователь закрыл страницу, а сервер всё ещё готовит отчёт? Долгую работу нужно уметь останавливать. CancellationTokenSource создаёт сигнал отмены, а его Token передаётся по цепочке во все асинхронные методы. CancelAfter ставит таймер; методы .NET вроде Task.Delay, HttpClient и EF Core принимают токен и при отмене выбрасывают OperationCanceledException. В собственном вычислительном цикле вызывай token.ThrowIfCancellationRequested().

C#
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(450));

try
{
    await DownloadAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Cancelled: it took too long");
}

async Task DownloadAsync(CancellationToken token)
{
    for (int part = 1; part <= 5; part++)
    {
        await Task.Delay(300, token);     // throws once the token is cancelled
        Console.WriteLine($"Part {part} downloaded");
    }
}
Ожидаемый результат
Part 1 downloaded
Cancelled: it took too long
Отмена на 450 мс: первая часть (300 мс) скачана, вторая прервана

Для простого ограничения по времени саму задачу менять не нужно: WaitAsync(TimeSpan) (с .NET 6) выбрасывает TimeoutException, если результат не пришёл вовремя. Учти: сама работа продолжается в фоне — чтобы действительно её остановить, нужно передать токен.

C#
Task<string> answer = FetchAsync("exam results", TimeSpan.FromSeconds(2));

try
{
    Console.WriteLine(await answer.WaitAsync(TimeSpan.FromMilliseconds(500)));
}
catch (TimeoutException)
{
    Console.WriteLine("Timeout: showing yesterday's cached results");
}

async Task<string> FetchAsync(string what, TimeSpan delay)
{
    await Task.Delay(delay);
    return $"Fresh {what}";
}
Ожидаемый результат
Timeout: showing yesterday's cached results
Сервер отвечает 2 секунды, а мы ждём только 0,5 секунды

Параллельные вычисления: Parallel и PLINQ

Parallel.For и Parallel.ForEach делят итерации цикла между потоками пула, а AsParallel() превращает обычный запрос LINQ в PLINQ — параллельный LINQ. Ниже четыре тяжёлых вычисления — подсчёт простых чисел до разных пределов — идут одновременно. Главное правило: каждый поток либо пишет в свою ячейку (results[i]), либо атомарно меняет общую переменную через Interlocked.

C#
using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int[] limits = [1_000_000, 2_000_000, 3_000_000, 4_000_000];
int[] results = new int[limits.Length];
Parallel.For(0, limits.Length, i => results[i] = CountPrimes(limits[i]));   // each task writes its own cell
for (int i = 0; i < limits.Length; i++)
    Console.WriteLine($"Primes below {limits[i]:N0}: {results[i]:N0}");

long total = 0;
Parallel.ForEach(Enumerable.Range(1, 1000), n => Interlocked.Add(ref total, n));
Console.WriteLine($"Sum 1..1000 = {total}");

int evenSquares = Enumerable.Range(1, 1000).AsParallel().Count(n => n * n % 2 == 0);
Console.WriteLine($"PLINQ, even squares: {evenSquares}");

static int CountPrimes(int limit)
{
    int count = 0;
    for (int n = 2; n < limit; n++)
    {
        bool prime = true;
        for (int d = 2; d * d <= n; d++)
            if (n % d == 0) { prime = false; break; }
        if (prime) count++;
    }
    return count;
}
Ожидаемый результат
Primes below 1,000,000: 78,498
Primes below 2,000,000: 148,933
Primes below 3,000,000: 216,816
Primes below 4,000,000: 283,146
Sum 1..1000 = 500500
PLINQ, even squares: 500
Четыре вычисления идут параллельно, но результаты всегда печатаются в одном порядке, ведь у каждого своя ячейка
Состояние гонки
long total = 0;
Parallel.ForEach(Enumerable.Range(1, 1000), n =>
{
    total += n;      // read + add + write from many threads: a race
});
// the result can be less than 500500
Атомарное сложение
long total = 0;
Parallel.ForEach(Enumerable.Range(1, 1000), n =>
{
    Interlocked.Add(ref total, n);   // one atomic operation
});
// always 500500 (or simply: Enumerable.Range(1, 1000).AsParallel().Sum())

А как вычислительная работа сочетается с асинхронным кодом? В графическом интерфейсе отправь тяжёлый расчёт в поток пула через await Task.Run(() => CountPrimes(4_000_000)): окно не зависает, а результат придёт, когда будет готов. На сервере (ASP.NET Core) Task.Run обычно ничего не даёт — работа просто переходит из одного потока пула в другой. Task.Run нужен, чтобы увести одну работу в фон, а Parallel — чтобы разбить одну большую работу на части.

ConfigureAwait и ловушки блокировки

В приложениях с графическим интерфейсом (WPF, WinForms, MAUI) есть контекст синхронизации: после await код возвращается в UI-поток, чтобы безопасно менять кнопки и текст. Библиотечный код с интерфейсом не работает, поэтому там пишут ConfigureAwait(false) — «продолжение можно выполнить в любом потоке». Это немного быстрее и предотвращает некоторые взаимоблокировки (deadlock). В ASP.NET Core и консольных приложениях такого контекста нет, поэтому в коде приложения ConfigureAwait обычно не нужен.

C#
// library code: it does not need to come back to the caller's UI thread
public static async Task<int> CountLinesAsync(string path, CancellationToken token = default)
{
    string[] lines = await File.ReadAllLinesAsync(path, token).ConfigureAwait(false);
    return lines.Count(line => line.Length > 0);
}
Библиотечный метод: принимает токен и использует ConfigureAwait(false)
Неверно: блокировка через `.Result`
// blocks the thread while waiting;
// in a UI app (WPF, WinForms) this can freeze or deadlock
string report = LoadReportAsync().Result;
Верно: `await`
// the thread is free while waiting
string report = await LoadReportAsync();

Главное

  • Для ввода-вывода — async/await, для вычислений — Parallel и PLINQ.
  • IAsyncEnumerable<T> + await foreach обрабатывают элементы по мере поступления; в .NET 10 для них есть LINQ.
  • CancellationToken передаётся по цепочке; при отмене выбрасывается OperationCanceledException, а для лимита времени есть WaitAsync.
  • В параллельном коде каждый поток пишет в свою ячейку или использует Interlocked.
  • «Async до конца»: никаких .Result и .Wait(); в библиотечном коде — ConfigureAwait(false).

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Какой инструмент подходит для подсчёта простых чисел на всех ядрах?