W ostatnich kilku postach przedstawiłem “egzotyczne” słowa kluczowe w c#. Wiele z nich, myślę, nie było znanych nawet bardziej zaawansowanym programistom. Z pewnością nie są one niezbędne do pisania aplikacji. Często nawet nie jest wskazane aby z nich korzystać, chyba, że naprawdę dokładnie przeanalizowaliśmy sytuację. Słowo zaprezentowane w dzisiejszym poście również zalicza się do tego zbioru. Myślę, że volatile może być znane programistom C++ ale w świecie c# jest dużo mniej popularne. Jeśli piszecie aplikacje czysto wielozadaniowe wtedy volatile prawdopodobnie będzie należał do zbioru najważniejszych słów kluczowych. Dla programistów aplikacji biznesowych, które nie korzystają z zawansowanego modelu współbieżnego, volatile wpiszę się zdecydowanie tylko w rzadkie scenariusze.
Niestety, w oficjalnej dokumentacji MSDN brakuje dobrego opisu i przykładów. Zacznijmy od definicji. Volatile w c# mówi kompilatorowi aby nie dokonywał optymalizacji na danym polu. Przykład:
class SampleClass { private volatile bool _flag; }
Od tej pory _flag nie będzie zawsze optymalizowany przez kompilator. Zmiany można zaobserwować w trybie Release. W Debug zwykle nie ma bardziej skomplikowanych optymalizacji. Takie informacje pewnie nie są zbyt praktyczne i wypadałoby określić jakiego typu są to optymalizacje? I dlaczego czasami warto z nich zrezygnować?
Przede wszystkim użycie volatile ma znaczenie w modelu współbieżnym aplikacji. Kompilator dokonuje serii optymalizacji które na końcu mogą spowodować, że kod się nie zachowuje tak jak logicznie to wynika z kodu. Szczególnie ma to znaczenia dla komputerów z kilkoma rdzeniami lub procesorami.
Problemy z optymalizacją wynikają ze skomplikowanej architektury procesorów i tzw. modelu pamięci (memory model). Dla programistów, którzy pamiętają jeszcze Assembler będzie to dużo łatwiej zrozumieć. Aby nie komplikować, procesor operuje na danych z tzw. rejestrów a nie bezpośrednio na pamięci operacyjnej czy tym bardziej dysku twardym. Na przykład aby wykonać operację matematyczną najpierw procesor pobiera z pamięci liczbę i umieszcza ją w stosownym rejestrze. Wszelkie operacje dokonywane są właśnie na tym rejestrze (jest wiele typów rejestrów ale o tym odsyłam do zewnętrznych zasobów). Po wykonaniu stosownej logiki, z powrotem wartość z rejestru jest przenoszona do pamięci. W świecie c# widzimy tylko zmienne ale w rzeczywistości nawet linia typu i++ powoduje przeniesie wartości z pamięci do rejestru, wykonanie operacji i z powrotem wynik ląduje w pamięci. Z tego względu zwykła inkrementacja nie jest operacją atomową i należy do tego użyć klasy Interlocked.
Rozważmy przykładową optymalizację kodu dokonaną przez kompilator:
while( _flag ) { // do something }
Jeśli w środku pętli nie modyfikujemy flagi _flag, optymalizacja dokonana przez kompilator mogłaby wyglądać tak:
if(_flag) { while(true) { // do something } }
Innym problemem są optymalizacje dokonywane przez sam CPU. Procesor cachuje wartość w rejestrze zamiast za każdym razem kopiować ją z pamięci operacyjnej. Generalnie dostęp do danych z dysku twardego jest bardzo kosztowny, dostęp do pamięci operacyjnej “średnio” a z kolei wszelkie operacje na rejestrach są bardzo wydajne. Naturalne wydaje się wiec unikanie operacji na RAM a zaplanowanie tak przepływu logiki aby uzyskać jak najmniej operacji kopiowania między RAM a rejestrami. Z tego względu procesor ma do dyspozycji cache i kolejne operacje odczytu wykonywane są na cachu a nie na pamięci operacyjnej. Mając w kodzie pętle while odpytującą flagę, kopiowana wartość jest tylko raz, następne wywołania dotyczą cachu rejestru. Z tego względu gdy inny wątek zmodyfikuje zmienną to wartość może nie być widziana przez inny procesor. Jest to trudne do zaobserwowania ponieważ zależy to od wspomnianego modelu pamięci i architektury procesora. Zasadniczo jednak istnieje ryzyko, że wartość zmodyfikowana przez inny wątek nie będzie widziana przez wszystkie pozostałe wątki gdy korzystają one z wartości buforowanej. Należy zaznaczyć, że wszelka operacja zapisu powoduje aktualizacje całego cache dostępnego dl wątku\rdzenia\cpu. Jest to przeciwne zachowanie do Javy, jednak w .NET operacja zapisu jest bezpieczna. Problem pojawia się wyłącznie w odczycie danych.
Rozważmy, żywy przykład. Od razu ostrzegam, że podany kod może nie zadziałać u wszystkich (zależne od procesora itp.). Należy program skompilować w trybie Release (aby zostały dokonane optymalizacje) i uruchomić koniecznie NIE z poziomu Visual Studio! Można np. przejść do katalogu Bin\Release. Podczas swoich testów również zaznaczyłem opcję x64. Kod (przykład poniższego programu jak i optymalizacji powyższego while pochodzi z stąd – polecam):
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 } } }
Jaki z punktu logicznego myślenia powinien być wynik? Pętla while powinna zakończyć działanie po ustawieniu flagi na false przez inny wątek. Takie zachowanie na pewno można zaobserwować w trybie debug. W trybie release jednak odczyt test.flag będzie zbuforowany i zapis dokonany przez wątek nie będzie widoczny dla pętli while. Aby ominąć tę optymalizację i zawsze odczytywać z pamięci należy użyć słowa volatile:
volatile 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 } }
Kod jest całkowicie bezpieczny – zawsze się zakończy. Jednak wprowadzenie volatile powoduje stratę jeśli chodzi o wydajność – za każdym razem w końcu dokonujemy flush. Jeśli piszemy aplikacje współbieżne należy mieć to na uwadze bo tam wydajność zwykle jest bardzo ważna.
Wiemy, że każdy write powoduje flush. Aby również read powodował flush (odświeżenie cachu) należy pole oznaczyć słowem volatile. Wszelki kod w Lock również jest bezpieczny i nie powoduje opisanych wcześniej problemów.
Należy również podkreślić, że volatile to nie operator do synchronizacji. Wszelkie operacje, które nie są atomowe nadal są niebezpieczne na polu volatile. Do synchronizacji mamy do dyspozycji lock, semafory i masę innych narzędzi dostarczonych przez .NET.
jakbys sie ustosunkowal do volatile i const? czy mozesz podac przyklady kiedy uzywac oba?
Odnośnie fragmentu “odczyt test.flag będzie zbuforowany i zapis dokonany przez wątek nie będzie widoczny dla pętli while” – czy aby napewno tak się zadzieje, bo jeśli faktycznie tak jest to praktycznie każdą zmienną wykorzystywaną w środowisku wielowątkowym musielibyśmy określać jako volatile. Mam wrażenie że w tym przypadku chodzi o to, że problem z kodem: if(_flag) while(true) { …, gdzie dopiero po komendzie if może dojść do ustawienia flagi na false i program w takim przypadku wpadnie w nieskończoną pętle.
@Grek:
Ale pamietaj, ze jesli jest to zmienna wspolna dla kilku watkow wtedy byc moze chcemy ja umiescic np. w lock, ktory zawsze robi flush.
Czyli gdybyśmy obudowali ją w lock, to volatile nie byłoby potrzebne w tym przypadku?
Wszystko w lock jest bezpieczne i nie trzeba tam volatile. Nalezy pamietac, ze volatile jest zlym rozwiazaniem i lepiej korzystac z barier itp.