JavaRush /Курсы /C# SELF /Параллельная обработка данных

Параллельная обработка данных

C# SELF
60 уровень , 3 лекция
Открыта

1. Введение

Сегодня мы разберём, как обрабатывать большие объёмы данных максимально быстро, используя все доступные процессорные ядра вашего компьютера (или сервера). Для этого нам пригодятся классы из пространства имён System.Threading.Tasks.Parallel, а именно методы Parallel.For и Parallel.ForEach.

Как быть, если задача — чистый CPU-bound?

Классический цикл for или foreach обрабатывает элементы друг за другом. Просто и надёжно. Но если у вас многоядерный процессор, цикл использует только одно ядро, а остальные откровенно скучают. Почему бы не распределить части массива по разным ядрам для одновременной обработки?

Пример:


// Считаем сумму квадратов от 1 до N
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
    sum += i * i;
}

Этот код прост, но работает последовательно. Что если разбросать задачи по ядрам?

Meet the Family: Parallel.For и Parallel.ForEach

Что это такое?

  • Parallel.For — работает как обычный цикл for, только разбивает работу на части и автоматически распределяет по потокам, используя все доступные ядра.
  • Parallel.ForEach — обрабатывает коллекцию, как обычный foreach, но тоже параллельно.

Официальная документация:

Почему это удобно?

Вам не нужно самостоятельно создавать, запускать и контролировать потоки. Фреймворк делает всю тяжёлую работу за вас. Вы пишете код, похожий на обычный цикл, а параллелизм происходит автоматически, под капотом.

2. Синтаксис: базовые примеры

Parallel.For


long total = 0;
Parallel.For(1, 1_000_001, i =>
{
    // Эту лямбду могут одновременно выполнять разные потоки
    Interlocked.Add(ref total, i * i); // Чтобы не было гонок
});
Console.WriteLine($"Сумма квадратов: {total}");

Обратите внимание: переменную total мы обновляем через Interlocked.Add — чтобы избежать гонки данных.

Parallel.ForEach


var numbers = Enumerable.Range(1, 10_000_000).ToArray();
long sum = 0;

Parallel.ForEach(numbers, num =>
{
    Interlocked.Add(ref sum, num * num); // Безопасное сложение
});
Console.WriteLine($"Сумма квадратов: {sum}");

Взгляд изнутри (Визуальная схема)


+-------------------+
|Коллекция/диапазон |
+---------+---------+
          |
          v
  +----------------------+
  |   Parallel.ForEach   |
  +----------+-----------+
             |
        +----+----+----+----+
        |         |         |
        v         v         v
  Task #1    Task #2    Task #3   ... (доступно ядер)
        |         |         |
     +--+----+  +--+-----+  +--+-----+
     |Обработка|  |Обработка|  |Обработка|
     +-------+  +--------+  +--------+
        \         |         /
         +--------+--------+
                  |
                  v
             Результат

3. Анализ больших файлов (CPU-bound обработка)

Пусть у нас есть текстовый файл с десятками тысяч строк — например, каждая строка содержит число. Нужно прочитать файл, возвести каждое число в квадрат и посчитать сумму квадратов.

Синхронная версия


string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;

foreach (var line in lines)
{
    if (long.TryParse(line, out long n))
    {
        sum += n * n;
    }
}
Console.WriteLine($"Сумма квадратов: {sum}");

Параллельная версия с Parallel.For


string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;

Parallel.For(0, lines.Length, i =>
{
    if (long.TryParse(lines[i], out long n))
    {
        Interlocked.Add(ref sum, n * n);
    }
});
Console.WriteLine($"Сумма квадратов: {sum}");

Что изменилось: мы заменили обычный цикл на параллельный, а sum теперь увеличиваем через Interlocked.Add — чтобы не было конфликтов между потоками.

4. Что происходит под капотом?

Когда вы вызываете Parallel.For или Parallel.ForEach, .NET автоматически делит вашу работу на фрагменты и распределяет их по доступным процессорным ядрам, используя пул потоков. Каждый фрагмент обрабатывается независимо в своём потоке.

Преимущество: если у вас 4 ядра, работа может идти почти в 4 раза быстрее (если задача не зависит от внешних ресурсов и не упирается в другие ограничения, например, память или скорость чтения с диска).

Сравним время выполнения


var numbers = Enumerable.Range(1, 100_000_000).ToArray();
long sumSync = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();

foreach (var n in numbers)
    sumSync += n * n;

sw.Stop();
Console.WriteLine($"Sync: {sw.ElapsedMilliseconds} ms, сумма: {sumSync}");

long sumParallel = 0;
sw.Restart();

Parallel.ForEach(numbers, n =>
    Interlocked.Add(ref sumParallel, n * n)
);

sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, сумма: {sumParallel}");

Попробуйте сами! На мощном компьютере ускорение может быть в разы, но всё зависит от задач и узких мест.

5. Полезные нюансы

Управление степенью параллелизма

Иногда имеет смысл ограничить число используемых потоков (например, чтобы не перегружать систему). Для этого используйте MaxDegreeOfParallelism:


using System.Threading.Tasks;
long sum = 0;
var options = new ParallelOptions { 
    MaxDegreeOfParallelism = 2 
};
Parallel.For(0, 100, options, i =>
{
    Interlocked.Add(ref sum, i * i);
});
Console.WriteLine($"Сумма квадратов: {sum}");

Где это полезно: если вы знаете, что часть вычислений сильно нагружает диск, а не процессор — задайте число потоков поменьше и оцените влияние на производительность.

Когда использовать параллельные циклы

Обычный for Parallel.For/Parallel.ForEach
Процессоры Использует одно ядро Использует все ядра
Порядок Гарантирован Не гарантирован
Скорость Обычно медленнее Часто существенно быстрее
Простота Очень просто Требует учёта потокобезопасности
Лучшее применение Маленькие объёмы данных, I/O-bound Большие объёмы данных, CPU-bound

Расширение: что ещё умеет Parallel?

Parallel.Invoke() — запускает несколько независимых методов одновременно:


static void DoTask1() => Console.WriteLine("Задача 1 выполнена");
static void DoTask2() => Console.WriteLine("Задача 2 выполнена");
static void DoTask3() => Console.WriteLine("Задача 3 выполнена");

Parallel.Invoke(
    () => DoTask1(),
    () => DoTask2(),
    () => DoTask3()
);

Каждый метод выполнится на своём ядре по возможности.

Применение в реальной жизни

  • Обработка изображений: одновременная обработка разных блоков (например, применение фильтра).
  • Вычисления по массивам: финансовые расчёты, моделирование (оценка портфеля по сценариям).
  • Работа с большими журналами логов: поиск и агрегация на нескольких ядрах.
  • Машинное обучение: разбиение на независимые задачи (батчи данных, feature engineering).

И, конечно, на собеседовании вы сможете не только рассказать, что такое параллельные циклы, но и честно прокомментировать их плюсы и минусы.

6. Типичные ошибки при работе с Parallel.For и Parallel.ForEach

Ошибка №1: Игнорирование гонок данных.
Обновление общей переменной без Interlocked или lock приводит к некорректным результатам из-за одновременного доступа потоков.

Ошибка №2: Использование для I/O-bound задач.
Параллельные циклы не ускоряют задачи, зависящие от диска или сети, а могут даже замедлить их из-за накладных расходов.

Ошибка №3: Предположение о порядке выполнения.
Параллельные циклы не гарантируют порядок обработки элементов, что может сломать логику, если она зависит от последовательности.

Ошибка №4: Игнорирование побочных эффектов.
Изменение общего состояния (например, коллекций) в параллельных циклах может привести к ошибкам, если не использовать потокобезопасные структуры.

Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ