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.