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

Паралельна обробка даних

C# SELF
Рівень 60 , Лекція 3
Відкрита

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: ігнорування побічних ефектів.
Зміна спільного стану (наприклад, колекцій) у паралельних циклах може призвести до помилок, якщо не використовувати потокобезпечні структури.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ