W ostatnich postach przedstawiałem różne scenariusze użycia PLINQ. Ze względu na zrównoleglenie przetwarzania, kolejność na wyjściu nie zawsze będzie taka sama. Najlepiej to rozważyć na przykładzie:
int[] numbers = Enumerable.Range(1, 50).ToArray(); foreach(int number in numbers.AsParallel().Where(n=>n>2)) { Console.WriteLine(number); }
W scenariuszu sekwencyjnym, spodziewalibyśmy się liczb z zakresu 3-50. Skoro sekwencja wejściowa ma uporządkowane liczby od 1-50 to po wykonaniu zapytania oczekujemy po prostu filtracji. W PLINQ jednak jak wiemy, kilka osobnych wątków wykonuje zapytanie i z tego względu nie ma gwarancji, że kolejność będzie zachowana. Na wyjściu oczywiście uzyskamy elementy większe niż 2 ale w losowej kolejności typu 5,6,4,10,3. Jeśli to konieczne, istnieje sposób na zachowanie kolejności a mianowicie metoda AsOrdered:
int[] numbers = Enumerable.Range(1, 50).ToArray(); foreach(int number in numbers.AsParallel().AsOrdered().Where(n=>n>2)) { Console.WriteLine(number); }
Powinniśmy jednak tak projektować algorytmy, aby unikać potrzeby korzystania z AsOrdered – operacja oczywiście spowoduje zauważalną stratę w wydajności.
Kolejna kwestia to foreach. W poprzednich postach używałem zawsze ForEach. Nie jest to jednak optymalne rozwiązanie. W końcu najpierw dzielimy dane na podgrupy, które później będą wykonywane niezależnie w różnych wątkach. Następnie wywołujemy ForEach, który ma charakter sekwencyjny i zawsze wymaga wykonania całości. Jeśli korzystamy z ForEach, zawsze musimy poczekać, aż wszystkie wątki wykonają się i na końcu trzeba scalić wynik – dość czasochłonne. Na szczęście istnieje funkcja ForAll, która nie czeka do końca a korzysta z danych, które aktualnie są już wygenerowane:
int[] numbers = Enumerable.Range(1, 50).ToArray(); numbers.AsParallel().Where(n=>n>2).ForAll(Console.WriteLine);
ForAll zatem przetwarza dane, zanim jeszcze wszystkie wątki skończą zadanie. W ten sposób unikamy ostatniego kroku – scalenia poszczególnych zapytań.
Ponadto istnieje sposób, na manualne określenie metody scalania:
int[] numbers = Enumerable.Range(1, 50).ToArray(); var query = numbers.AsParallel().WithMergeOptions(ParallelMergeOptions.FullyBuffered).Where(n => n > 2);
ParallelMergeOptions przyjmuje następujące wartości:
- AutoBuffered – dokonuje buforowania elementów. W praktyce to oznacza, że gdy mamy ForEach, elementy dostępne będą dopiero po jakimś czasie, gdy bufor zapełni się.
- FullyBuffered – elementy będą dopiero dostępne gdy wszystkie wątki zostaną wykonane. W międzyczasie będą one buforować dane a na końcu bufor zostanie w całości zwrócony.
- NotBuffered – brak bufora. Elementy będą wyświetlane natychmiast gdy są tylko dostępne.
- Default – aktualnie jest to AutoBufferred.
Należy jednak pamiętać, że metoda WithMergeOptions to tylko wskazówka dla PLINQ – jeśli w czasie wykonania zapytania okaże się, że żądany typ scalania nie ma sensu to po prostu zostanie on zignorowany. Funkcja ForAll zatem używa NotBuffered ponieważ nic nie scala. Podobnie każda inna funkcja ma skojarzony ze sobą sposób scalania. Funkcja WithMergeOptions powinna być wykorzystana wyłącznie w scenariuszach, gdzie mamy pewność, że domyślne zachowanie nie jest najoptymalniejsze.