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