Studiowałem ponowne entuzjazm w programowaniu. Na to stronie IBM (naprawdę dobra). Założyłem kod, skopiowany poniżej. To pierwszy kod, który pojawia się po stronie.
Kod próbuje pokazać problemy dotyczące wspólnego dostępu do zmiennej w nieliniowym rozwoju programu tekstowego (asynchroniczność), wypisując dwie wartości, które stale się zmieniają w „niebezpiecznym kontekście”.
#include <signal.h>
#include <stdio.h>
struct two_int { int a, b; } data;
void signal_handler(int signum){
printf ("%d, %d\n", data.a, data.b);
alarm (1);
}
int main (void){
static struct two_int zeros = { 0, 0 }, ones = { 1, 1 };
signal (SIGALRM, signal_handler);
data = zeros;
alarm (1);
while (1){
data = zeros;
data = ones;
}
}
Problemy pojawiły się, gdy próbowałem uruchomić kod (lub lepiej, nie pojawił się). Korzystałem z gcc w wersji 6.3.0 20170516 (Debian 6.3.0-18 + deb9u1) w domyślnej konfiguracji. Nieudane wyjście nie występuje. Częstotliwość uzyskiwania „niewłaściwych” wartości par wynosi 0!
Co w końcu się dzieje? Dlaczego nie ma problemu z ponownym wejściem za pomocą statycznych zmiennych globalnych?
Odpowiedzi:
To nie jest tak naprawdę ponowne wejście ; nie uruchamiasz funkcji dwukrotnie w tym samym wątku (lub w różnych wątkach). Możesz to uzyskać poprzez rekurencję lub przekazanie adresu bieżącej funkcji jako argument funkcji zwrotnej arg do innej funkcji. (I nie byłoby to niebezpieczne, ponieważ byłoby synchroniczne).
To jest po prostu waniliowy wyścig danych UB (niezdefiniowane zachowanie) między procedurą obsługi sygnału a głównym wątkiem: tylko
sig_atomic_t
jest bezpieczny . Inne mogą działać, na przykład w twoim przypadku, gdy 8-bajtowy obiekt można załadować lub zapisać za pomocą jednej instrukcji na x86-64, a kompilator wybiera taki asm. (Jak pokazuje odpowiedź @ icarus).Zobacz programowanie MCU - optymalizacja C ++ O2 zrywa podczas pętli - procedura obsługi przerwań na mikrokontrolerze z jednym rdzeniem jest w zasadzie taka sama jak procedura obsługi sygnałów w programie z jednym wątkiem. W takim przypadku wynikiem UB jest to, że ładunek został wyciągnięty z pętli.
Twój przypadek testowy zerwania faktycznie zachodzi z powodu wyścigu danych UB został prawdopodobnie opracowany / przetestowany w trybie 32-bitowym lub ze starszym głupszym kompilatorem, który ładował osobno elementy struktury.
W twoim przypadku kompilator może zoptymalizować zapasy z nieskończonej pętli, ponieważ żaden program bez UB nigdy ich nie zaobserwuje.
data
nie ma_Atomic
lubvolatile
, i nie ma innych efektów ubocznych w pętli. Więc nie ma mowy, aby jakikolwiek czytnik mógł zsynchronizować się z tym pisarzem. Dzieje się tak, jeśli kompilujesz z włączoną optymalizacją ( Godbolt pokazuje pustą pętlę u dołu głównego). Zmieniłem również struct na dwalong long
, a gcc używa pojedynczegomovdqa
16-bajtowego magazynu przed zapętleniem. (Nie jest to gwarantowane atomowo, ale w praktyce działa na prawie wszystkich procesorach, zakładając, że jest wyrównane, lub na Intelie po prostu nie przekracza granicy linii pamięci podręcznej. Dlaczego przypisanie liczb całkowitych naturalnie wyrównanej zmiennej atomowej na x86? )Zatem kompilacja z włączoną optymalizacją również przerwałaby test i za każdym razem pokazywałaby tę samą wartość. C nie jest przenośnym językiem asemblera.
volatile struct two_int
zmusiłoby również kompilator do nieoptymalizowania ich, ale nie zmusiłoby go do załadowania / przechowywania całej struktury atomowo. (Nie byłoby powstrzymać go od tego czy, choć.) Zauważ, żevolatile
nie nie uniknąć danych wyścigu UB, ale w praktyce jest to wystarczające dla komunikacji między gwintem i było to, jak ludzie budowane ręcznie walcowane ATOMiCS (wraz z inline ASM) przed C11 / C ++ 11, dla normalnych architektur CPU. Są cache-spójny takvolatile
jest w praktyce przeważnie podobny do_Atomic
zmemory_order_relaxed
czystej obciążenia i czystej-sklepu, jeśli są stosowane dla typów zawęzić tyle że kompilator użyje pojedynczą instrukcję, aby nie dostać łzawienie. I oczywiścievolatile
nie ma żadnych gwarancji ze standardu ISO C w porównaniu do pisania kodu, który kompiluje się do tego samego asm przy użyciu_Atomic
i mo_relaxed.Jeśli miałeś funkcję, która działała
global_var++;
naint
lublong long
, że biegniesz z głównego i asynchronicznie z procedury obsługi sygnału, byłby to sposób na użycie ponownego wejścia do utworzenia UB wyścigu danych.W zależności od sposobu kompilacji (do miejsca docelowego pamięci inc lub add, lub do oddzielenia load / inc / store) byłoby atomowe lub nie w odniesieniu do procedur obsługi sygnałów w tym samym wątku. Zobacz Can num ++ be atomic dla 'int num'? więcej informacji o atomowości na x86 i w C ++. (C11
stdatomic.h
i_Atomic
atrybut zapewniają funkcjonalność równoważnąstd::atomic<T>
szablonowi C ++ 11 )Przerwanie lub inny wyjątek nie może się zdarzyć w środku instrukcji, więc dodanie do miejsca docelowego pamięci jest niepodzielne. kontekst włącza jednordzeniowy procesor. Jedynie (spójny z pamięcią podręczną) moduł zapisujący DMA mógł „nadepnąć” na przyrost z prefiksu
add [mem], 1
bezlock
prefiksu na jednordzeniowy procesor. Nie ma żadnych innych rdzeni, na których mógłby działać inny wątek.Jest to więc podobne do przypadku sygnałów: procedura obsługi sygnału działa zamiast normalnego wykonania wątku obsługującego sygnał, więc nie można go obsłużyć w środku jednej instrukcji.
źródło
Patrząc na eksplorator kompilatora godbolt (po dodaniu brakującego
#include <unistd.h>
), widać, że dla prawie każdego kompilatora x86_64 wygenerowany kod używa ruchów QWORD w celu załadowaniaones
izeros
w pojedynczej instrukcji.Witryna IBM mówi,
On most machines, it takes several instructions to store a new value in data, and the value is stored one word at a time.
co mogło być prawdą dla typowego procesora w 2005 r., Ale jak pokazuje kod, obecnie nie jest to prawdą. Zmiana struktury na dwie długie zamiast dwóch liczb wewnętrznych pokazałaby problem.Wcześniej pisałem, że był to „atomowy”, który był leniwy. Program działa tylko na jednym procesorze. Każda instrukcja zostanie wykonana z punktu widzenia tego procesora (zakładając, że nic innego nie zmienia pamięci, takiej jak dma).
Zatem na
C
poziomie nie jest zdefiniowane, że kompilator wybierze pojedynczą instrukcję do napisania struktury, więc może dojść do uszkodzenia wymienionego w dokumencie IBM. Nowoczesne kompilatory ukierunkowane na bieżący procesor używają pojedynczej instrukcji. Pojedyncza instrukcja jest wystarczająca, aby uniknąć uszkodzenia jednego programu wątkowego.źródło
int
nalong long
i skompiluj do 32-bitowego. Lekcja polega na tym, że nigdy nie wiadomo, czy / kiedy się zepsuje.long long
nadal kompiluje się do jednej instrukcji dla x86-64: 16 bajtówmovdqa
. Chyba że wyłączysz optymalizację, jak w twoim linku Godbolt. (Domyślnym-O0
trybem GCC jest tryb debugowania, który jest pełen szumów związanych z przechowywaniem / przeładowywaniem i zwykle nie jest interesujący.)