CodeGym /Kursy /C# SELF /Równoległe przetwarzanie danych

Równoległe przetwarzanie danych

C# SELF
Poziom 60, Lekcja 3
Dostępny

1. Wprowadzenie

Dzisiaj przeanalizujemy, jak przetwarzać duże ilości danych jak najszybciej, wykorzystując wszystkie dostępne rdzenie procesora twojego komputera (lub serwera). Przydadzą nam się klasy z przestrzeni nazw System.Threading.Tasks.Parallel, a konkretnie metody Parallel.For i Parallel.ForEach.

Co zrobić, jeśli zadanie jest czysto CPU-bound?

Klasyczna pętla for lub foreach przetwarza elementy jeden po drugim. Prosto i niezawodnie. Ale jeśli masz wielordzeniowy procesor, pętla używa tylko jednego rdzenia, a pozostałe się nudzą. Czemu by nie rozdzielić części tablicy na różne rdzenie, żeby przetwarzać je jednocześnie?

Przykład:


// Obliczamy sumę kwadratów od 1 do N
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
    sum += i * i;
}

Ten kod jest prosty, ale działa sekwencyjnie. A co jeśli rozrzucić zadania po rdzeniach?

Poznaj rodzinę: Parallel.For i Parallel.ForEach

Czym one są?

  • Parallel.For — działa jak zwykła pętla for, tylko dzieli pracę na części i automatycznie rozdziela je na wątki, wykorzystując wszystkie dostępne rdzenie.
  • Parallel.ForEach — przetwarza kolekcję jak zwykły foreach, ale równolegle.

Oficjalna dokumentacja:

Dlaczego to wygodne?

Nie musisz ręcznie tworzyć, uruchamiać i kontrolować wątków. Framework robi całą ciężką robotę za ciebie. Piszesz kod podobny do zwykłej pętli, a równoległość dzieje się automatycznie, pod maską.

2. Składnia: podstawowe przykłady

Parallel.For


long total = 0;
Parallel.For(1, 1_000_001, i =>
{
    // Ta lambdę mogą jednocześnie wykonywać różne wątki
    Interlocked.Add(ref total, i * i); // Żeby nie było konfliktów
});
Console.WriteLine($"Suma kwadratów: {total}");

Zwróć uwagę: zmienną total aktualizujemy przez Interlocked.Add — żeby uniknąć race condition.

Parallel.ForEach


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

Parallel.ForEach(numbers, num =>
{
    Interlocked.Add(ref sum, num * num); // Bezpieczne dodawanie
});
Console.WriteLine($"Suma kwadratów: {sum}");

Widok od środka (schemat wizualny)


+-------------------+
|Kolekcja/zakres    |
+---------+---------+
          |
          v
  +----------------------+
  |   Parallel.ForEach   |
  +----------+-----------+
             |
        +----+----+----+----+
        |         |         |
        v         v         v
  Task #1    Task #2    Task #3   ... (dostępne rdzenie)
        |         |         |
     +--+----+  +--+-----+  +--+-----+
     |Przetwar-|  |Przetwar-|  |Przetwar-|
     |zanie    |  |zanie    |  |zanie    |
     +-------+  +--------+  +--------+
        \         |         /
         +--------+--------+
                  |
                  v
               Wynik

3. Analiza dużych plików (przetwarzanie CPU-bound)

Załóżmy, że mamy plik tekstowy z dziesiątkami tysięcy linii — np. każda linia zawiera liczbę. Trzeba wczytać plik, podnieść każdą liczbę do kwadratu i policzyć sumę kwadratów.

Wersja synchroniczna


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($"Suma kwadratów: {sum}");

Wersja równoległa z 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($"Suma kwadratów: {sum}");

Co się zmieniło: zamieniliśmy zwykłą pętlę na równoległą, a sum teraz zwiększamy przez Interlocked.Add — aby uniknąć konfliktów między wątkami.

4. Co się dzieje pod maską?

Kiedy wywołujesz Parallel.For lub Parallel.ForEach, .NET automatycznie dzieli twoją pracę na fragmenty i rozdziela je na dostępne rdzenie procesora, używając puli wątków. Każdy fragment jest przetwarzany niezależnie w swoim wątku.

Zaleta: jeśli masz 4 rdzenie, praca może iść prawie 4 razy szybciej (jeśli zadanie nie zależy od zewnętrznych zasobów i nie napotyka innych ograniczeń, np. pamięci czy szybkości odczytu z dysku).

Porównajmy czasy wykonania


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, suma: {sumSync}");

long sumParallel = 0;
sw.Restart();

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

sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, suma: {sumParallel}");

Spróbuj sam! Na mocnym komputerze przyspieszenie może być wielokrotne, ale wszystko zależy od zadań i wąskich gardeł.

5. Przydatne niuanse

Kontrola stopnia równoległości

Czasami warto ograniczyć liczbę używanych wątków (np. żeby nie przeciążyć systemu). Użyj do tego 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($"Suma kwadratów: {sum}");

Gdzie to się przydaje: jeśli wiesz, że część obliczeń mocno obciąża dysk, a nie procesor — ustaw mniejszą liczbę wątków i oceń wpływ na wydajność.

Kiedy używać pętli równoległych

Zwykły for Parallel.For/Parallel.ForEach
Procesory Używa jednego rdzenia Używa wszystkich rdzeni
Kolejność Gwarantowana Nie jest gwarantowana
Szybkość Zazwyczaj wolniej Często znacznie szybciej
Prostota Bardzo proste Wymaga uwagi dotyczącej thread-safety
Najlepsze zastosowanie Małe ilości danych, I/O-bound Duże ilości danych, CPU-bound

Rozszerzenie: co jeszcze potrafi Parallel?

Parallel.Invoke() — uruchamia kilka niezależnych metod jednocześnie:


static void DoTask1() => Console.WriteLine("Zadanie 1 wykonane");
static void DoTask2() => Console.WriteLine("Zadanie 2 wykonane");
static void DoTask3() => Console.WriteLine("Zadanie 3 wykonane");

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

Każda metoda zostanie wykonana na swoim rdzeniu, jeśli to możliwe.

Zastosowania w praktyce

  • Przetwarzanie obrazów: jednoczesne przetwarzanie różnych bloków (np. zastosowanie filtra).
  • Obliczenia na tablicach: obliczenia finansowe, symulacje (ocena portfela po scenariuszach).
  • Praca z dużymi logami: wyszukiwanie i agregacja na wielu rdzeniach.
  • Machine learning: dzielenie na niezależne zadania (batch'e danych, feature engineering).

I oczywiście na rozmowie kwalifikacyjnej będziesz mógł nie tylko opowiedzieć, czym są pętle równoległe, ale też uczciwie skomentować ich plusy i minusy.

6. Typowe błędy przy pracy z Parallel.For i Parallel.ForEach

Błąd #1: Ignorowanie race condition.
Aktualizacja wspólnej zmiennej bez Interlocked lub lock prowadzi do niepoprawnych wyników z powodu jednoczesnego dostępu wątków.

Błąd #2: Używanie do zadań I/O-bound.
Pętle równoległe nie przyspieszą zadań zależnych od dysku lub sieci, a mogą nawet je spowolnić z powodu narzutów.

Błąd #3: Zakładanie kolejności wykonania.
Pętle równoległe nie gwarantują kolejności przetwarzania elementów, co może zepsuć logikę, jeśli zależy ona od sekwencji.

Błąd #4: Ignorowanie efektów ubocznych.
Modyfikacja wspólnego stanu (np. kolekcji) w pętlach równoległych może prowadzić do błędów, jeśli nie używasz thread-safe struktur.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION