Klasa Parallel

Klasa Parallel stanowi doskonałe dopełnienie do task’ów.  Dostarcza trzy statyczne metody:

  1. Invoke – współbieżne uruchomienie kilku zadań.
  2. For – współbieżna pętla FOR.
  3. ForEach – współbieżna pętla foreach.

Klasa stanowi helper, ponieważ wszystkie powyższe operacje można wykonać za pomocą obiektów Task lub Thread. Implementacja jednak własnej pętli współbieżnej jest zawsze trudniejsza niż użycie po prostu gotowej metody.

Zacznijmy od Invoke, przykład:

static void Main(string[] args)
{
   Parallel.Invoke(Method1, Method2, Method3);        
}
static void Method1()
{
   Console.WriteLine("Method1");
}
static void Method2()
{
   Console.WriteLine("Method2");
}
static void Method3()
{
   Console.WriteLine("Method3");
}

Invoke przyjmuje jako parametry metody Action. Następnie uruchomi zadania równoległe. Należy zwrócić uwagę na następujące szczegóły implementacyjne:

  1. Invoke zakończy się dopiero gdy wszystkie zadania wykonają się. Wywołanie zatem blokuje wykonanie aż do momentu zakończenia się wszystkich metod.
  2. Nie ma gwarancji, że każda metoda zostanie wykonana równoległe. W zależności od scheduler’a może okazać się, że cześć zostanie wykonana sekwencyjnie.
  3. Invoke jest na tyle inteligentny, że nie zabija wątku po zakończeniu zadania. Jeśli przekażemy dużą tablicę metod (np. 100 zadań do wykonania), wtedy zostanie najpierw wykonanych powiedzmy 10 metod, a następnie kolejne 10, wykorzystując już poprzednie wątki. Wszystko zależy oczywiście od aktualnego obciążenia systemu i architektury. Wystarczy wiedzieć, że wątki w Invoke mogą być ponownie używane i w pewnych sytuacjach nie wszystkie metody są uruchamiane jednocześnie.
  4. Wyjątek wyrzucony w jednym zadaniu, nie wpływa na inne. Jeśli Method1 wyrzuci wyjątek wtedy i tak Method2 oraz Method3 zostaną wykonane. Na końcu jednak (po zakończeniu wszystkich task’ow), Invoke wyrzuci zbiorowy wyjątek.

Na udowodnienie ostatniego punktu napiszmy krótki program:

static void Main(string[] args)
{
   try
   {
       Parallel.Invoke(Method1, Method2, Method3);
   }
   catch(AggregateException e)
   {
       foreach (Exception innerException in e.InnerExceptions)
       {
           Console.WriteLine(innerException.Message);   
       }            
   }
}
static void Method1()
{
   throw new NotImplementedException();
}
static void Method2()
{
   Console.WriteLine("Method2");
   Thread.Sleep(5000);
}
static void Method3()
{
   Console.WriteLine("Method3");
}

Metoda Method1 wyrzuca wyjątek. Mimo to, taski Method2 oraz Method3 zostaną wykonane – są one całkowicie niezależne. Na końcu Invoke wyrzuci wszystkie wyjątki natrafione podczas wykonywania tasków. AggregateException zawiera kolekcje InnerException. Dla powyższego przykładu, ta kolekcja zawiera wyłącznie NotImplementedException.

Zadania można również anulować tzn.:

class Program
{
    static CancellationTokenSource _cancellationToken = new CancellationTokenSource();

    static void Main(string[] args)
    {        
        try
        {
            Task.Factory.StartNew(() => _cancellationToken.Cancel());
            var parallelOptions=new ParallelOptions();
            parallelOptions.CancellationToken = _cancellationToken.Token;
            Parallel.Invoke(parallelOptions, Method1, Method2, Method3);
        }
        catch(OperationCanceledException e)
        {
            Console.WriteLine(e.Message);
        }
    }
    static void Method1()
    {
        Console.WriteLine("Method1");  
    }
    static void Method2()
    {
        Console.WriteLine("Method2");        
    }
    static void Method3()
    {
        Console.WriteLine("Method3");
    }
}

W momencie anulowania tasków za pomocą metody Cancel, zostanie wyrzucony wyjątek OperationCanceledException. Wszystkie metody, które zostały już wywołane zakończą swoje działanie normalnie. Anulowanie dotyczy wyłącznie task’ow, które jeszcze nie zostały odpalone przez scheduler.

Parallel.For jest również analogiczny  w użyciu:

internal class Program
{
    private static void Main(string[] args)
    {
        Parallel.For(0, 100, Method);
    }
    private static void Method(int i)
    {
        Console.WriteLine(i);
    }
}

Powyższy kod wykona pętle od 0 do 100 wykonując za każdym razem metodę Method. Jako parametr wejściowy Method, przekazywany jest indeks aktualnej iteracji. Podobnie jak Invoke, For używa ponownie stworzone już wątki. Prawdopodobnie utworzenie stu wątków jest dość obciążające dlatego tworzone jest mniej a następnie są one ponownie używane. Innymi słowy, zbiór jest dzielony na kilka podzbiorów metod.

Istnieje kilka przeładowań For. Można m.in. przekazać wspomniany wyżej token czy state do metody. For zachowuje się analogicznie do Invoke oraz ForEach – blokuje wykonanie aż do zakończenia wszystkich iteracji. Klasyczna pętla umożliwia przerwanie “break”. W współbieżnym odpowiedniku jest również to możliwe za pomocą wspomnianego state:

private static void Main(string[] args)
{        
   Parallel.For(0, 100, Method);
}
private static void Method(int i,ParallelLoopState state)
{
   Console.WriteLine(i);
   state.Break();
}

LoopState zawiera także metodę Stop:

private static void Main(string[] args)
{        
   Parallel.For(0, 100, Method);
}
private static void Method(int i,ParallelLoopState state)
{
   Console.WriteLine(i);
   state.Stop();
}

Jaka jest różnica? Wiemy, że w pętli współbieżnej iteracja numer 5 może zostać wykonana przed iteracją  2. Break wykonany na iteracji 2 spowoduje anulowanie wszystkich metod po 2 (jeśli oczywiście nie zostały już one wykonane). Stop z kolei na iteracji 2 spowoduje anulowanie wszystkich metod nawet iteracji numer 0 i 1 jeśli jeszcze nie zostały wykonane. Break z kolei zachowuje się jak w klasycznej pętli – anuluje iteracje o wyższym indeksie.

Powyższe przykłady pokazywały bardzo proste scenariusze. Często jednak indeks jest potrzebny aby wykonać jakieś operację. Załóżmy, że chcemy zaimplementować w sposób równoległy poniższy kod:

int sum = 0;
for(int i=0;i<1000;i++)
{
  sum += i;
}
Console.WriteLine(sum);

Pierwsza próba może wyglądać następująco:

int sum = 0;
Parallel.For(0, 1000, i => sum += i);
Console.WriteLine(sum);

Niestety powyższy kod jest nie jest thread-safe i za każdym razem będzie otrzymywać różne wyniki. Naturalnie próbujemy wykorzystać blokadę:

 static void Main(string[] args)
{
    object syncLock = new object();
    int sum = 0;
    Parallel.For(0, 1000, i =>
                            {
                                lock (syncLock)
                                {
                                    sum += i;

                                }
                            }
        );
    Console.WriteLine(sum);
}

Rozwiązanie matematycznie poprawnie, jednak zdecydowanie za wolne. Zakładanie blokady w każdej iteracji spowoduje, że szybciej byśmy to wykonalni w tradycyjnej, sekwencyjnej pętli.

Za pomocą Parallel możemy jednak to zrobić dużo lepiej. Zacznijmy od razu od kodu:

internal class Program
{
   private static int _sum;
   private static object _sync=new object();

   private static void Main(string[] args)
   {                        
       Parallel.For(0, 1000, InitTask, DoWork, FinishTask);
       Console.WriteLine(_sum);
   }
   private static int InitTask()
   {
       return 0;
   }
   private static int DoWork(int i, ParallelLoopState state, int localsum)
   {
       return localsum + i;
   }
   private static void FinishTask(int i)
   {
       lock (_sync)
       {
           _sum += i;
       }
   }
}

Przekazujemy teraz trzy metody: InitTask, DoWork oraz FinishTask. InitTask będzie wywoływany dla każdego wątku na początku (w momencie inicjalizacji) – zwraca on początkowy stan wątku. Następnie DoWork będzie wykonywany sekwencyjnie dla wszystkich iteracji przydzielonych do danego wątku. Jeśli mamy 1000 iteracji a 200 wątków wtedy DoWork zostanie wykonany 5 razy w każdym wątku. FinishTask z kolei zostanie wywołany na końcu,gdy wszystkie iteracje przydzielone do wątku zostały już wykonane. Każdy wątek ma swój stan nazwany tutaj localSum. Wiemy, że stan ma każdy wątek więc operacje na nim są thread-safe. Niebezpiecznie jest  wykonanie tylko FinishTask ponieważ operujemy tam na globalnej zmiennej _sum– tutaj musimy zablokować dostęp. Jeśli zatem mamy 1000 iteracji, 200 wątków wtedy będziemy musieli zablokować dostęp 200 razy a nie 1000.

Metoda Foreach nie powinna już budzić żadnych wątpliwości:

internal class Program
{
    private static void Main(string[] args)
    {
        List<int> numbers=new List<int>();
        numbers.Add(1);
        numbers.Add(2);
        numbers.Add(3);
        numbers.Add(4);

        Parallel.ForEach(numbers,Method);
    }
    private static void Method(int i)
    {
        Console.WriteLine(i);
    }
}

Mam nadzieję, że tym wpisem zachęciłem Was do korzystania albo przemyślenia przynajmniej czy kod nad którym pracujemy nie da się w łatwy sposób zrównoleglić.

5 thoughts on “Klasa Parallel”

  1. Witam.

    Niedawno trafiłem na bloga. Zacząłem przeglądać Twoje wpisy i coś mnie zastanowiło. Interesuje mnie, dlaczego stosujesz konstrukcje typu:

    bool SomeBool = …;
    if (SomeBool == true) { }

    Czy tego typu zapisy mają jakiś konkretny cel (czytelność, optymalizacje, przyjęte standardy kodowania), czy może wynikają z osobistych preferencji lub nawyków?

  2. Witam
    Moglbys wkleić calosc?
    Moze akurat chodzilo o to ze warunek jest dosc skomplikowany i lepiej dodac zmienna opisujaca go. Sam IF zwykle nie wiele mowi.

  3. Ot, choćby w artykule http://msdn.microsoft.com/pl-pl/library/dobre-i-zle-praktyki-w-c-sharp–czesc-1.aspx :

    int number;
    if(int.TryParse(text, out number) == false)
    number = -1;

    Interesuje mnie sens zapisywania jasnych porównań typu:

    if (SomeBoolValue == true)
    lub
    if (SomeBoolValue == false)

    zamiast zwykłego

    if (SomeBoolValue)
    bądź
    if (!SomeBoolValue)

    Zauważyłem taką tendencję w wielu przykładowych kodach, różnych autorów i zastanawiam się, czy coś mi umyka, czy po prostu tworzący tekst mają przyjęty taki styl pisania.

  4. Przepraszam, że post pod postem, edytować nie mogę. Nie chodzi o wprowadzanie zmiennej opisującej skomplikowane wyrażenie logiczne, ale o jawne porównanie wartości tejże zmiennej z prawdą lub fałszem zamiast ograniczenia się do wstawienia tej zmiennej (po ewentualnym zanegowaniu) jako warunku.

  5. Szczerze mówiąc podczas codziennej pracy staram się pisać tak:
    if(SomeBoolValue) zamiast if(SomeBoolValue==true).

    Powyzsze przyklady zostały napisane w taki sposob, bez zadnej przyczyny:)

Leave a Reply

Your email address will not be published.