1. Вступ
Сьогодні розберемося, як обробляти великі обсяги даних максимально швидко, використовуючи всі доступні процесорні ядра вашого компʼютера (або сервера). Для цього нам стане у пригоді клас Parallel із простору імен System.Threading.Tasks, а саме методи 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;
}
Цей код простий, але працює послідовно. А що, якщо розкидати завдання між ядрами?
Знайомство з сімейством: 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: ігнорування побічних ефектів.
Зміна спільного стану (наприклад, колекцій) у паралельних циклах може призвести до помилок, якщо не використовувати потокобезпечні структури.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ