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.
GO TO FULL VERSION