Podczas gdy pracowałem nad samouczkiem wideo do pobrania w trybie online, dotyczącym tworzenia grafiki 3D i silnika gier, pracowałem z nowoczesnym OpenGL. Użyliśmy volatile
w ramach jednej z naszych zajęć. Witrynę z samouczkami można znaleźć tutaj, a film wideo działający ze volatile
słowem kluczowym znajduje się w Shader Engine
serii wideo 98. Te prace nie są moje własne, ale są akredytowane Marek A. Krzeminski, MASc
i jest to fragment ze strony pobierania wideo.
A jeśli jesteś zapisany do swojej stronie internetowej i mieć dostęp do jego filmu w tym filmie on odwołuje się ten artykuł dotyczący wykorzystania Volatile
z multithreading
programowaniem.
niestabilny: najlepszy przyjaciel programisty wielowątkowego
Andrei Alexandrescu, 01 lutego 2001
Słowo kluczowe volatile zostało opracowane, aby zapobiec optymalizacjom kompilatora, które mogą powodować nieprawidłowe wyświetlanie kodu w obecności pewnych zdarzeń asynchronicznych.
Nie chcę zepsuć Ci nastroju, ale ta kolumna porusza przerażający temat programowania wielowątkowego. Jeśli - jak mówi poprzednia część Generic - programowanie bezpieczne w wyjątkowych sytuacjach jest trudne, to dziecinnie proste w porównaniu z programowaniem wielowątkowym.
Programy używające wielu wątków są bardzo trudne do napisania, udowodnienia poprawności, debugowania, utrzymania i ogólnie oswajania. Nieprawidłowe programy wielowątkowe mogą działać przez lata bez zakłóceń, tylko po to, aby nieoczekiwanie uruchomić amok, ponieważ został spełniony krytyczny warunek czasowy.
Nie trzeba dodawać, że programista piszący kod wielowątkowy potrzebuje wszelkiej możliwej pomocy. Ta kolumna koncentruje się na warunkach wyścigu - częstym źródle problemów w programach wielowątkowych - i dostarcza wglądu i narzędzi, jak ich uniknąć oraz, co zadziwiające, aby kompilator ciężko pracował, aby ci w tym pomóc.
Tylko małe słowo kluczowe
Chociaż standardy C i C ++ są wyraźnie ciche, jeśli chodzi o wątki, robią niewielkie ustępstwa na temat wielowątkowości w postaci słowa kluczowego volatile.
Podobnie jak jego lepiej znany odpowiednik const, zmienny jest modyfikatorem typu. Jest przeznaczony do użytku w połączeniu ze zmiennymi, które są dostępne i modyfikowane w różnych wątkach. Zasadniczo bez ulotności pisanie programów wielowątkowych staje się niemożliwe lub kompilator marnuje ogromne możliwości optymalizacji. Wyjaśnienie jest w porządku.
Rozważ następujący kod:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Celem Gadget :: Wait powyżej jest sprawdzanie zmiennej składowej flag_ co sekundę i zwracanie jej, gdy zmienna ta zostanie ustawiona na wartość true przez inny wątek. Przynajmniej tak zamierzał jego programista, ale niestety Wait jest niepoprawne.
Załóżmy, że kompilator odkryje, że Sleep (1000) jest wywołaniem zewnętrznej biblioteki, która nie może zmodyfikować zmiennej składowej flag_. Następnie kompilator stwierdza, że może buforować flag_ w rejestrze i używać tego rejestru zamiast uzyskiwać dostęp do wolniejszej pamięci wbudowanej. Jest to doskonała optymalizacja dla kodu jednowątkowego, ale w tym przypadku szkodzi poprawności: po wywołaniu Wait for some Gadget object, mimo że inny wątek wywołuje Wakeup, Wait zapętli się na zawsze. Dzieje się tak, ponieważ zmiana flag_ nie zostanie odzwierciedlona w rejestrze przechowującym flag_. Optymalizacja jest zbyt ... optymistyczna.
Buforowanie zmiennych w rejestrach jest bardzo cenną optymalizacją, która ma zastosowanie przez większość czasu, więc szkoda byłoby ją marnować. C i C ++ dają Ci szansę na jawne wyłączenie takiego buforowania. Jeśli użyjesz modyfikatora volatile na zmiennej, kompilator nie będzie buforował tej zmiennej w rejestrach - każdy dostęp dotrze do rzeczywistej lokalizacji pamięci tej zmiennej. Wszystko, co musisz zrobić, aby kombinacja oczekiwania / wybudzania gadżetu działała, to odpowiednio zakwalifikować flag_:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
Większość wyjaśnień uzasadnienia i użycia stopu nietrwałego w tym miejscu i radzi, aby kwalifikować zmienne typy pierwotne, których używasz w wielu wątkach. Jednak z volatile można zrobić znacznie więcej, ponieważ jest to część wspaniałego systemu typów w C ++.
Używanie ulotnych z typami zdefiniowanymi przez użytkownika
Można kwalifikować nie tylko typy pierwotne, ale także typy zdefiniowane przez użytkownika. W takim przypadku volatile modyfikuje typ w sposób podobny do const. (Możesz również jednocześnie zastosować const i volatile do tego samego typu).
W przeciwieństwie do const, volatile rozróżnia typy pierwotne i typy zdefiniowane przez użytkownika. Mianowicie, w przeciwieństwie do klas, typy pierwotne nadal obsługują wszystkie swoje operacje (dodawanie, mnożenie, przypisanie itp.), Gdy są kwalifikowane zmiennie. Na przykład można przypisać nieulotną wartość int do nieulotnej wartości int, ale nie można przypisać nieulotnego obiektu do nieulotnego obiektu.
Zilustrujmy, jak nietrwałość działa na typach zdefiniowanych przez użytkownika na przykładzie.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Jeśli uważasz, że lotność nie jest zbyt użyteczna w przypadku obiektów, przygotuj się na niespodziankę.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
Konwersja z typu niekwalifikowanego do jego lotnego odpowiednika jest trywialna. Jednak, podobnie jak w przypadku const, nie można cofnąć podróży od niestabilnej do niekwalifikowanej. Musisz użyć obsady:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Klasa z kwalifikacją lotną zapewnia dostęp tylko do podzbioru swojego interfejsu, podzbioru, który jest pod kontrolą implementatora klasy. Użytkownicy mogą uzyskać pełny dostęp do interfejsu tego typu tylko przy użyciu const_cast. Ponadto, podobnie jak constness, zmienność przenosi się z klasy do jej elementów członkowskich (na przykład zmienne volatileGadget.name_ i volatileGadget.state_ są zmiennymi nietrwałymi).
niestabilne, krytyczne sekcje i warunki wyścigu
Najprostszym i najczęściej używanym urządzeniem synchronizującym w programach wielowątkowych jest mutex. Muteks ujawnia prymitywy Acquire i Release. Po wywołaniu Acquire w jakimś wątku, każdy inny wątek wywołujący Acquire zostanie zablokowany. Później, gdy ten wątek wywoła Release, zostanie zwolniony dokładnie jeden wątek zablokowany w wywołaniu Acquire. Innymi słowy, dla danego muteksu tylko jeden wątek może uzyskać czas procesora między wywołaniem Acquire a wywołaniem Release. Wykonywany kod między wywołaniem Acquire a wywołaniem Release nazywany jest sekcją krytyczną. (Terminologia systemu Windows jest nieco zagmatwana, ponieważ sam muteks nazywa się sekcją krytyczną, podczas gdy „mutex” jest w rzeczywistości muteksem między procesami. Byłoby miło, gdyby nazywano je muteksem wątku i muteksem procesu).
Muteksy służą do ochrony danych przed warunkami wyścigu. Z definicji sytuacja wyścigu występuje, gdy wpływ większej liczby wątków na dane zależy od sposobu planowania wątków. Warunki wyścigu pojawiają się, gdy co najmniej dwa wątki rywalizują o wykorzystanie tych samych danych. Ponieważ wątki mogą się wzajemnie przerywać w dowolnym momencie, dane mogą zostać uszkodzone lub źle zinterpretowane. W związku z tym zmiany, a czasami dostęp do danych, muszą być starannie chronione za pomocą krytycznych sekcji. W programowaniu obiektowym zwykle oznacza to, że przechowujesz muteks w klasie jako zmienną składową i używasz go za każdym razem, gdy uzyskujesz dostęp do stanu tej klasy.
Doświadczeni wielowątkowi programiści mogli ziewnąć, czytając dwa powyższe akapity, ale ich celem jest zapewnienie intelektualnego treningu, ponieważ teraz połączymy się z niestabilnym połączeniem. Robimy to, rysując paralelę między światem typów C ++ a światem semantyki wątków.
- Poza sekcją krytyczną każdy wątek może przerwać dowolny inny w dowolnym momencie; nie ma kontroli, więc w konsekwencji zmienne dostępne z wielu wątków są niestabilne. Jest to zgodne z pierwotną intencją volatile - zapobieganiem nieświadomemu buforowaniu przez kompilator wartości używanych przez wiele wątków jednocześnie.
- Wewnątrz krytycznej sekcji zdefiniowanej przez mutex, tylko jeden wątek ma dostęp. W związku z tym wewnątrz sekcji krytycznej wykonywany kod ma semantykę jednowątkową. Zmienna kontrolowana nie jest już ulotna - możesz usunąć kwalifikator volatile.
Krótko mówiąc, dane współdzielone między wątkami są koncepcyjnie nietrwałe poza sekcją krytyczną i nieulotne w sekcji krytycznej.
Wchodzisz do krytycznej sekcji, blokując muteks. Kwalifikator volatile z typu jest usuwany przez zastosowanie const_cast. Jeśli uda nam się połączyć te dwie operacje, tworzymy połączenie między systemem typów C ++ a semantyką wątków aplikacji. Możemy sprawić, że kompilator sprawdzi za nas warunki wyścigu.
LockingPtr
Potrzebujemy narzędzia, które zbiera pozyskanie muteksów i const_cast. Opracujmy szablon klasy LockingPtr, który jest inicjowany za pomocą obiektu volatile obj i obiektu mutex mtx. Podczas swojego życia LockingPtr utrzymuje pozyskanie MTX. Ponadto LockingPtr oferuje dostęp do volatile-stripped obj. Dostęp jest oferowany w formie inteligentnego wskaźnika, poprzez operator-> i operator *. Const_cast jest wykonywany wewnątrz LockingPtr. Rzutowanie jest poprawne semantycznie, ponieważ LockingPtr przechowuje muteks nabyty przez cały okres jego istnienia.
Najpierw zdefiniujmy szkielet klasy Mutex, z którą będzie działać LockingPtr:
class Mutex {
public:
void Acquire();
void Release();
...
};
Aby użyć LockingPtr, należy zaimplementować Mutex przy użyciu natywnych struktur danych systemu operacyjnego i podstawowych funkcji.
LockingPtr jest szablonem z typem kontrolowanej zmiennej. Na przykład, jeśli chcesz sterować Widżetem, używasz LockingPtr, który inicjujesz za pomocą zmiennej typu volatile Widget.
Definicja LockingPtr jest bardzo prosta. LockingPtr implementuje nieskomplikowany inteligentny wskaźnik. Skupia się wyłącznie na zbieraniu const_cast i sekcji krytycznej.
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
Pomimo swojej prostoty LockingPtr jest bardzo przydatną pomocą w pisaniu poprawnego kodu wielowątkowego. Powinieneś zdefiniować obiekty, które są współdzielone między wątkami jako nietrwałe i nigdy nie używaj z nimi const_cast - zawsze używaj automatycznych obiektów LockingPtr. Zilustrujmy to przykładem.
Załóżmy, że masz dwa wątki, które współużytkują obiekt wektorowy:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
Wewnątrz funkcji wątku po prostu używasz LockingPtr, aby uzyskać kontrolowany dostęp do zmiennej składowej buffer_:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
Kod jest bardzo łatwy do napisania i zrozumienia - ilekroć potrzebujesz użyć buffer_, musisz utworzyć LockingPtr wskazujący na to. Gdy to zrobisz, uzyskasz dostęp do całego interfejsu wektora.
Fajne jest to, że jeśli popełnisz błąd, kompilator wskaże to:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
Nie możesz uzyskać dostępu do żadnej funkcji buffer_, dopóki nie zastosujesz const_cast lub nie użyjesz LockingPtr. Różnica polega na tym, że LockingPtr oferuje uporządkowany sposób stosowania const_cast do zmiennych nietrwałych.
LockingPtr jest niezwykle ekspresyjny. Jeśli potrzebujesz tylko wywołać jedną funkcję, możesz utworzyć nienazwany tymczasowy obiekt LockingPtr i używać go bezpośrednio:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
Powrót do typów pierwotnych
Widzieliśmy, jak ładnie niestabilny chroni obiekty przed niekontrolowanym dostępem i jak LockingPtr zapewnia prosty i skuteczny sposób pisania kodu bezpiecznego dla wątków. Wróćmy teraz do typów pierwotnych, które są traktowane inaczej przez zmienne.
Rozważmy przykład, w którym wiele wątków współdzieli zmienną typu int.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Jeśli Increment i Decrement mają być wywoływane z różnych wątków, powyższy fragment jest błędny. Po pierwsze, ctr_ musi być niestabilne. Po drugie, nawet pozornie atomowa operacja, taka jak ++ ctr_, jest w rzeczywistości operacją trzystopniową. Sama pamięć nie ma zdolności arytmetycznych. Przy zwiększaniu zmiennej procesor:
- Odczytuje tę zmienną w rejestrze
- Zwiększa wartość w rejestrze
- Zapisuje wynik z powrotem w pamięci
Ta trzyetapowa operacja nosi nazwę RMW (odczyt-modyfikacja-zapis). Podczas części Modify operacji RMW większość procesorów zwalnia magistralę pamięci, aby umożliwić innym procesorom dostęp do pamięci.
Jeśli w tym czasie inny procesor wykonuje operację RMW na tej samej zmiennej, mamy sytuację wyścigu: drugi zapis nadpisuje efekt pierwszego.
Aby tego uniknąć, możesz ponownie polegać na LockingPtr:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Teraz kod jest poprawny, ale jego jakość jest gorsza w porównaniu z kodem SyncBuf. Czemu? Ponieważ z Counter, kompilator nie ostrzeże Cię, jeśli przez pomyłkę uzyskasz bezpośredni dostęp do ctr_ (bez blokowania go). Kompilator kompiluje ++ ctr_, jeśli ctr_ jest ulotny, chociaż wygenerowany kod jest po prostu nieprawidłowy. Kompilator nie jest już twoim sprzymierzeńcem i tylko twoja uwaga może pomóc ci uniknąć warunków wyścigu.
Co wtedy powinieneś zrobić? Po prostu hermetyzuj pierwotne dane, których używasz w strukturach wyższego poziomu i używaj ulotnych z tymi strukturami. Paradoksalnie, gorzej jest używać volatile bezpośrednio z wbudowanymi funkcjami, mimo że początkowo taki był cel użycia volatile!
niestabilne funkcje składowe
Do tej pory mieliśmy klasy, które agregują zmienne składowe danych; pomyślmy teraz o projektowaniu klas, które z kolei będą częścią większych obiektów i będą współdzielone między wątkami. Tutaj bardzo pomocne mogą być zmienne funkcje składowe.
Projektując klasę, kwalifikujesz nietrwałe tylko te funkcje składowe, które są bezpieczne wątkowo. Musisz założyć, że kod z zewnątrz będzie wywoływał funkcje ulotne z dowolnego kodu w dowolnym momencie. Nie zapomnij: nietrwałość oznacza wolny kod wielowątkowy i brak krytycznej sekcji; nieulotna oznacza scenariusz jednowątkowy lub w sekcji krytycznej.
Na przykład definiujesz klasę Widget, która implementuje operację w dwóch wariantach - bezpiecznym wątkowo i szybkim, niezabezpieczonym.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Zwróć uwagę na użycie przeciążenia. Teraz użytkownik Widget może wywołać Operację używając jednolitej składni albo dla obiektów ulotnych i uzyskać bezpieczeństwo wątków, albo dla zwykłych obiektów i uzyskać szybkość. Użytkownik musi zachować ostrożność podczas definiowania współdzielonych obiektów Widgetu jako nietrwałych.
Podczas implementowania niestabilnej funkcji składowej pierwszą operacją jest zwykle zablokowanie jej za pomocą LockingPtr. Następnie praca jest wykonywana przy użyciu nieulotnego rodzeństwa:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Podsumowanie
Pisząc programy wielowątkowe, możesz wykorzystać zmienność na swoją korzyść. Musisz przestrzegać następujących zasad:
- Zdefiniuj wszystkie obiekty udostępnione jako nietrwałe.
- Nie używaj lotnych bezpośrednio z typami pierwotnymi.
- Definiując klasy współdzielone, użyj zmiennych funkcji składowych, aby wyrazić bezpieczeństwo wątków.
Jeśli to zrobisz i jeśli użyjesz prostego komponentu ogólnego LockingPtr, możesz napisać kod bezpieczny dla wątków i znacznie mniej martwić się warunkami wyścigu, ponieważ kompilator będzie się o ciebie martwił i pilnie wskaże miejsca, w których się mylisz.
Kilka projektów, z którymi byłem zaangażowany, używa volatile i LockingPtr z doskonałym skutkiem. Kod jest czysty i zrozumiały. Pamiętam kilka zakleszczeń, ale wolę zakleszczenia od warunków wyścigu, ponieważ są one o wiele łatwiejsze do debugowania. Praktycznie nie było problemów związanych z warunkami wyścigu. Ale wtedy nigdy nie wiadomo.
Podziękowanie
Podziękowania dla Jamesa Kanze i Sorina Jianu, którzy pomogli we wnikliwych pomysłach.
Andrei Alexandrescu jest kierownikiem ds. Rozwoju w RealNetworks Inc. (www.realnetworks.com) z siedzibą w Seattle w stanie Waszyngton i autorem uznanej książki Modern C ++ Design. Można się z nim skontaktować pod adresem www.moderncppdesign.com. Andrei jest również jednym z polecanych instruktorów The C ++ Seminar (www.gotw.ca/cpp_seminar).
Ten artykuł może być trochę przestarzały, ale daje dobry wgląd w doskonałe wykorzystanie zmiennego modyfikatora w programowaniu wielowątkowym, aby pomóc zachować asynchroniczność wydarzeń, podczas gdy kompilator sprawdza dla nas warunki wyścigu. Może to nie odpowiadać bezpośrednio na pierwotne pytanie OP dotyczące tworzenia ogrodzenia pamięci, ale zdecydowałem się opublikować to jako odpowiedź dla innych jako doskonałe odniesienie do dobrego wykorzystania ulotności podczas pracy z aplikacjami wielowątkowymi.