Najpierw musisz nauczyć się myśleć jak prawnik językowy.
Specyfikacja C ++ nie zawiera odniesienia do żadnego konkretnego kompilatora, systemu operacyjnego lub procesora. Odwołuje się do abstrakcyjnej maszyny, która jest uogólnieniem rzeczywistych systemów. W świecie Language Lawyer zadaniem programisty jest pisanie kodu dla abstrakcyjnej maszyny; zadaniem kompilatora jest aktualizacja tego kodu na konkretnej maszynie. Kodując sztywno zgodnie ze specyfikacją, możesz mieć pewność, że Twój kod będzie się kompilował i działał bez modyfikacji w dowolnym systemie z kompatybilnym kompilatorem C ++, zarówno dzisiaj, jak i za 50 lat.
Maszyna abstrakcyjna w specyfikacji C ++ 98 / C ++ 03 jest zasadniczo jednowątkowa. Dlatego nie jest możliwe napisanie wielowątkowego kodu C ++, który jest „w pełni przenośny” w odniesieniu do specyfikacji. Specyfikacja nawet nie mówi nic o atomowości ładowań i magazynów pamięci ani o kolejności, w której mogą się zdarzać ładunki i sklepy, nie wspominając o takich rzeczach jak muteksy.
Oczywiście możesz pisać kod wielowątkowy w praktyce dla konkretnych konkretnych systemów - takich jak pthreads lub Windows. Ale nie ma standardowego sposobu pisania kodu wielowątkowego dla C ++ 98 / C ++ 03.
Maszyna abstrakcyjna w C ++ 11 jest wielowątkowa z założenia. Ma również dobrze zdefiniowany model pamięci ; oznacza to, co kompilator może, a czego nie może zrobić, jeśli chodzi o dostęp do pamięci.
Rozważ następujący przykład, w którym para zmiennych globalnych jest dostępna jednocześnie przez dwa wątki:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
Co może wygenerować wątek 2?
W C ++ 98 / C ++ 03 nie jest to nawet niezdefiniowane zachowanie; samo pytanie nie ma znaczenia, ponieważ standard nie uwzględnia niczego zwanego „wątkiem”.
W C ++ 11 wynikiem jest zachowanie niezdefiniowane, ponieważ ładunki i zapasy nie muszą być ogólnie atomowe. Co może nie wydawać się dużą poprawą ... I samo w sobie nie jest.
Ale w C ++ 11 możesz napisać to:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17); cout << y.load() << " ";
y.store(37); cout << x.load() << endl;
Teraz sprawy stają się znacznie bardziej interesujące. Po pierwsze, zachowanie tutaj jest zdefiniowane . Wątek 2 może teraz zostać wydrukowany 0 0
(jeśli działa przed wątkiem 1), 37 17
(jeśli działa po wątku 1) lub 0 17
(jeśli działa po tym, jak wątek 1 przypisuje x, ale przed przypisaniem do y).
Nie może drukować 37 0
, ponieważ domyślnym trybem dla ładunków / magazynów atomowych w C ++ 11 jest wymuszanie spójności sekwencyjnej . Oznacza to po prostu, że wszystkie ładunki i magazyny muszą być „tak, jakby” miały miejsce w kolejności, w jakiej zostały napisane w każdym wątku, podczas gdy operacje między wątkami mogą być przeplatane w dowolny sposób. Tak więc domyślne zachowanie atomiki zapewnia zarówno atomowość, jak i porządkowanie ładunków i zapasów.
Teraz na nowoczesnym procesorze zapewnienie sekwencyjnej spójności może być kosztowne. W szczególności kompilator najprawdopodobniej będzie emitował pełne bariery pamięciowe między każdym dostępem tutaj. Ale jeśli twój algorytm może tolerować ładunki i zamówienia poza kolejnością; tzn. jeśli wymaga atomowości, ale nie porządkowania; tzn. jeśli może tolerować 37 0
dane wyjściowe z tego programu, możesz napisać to:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl;
Im bardziej nowoczesny procesor, tym większe prawdopodobieństwo, że będzie to szybsze niż w poprzednim przykładzie.
Na koniec, jeśli chcesz zachować porządek w poszczególnych ładunkach i sklepach, możesz napisać:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl;
To zabiera nas z powrotem do zamówionych ładunków i sklepów - więc 37 0
nie jest to już możliwe wyjście - ale robi to przy minimalnym obciążeniu. (W tym trywialnym przykładzie wynik jest taki sam, jak pełna zgodność sekwencyjna; w większym programie tak nie byłoby).
Oczywiście, jeśli jedynymi wyjściami, które chcesz zobaczyć, są 0 0
lub 37 17
, możesz po prostu owinąć muteks wokół oryginalnego kodu. Ale jeśli przeczytałeś do tej pory, założę się, że wiesz już, jak to działa, a ta odpowiedź jest już dłuższa niż zamierzałem :-).
Więc dolna linia. Muteksy są świetne, a C ++ 11 je standaryzuje. Ale czasami ze względów wydajnościowych potrzebujesz prymitywów niższego poziomu (np. Klasyczny wzorzec blokowania z podwójną kontrolą ). Nowy standard zapewnia gadżety wysokiego poziomu, takie jak muteksy i zmienne warunkowe, a także zapewnia gadżety niskiego poziomu, takie jak typy atomowe i różne bariery pamięci. Teraz możesz pisać skomplikowane, wysokowydajne współbieżne procedury całkowicie w języku określonym przez standard, i możesz mieć pewność, że Twój kod będzie się kompilował i działał bez zmian zarówno w dzisiejszych systemach, jak i w przyszłości.
Chociaż szczerze mówiąc, chyba że jesteś ekspertem i pracujesz nad poważnym kodem niskiego poziomu, prawdopodobnie powinieneś trzymać się muteksów i zmiennych warunkowych. To właśnie zamierzam zrobić.
Aby uzyskać więcej informacji na ten temat, zobacz ten post na blogu .