Blokada lock a bariera oraz caching

W ostatnim poście pisałem o barierze jako rozwiązaniu na uniknięcie problemów z związanych z optymalizacją dokonywaną przez CPU (re-ordering). Kilka postów wcześniej pisałem z kolei o buforowaniu danych i słowie kluczowym volatile. Jeśli nie wiedzie co to jest MemoryBarrier oraz Volatile zachęcam do przeczytania tych wpisów najpierw – bez nich dzisiejszy post będzie kompletnie niezrozumiały.

Wiemy, że caching oraz re-ordering może na niektórych architekturach spowodować trudne w analizie błędy. Dziś chciałbym wyjaśnić jak do tego ma się klasyczna blokada lock.

Jeśli chodzi o volatile, sprawa jest bardzo prosta. Blokada LOCK gwarantuje nam, że zawsze otrzymamy “świeżą” wartość. Wszelki kod w blokadzie jest bezpieczny i wymusza “flush”. Dla przypomnienia kod, który bez volatile może spowodować nieoczekiwane rezultaty:

class Program
{
   bool flag=true;
   static void Main(string[] args)
   {
       Program test=new Program();
       var thread = new Thread(() => { test.flag = false; });
       thread.Start();

       while (test.flag)
       {
           // blokada
       }
   }
}

Bez volatile, powyższy program może się nigdy nie zakończyć.  LOCK zawsze powoduje flush i poniższy program  zawsze zakończy się, pomimo, że nie używamy słowa volatile:

class Program
{
    bool flag = true;
    static object _sync=new object();

    static void Main(string[] args)
    {
        Program test = new Program();
        var thread = new Thread(() => { test.flag = false; });
        thread.Start();
      
            while (true)
            {
                lock (_sync)
                {
                    if(!test.flag)
                        break;
                }
            }

        Console.WriteLine("Udalo!");
    }
}

Należy wspomnieć jednak o jednej kwestii – lock powoduje flush wyłącznie na początku lock’a (na wejściu). Z tego względu poniższy kod jest niepoprawny i również może nigdy nie zakończyć się:

class Program
{
    bool flag = true;
    static object _sync=new object();

    static void Main(string[] args)
    {
        Program test = new Program();
        var thread = new Thread(() => { test.flag = false; });
        thread.Start();

        lock (_sync)
        {
            while (true)
            {
                if (!test.flag)
                    break;
            }
        }
        Console.WriteLine("Udalo!");
    }
}

Powyższy przykład wykonuje flush tylko raz – następnie program korzysta z buforowanej wartości test.flag. Oczywiście przykłady stanowią wyłącznie dowód, że lock wykonuje flush – w praktyce blokady są zbyt kosztowne aby je używać w przedstawiony sposób.

Kolejna sprawa do omówienia to MemoryBarrier oraz lock. Podobnie jak z cache, lock zakłada blokadę na wejście i na wyjście. Załóżmy, że mamy kod:

lock(Sync)
{
  field1 = Method1();
  field2 = Method2();
}

Równoważne jest to z następującymi barierami:

Thread.MemoryBarrier();
field1 = Method1();
field2 = Method2();
Thread.MemoryBarrier();

Jaki z tego wniosek? Blokada lock to dość potężne narzędzie, wystarczające w wielu przypadkach. Większość programistów doskonale zna słowo kluczowe lock a nie ma pojęcia o barierach. Bezpieczniej z tego względu jest użyć lock – dużo większe szanse, że zostanie to prawidłowo zinterpretowane przez następnych programistów. Jeśli piszemy algorytmy typowo wielowątkowe wtedy poszczególni programiści muszą znać bardziej zaawansowane mechanizmy synchronizacji i bariera z pewnością nie jest niczym nowym. W takiej sytuacji oczywiście należy stosować rozwiązania jak najbardziej optymalne – spinning itp. W przypadku biznesowych aplikacji, nieumiejętne zastosowanie  np. spinningu może spowodować drastyczny spadek wydajności i moim zdaniem lepiej po prostu ograniczyć się do klasycznego lock’a.

Leave a Reply

Your email address will not be published.