Dobre i złe praktyki w C# - część II  

Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2012-07-24

Wprowadzenie

Rozwijająca się architektura procesorów umożliwia zrównoleglenie coraz większej liczby operacji. Programowanie współbieżne jeszcze 10 lat temu było zarezerwowane dla zaawansowanych operacji. Dzisiaj, nawet najprostsza aplikacja może z łatwością wykorzystać kilka rdzeni. W Windows 8, wraz z pojawieniem się WinRT, nastąpi kolejny poważny krok w kwestii uproszczenia API. Cykl artykułów pokaże kilka błędów, często popełnianych, oraz przedstawi klasy, które z jakiś względów nie są tak bardzo popularne.

Nieprawidłowe założenie blokady

Słowo kluczowe lock to najpopularniejszy sposób synchronizacji. W celu założenia blokady wystarczy tylko przekazać obiekt, do którego należy zablokować dostęp. Antywzorcem jest przekazanie obiektu this, ponieważ może spowodować deadlock. Na przykład:

class ThreadSafeClass
{
    public void Perform()
    {
        lock(this)
        {
            Console.WriteLine("Hello world");
        }
    }
}

Jeżeli użytkownik użyje powyższego kodu w następujący sposób:

var threadSafeClass=new ThreadSafeClass();
lock (threadSafeClass)
{
     var task = Task.Factory.StartNew(threadSafeClass.Perform);
     task.Wait();
}

Wówczas zostanie założona blokada na threadSafeClass. Następnie, metoda Perform ponownie spróbuje zablokować dostęp do obieku this, który wskazuje dokładnie na to samo, co threadSafeClass. Efektem tego będzie zakleszczenie (deadlock). Bezpieczniejszym rozwiązaniem jest użycie prywatnej instancji niezależnego obiektu:

class ThreadSafeClass
{
    private object _sync=new object();
    public void Perform()
    {
        lock(_sync)
        {
            Console.WriteLine("Hello world");
        }
    }
}

Najlepszym i najbezpieczniejszym obiektem do zablokowania jest pokazany wyżej, specjalnie utworzony obiekt na potrzeby synchronizacji. Użycie this jest najpopularniejszym antywzorcem. Oprócz this, nie powinno się używać:

  • blokowania typu - tzn. lock (typeof(SampleClass)) – sytuacja podobna do this,
  • blokowania klasy string - tzn. lock („jakiś napis”).

Jedynym bezpiecznym sposobem jest użycie prywatnej instancji obiektu, np.:

private object _sync = new object();

W zależności od wymagań można również użyć statycznej, współdzielonej przez wszystkie instancje blokady:

private static object _sync = new object();

Pula wątków

Klasa Thread wciąż jest najpopularniejszym sposobem tworzenia wątków. Niestety, to rozwiązanie jest dość wolne i pochłaniające dużą ilość zasobów. W środowisku .NET istnieje tzw. pula wątków. Dzięki temu mechanizmowi, wątek nie jest za każdym razem tworzony od nowa, a jedynie zdejmowany z puli. Pula jest zarządzana w pełni przez .NET Framework. Kończąc jakieś zadanie, wątek nie jest niszczony, a jedynie oddawany z powrotem do puli, umożliwiając tym samym szybkie rozpoczęcie następnego wątku, bez potrzeby wolnej alokacji zasobów. Wewnętrzny mechanizm określa, ile wątków powinno być w puli - uzależnione jest to m.in. od aktualnego obciążenia procesora. Wszystkie wątki, tworzone w ten sposób, są typu „Background”, co oznacza, że przy zamknięciu aplikacji wszystkie zostaną automatycznie usunięte.

Wątki z puli można uruchomić na wiele sposobów. Klasycznym rozwiązaniem jest po prostu użycie klasy ThreadPool, np.:

private static void Main(string[] args)
{
    ThreadPool.QueueUserWorkItem(Run);
}
private static void Run(object args)
{
    while(true)
    {
        Console.WriteLine("...");
    }
}

Wykorzystanie puli wiążę się z pewnymi ograniczeniami. Po pierwsze, gdy nie ma wolnego wątku w puli, wtedy zadanie będzie doczepione do kolejki i będzie musiało poczekać na swoją kolej. W przypadku bardzo dużej ilości wątków, pula może okazać się wolniejsza niż manualne tworzenie wątku. Związane jest to z kolejkowaniem, realokacją i innymi wewnętrznymi operacjami. Ta magiczna liczba wątków, określająca limit, zależy od procesora. Na jednym komputerze może być to 50, a na jakimś serwerze, wyposażonym w zaawansowane CPU, 500. W praktyce, pula jest zawsze szybsza – manualna konstrukcja i destrukcja wątku jest bardzo kosztowna.

Klasa ThreadPool to rekomendowany sposób zdejmowania wątków z puli, przed pojawieniem się .NET Framework 4.0. Od wersji 4.0 wprowadzono klasę Task, która domyślnie tworzy wątek typu background, wykorzystując pulę:

private static void Main(string[] args)
{
    Task.Factory.StartNew(Run, null);
    Thread.Sleep(5000);
}
private static void Run(object args)
{
    Console.WriteLine(Thread.CurrentThread.IsBackground);
    Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);
}

Jeśli z jakiś względów (np. wątek będzie uruchomiony przez długi czas) należy stworzyć wątek manualnie (tj. bez użycia puli), wtedy wystarczy przekazać dodatkowy parametr, a mianowicie TaskCreationOptions.LongRunning:

private static void Main(string[] args)
{
    Task.Factory.StartNew(Run, null,TaskCreationOptions.LongRunning);
    Thread.Sleep(5000);
}
private static void Run(object args)
{
    Console.WriteLine(Thread.CurrentThread.IsBackground);
    Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);
}

Kolejnym sposobem na uruchomienie wątku z puli jest użycie delegaty oraz BeginInvoke:

private static void Main(string[] args)
{
    Action action = Run;
    action.BeginInvoke(null, null);
    Thread.Sleep(1000);
}
private static void Run()
{
    Console.WriteLine(Thread.CurrentThread.IsBackground);
    Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread);
}

Klasa BackgroundWorker, wykorzystywana często w połączeniu z interfejsem użytkownika, również stworzy wątek z puli:

private static void Main(string[] args)
{
    var backgroundWorker=new BackgroundWorker();
    backgroundWorker.DoWork += BackgroundWorkerDoWork;
    backgroundWorker.RunWorkerAsync();
    Thread.Sleep(5000);
}
    static void BackgroundWorkerDoWork(object sender, DoWorkEventArgs e)
{
    Console.WriteLine(Thread.CurrentThread.IsBackground);          
}

Jak widać z powyższych przykładów, większość sposobów na tworzenie wątków wykorzystuje pulę. Klasa Thread jest tutaj wyjątkiem, który zbyt często, moim zdaniem, pojawia się w projektach. Thread tworzy zawsze wątek od nowa, nie korzystając z mechanizmów puli. Scenariusze użycia Thread nie są zbyt częste i dlatego należy dobrze przeanalizować dany przypadek przed użyciem tej klasy. Z mojego doświadczenia wynika, że ta klasa jest po prostu nadużywana.

Aktualizacja interfejsu z wątku  

Często popełnianym błędem przez początkujących programistów jest próba aktualizacji interfejsu (np. kontrolki Label) z innego wątku. Wywoła to oczywiście wyjątek cross-thread operation. Rozwiązaniem jest umieszczenie wszelkiej logiki związanej z GUI, w wątku, który stworzył te GUI. W WPF oraz WinForms służy do tego metoda Invoke. Warto stworzyć proste rozszerzenie, umożliwiające wywołanie Invoke, wyłącznie wtedy, gdy jest to wymagane. Implementacja dla WinForms może wyglądać następująco:

public static class ControlExtensions
{
   public static void InvokeIfRequired(this Control control, Action action)
   {
       if (control.InvokeRequired)
           control.Invoke(action);
       else
           action();
   }
   public static void InvokeIfRequired<T>(this Control control, Action<T> action, T parameter)
   {
       if (control.InvokeRequired)
           control.Invoke(action, parameter);
       else
           action(parameter);
   }
}

Dysponując powyższym rozszerzeniem można z łatwością wykonać aktualizację interfejsu:

this.InvokeIfRequired((value) => progressBar.Value = value, 10);

Zaimplementowana metoda InvokeIfRequired wywoła przekazaną metodę w wątku, w którym został stworzony interfejs. Co ważne, przekazanie do dispatchera będzie miało miejsce wyłącznie wtedy, gdy jest to potrzebne, tzn. gdy kod wywoływany zostanie z innego wątku. Implementacja dla WPF wygląda bardzo podobnie:

public static class ControlExtensions
{
   public static void InvokeIfRequired(this Control control, Action action)
   {
       if (System.Threading.Thread.CurrentThread!=control.Dispatcher.Thread)
           control.Dispatcher.Invoke(action);
       else
           action();
   }
   public static void InvokeIfRequired<T>(this Control control, Action<T> action, T parameter)
   {
       if (System.Threading.Thread.CurrentThread!=control.Dispatcher.Thread)
           control.Dispatcher.Invoke(action, parameter);
       else
           action(parameter);
   }
}

Rozważ alternatywne sposoby synchronizacji wątków – projekt algorytmu

Słowo klucze lock jest łatwe w użyciu, ale bardzo niewydajne, może spowodować blokady. Jeśli tylko jest to możliwe, lepiej poradzić sobie bez locka. Jak skorzystać z takiej ewentualności?

Pierwszym sposobem jest zaprojektowanie algorytmu w ten sposób, aby z definicji, tzn. z punktu matematycznego, był odporny na wielowątkowość. Takie rozwiązanie jest najlepsze, ponieważ nie trzeba korzystać z żadnych mechanizmów synchronizacji, które z kolei ograniczają wielowątkowość. Umieszczenie blokady powoduje w końcu, że cześć algorytmu wykonywana jest w sposób sekwencyjny, co jest oczywiście efektem niekorzystnym. Doskonałym przykładem jest implementacja wzorca singleton. Podstawowa wersja mogłaby wyglądać następująco:

public sealed class Singleton
{
    private static Singleton _instance = null;
    private Singleton() { }
    public static Singleton Instance
    {
        get
        {
            if(instance == null)
                instance = new Signleton();
            return _instance;
        }
    }
}

W środowisku współbieżnym może zdarzyć się sytuacja, w której kilka instancji zostanie utworzonych zamiast tylko jednej. Jeśli kilka wątków jednocześnie przejdzie do instrukcji IF, wtedy każdy z nich stworzy swoją kopię singletona. Najbardziej prymitywnym rozwiązaniem jest użycie blokady:

public sealed class Singleton
{
    private static Singleton _instance = null;
    private static readonly object m_Sync = new object();

    private Singleton() { }

    public static Singleton Instance
    {
        get
        {
            lock(m_Sync)
            {
                if(_instance == null)
                    _instance = new Signleton();
            }
            return _instance;
        }
    }
}

Lock jest prosty, a co najważniejsze działa. Jednak, jak zostało wspomniane, nie jest to perfekcyjne. Sprytne obejście może polegać na dostrzeżeniu jednego faktu. W tej chwili, blokada zakładana jest zawsze, a niebezpieczna sytuacja pojawia się wyłącznie wtedy, gdy obiekt nie został jeszcze stworzony. Przykład:

public sealed class Singleton
{
    private static volatile Singleton _instance = null;
    private static readonly object m_Sync = new object();
    private Singleton() { }
    public static Singleton Instance
    {
        get
        {
            if(_instance == null )
            {
                lock(m_Sync)
                {
                    if(_instance == null)
                        _instance = new Signleton();
                }
            }
            return _instance;
        }
    }
}

Powyższa implementacja obniża znacząco użycie blokady. W momencie, gdy obiekt zostanie utworzony, wówczas wszystko wykonywane jest współbieżnie, bez zbędnych blokad. Poprzedni kod zakładał blokadę za każdym razem, gdy programista chciał uzyskać dostęp do obiektu. Powyższe rozwiązanie jest wydajnościowo bliskie ideału, ale może się wydawać dziwne ze względu na te dwie instrukcje warunkowe. W inżynierii oprogramowania warto trzymać kod w najprostszej postaci, ponieważ jest to wtedy łatwe w utrzymaniu. Jeśli mechanizm lazy loading (inicjalizacja tylko na żądanie) nie jest koniecznością, wtedy lepiej napisać:

public sealed class Singleton
{
    private static Singleton _instance = new Singleton();
    private Singleton() { }
    public static Singleton Instance
    {
        get
        {
            return _instance;
        }
    }
}

W ten sposób osiągnięto kod bez blokad, wydajny oraz prosty! Niestety, lazy loading jest czasami koniecznością. W takich sytuacjach warto skorzystać z klas zagnieżdżonych:

public sealed class Singleton
{
   private Singleton() { }
   public static Singleton Instance
   {
       get
       {
           return Child._instance;
       }
   }
   class Child
   {
       private Child() { }
       internal static readonly Singleton _instance = new Singleton();
   }
}

Konstrukcja jest dość nietypowa, ale w sprytny sposób całkowicie omija konieczność blokowania kodu. W klasie zagnieżdżonej istnieje statyczne pole, tworzące obiekt. Jednak, w przeciwieństwie do poprzednich przykładów, nie zostanie ono utworzone, dopóki programista nie będzie chciał uzyskać dostępu do klasy Child. Z powyższego kodu wynika, że jedynym miejscem używania Childs jest właściwość Instance. W momencie pierwszego dostępu do właściwości Instance zostanie zainicjalizowane również pole _instance klasy Child! Cała sztuczka polega na tym, że C# nie tworzy pól statycznych, dopóki użytkownik nie próbuje odczytać wartości z jakiegoś pola statycznego, zawartego w danej klasie. Ktoś może się spytać – dlaczego klasa zagnieżdżona? Powyższa kombinacja może wydawać się bezcelowa skoro pola statyczne są zawsze tworzone na zasadzie „leniwej”. Jest to tylko częściowa prawda… Co jeśli singleton zawiera inne pola statyczne? Od kodu wymaga się, aby instancja została utworzona wyłącznie w momencie wywołania właściwości Instance, a nie jakiegokolwiek statycznego elementu.

Zakończenie

Kolejna część artykułu będzie również dotyczyć programowania współbieżnego. Dobre praktyki, związane z unikaniem blokad, to dość szeroki temat. Programowanie współbieżne stanowi jedno z bardziej zaawansowanych dziedzin inżynierii oprogramowania. Wykrycie błędu w kodzie wykonywanym równolegle jest bardzo trudne, ponieważ debuggowanie nie pomoże w przypadku tzw. race condition. Z tego względu, przestrzeganie dobrych praktyk i przemyślenie każdej linijki kodu jest kluczowe. Zwłaszcza w programowaniu współbieżnym widać, że im mniej czasu programista spędzi na pisaniu i projektowaniu kodu, tym więcej czasu spędzi na pracy z debuggerem.

 


          

Piotr Zieliński

Absolwent informatyki o specjalizacji inżynieria oprogramowania Uniwersytetu Zielonogórskiego. Posiada szereg certyfikatów z technologii Microsoft (MCP, MCTS, MCPD). W 2011 roku wyróżniony nagrodą MVP w kategorii Visual C#. Aktualnie pracuje w General Electric pisząc oprogramowanie wykorzystywane w monitorowaniu transformatorów . Platformę .NET zna od wersji 1.1 – wcześniej wykorzystywał głównie MFC oraz C++ Builder. Interesuje się wieloma technologiami m.in. ASP.NET MVC, WPF, PRISM, WCF, WCF Data Services, WWF, Azure, Silverlight, WCF RIA Services, XNA, Entity Framework, nHibernate. Oprócz czystych technologii zajmuje się również wzorcami projektowymi, bezpieczeństwem aplikacji webowych i testowaniem oprogramowania od strony programisty. W wolnych chwilach prowadzi blog o .NET i tzw. patterns & practices.