Przykładowy kod IBM, funkcje nieprzyłączające nie działają w moim systemie

11

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?

Daniel Bandeira
źródło
1
Upewnij się, że cała optymalizacja kompilatora jest wyłączona, i spróbuj ponownie
roaima
Myślałem, że ... ale które opcje chciałbym zmienić? Nie mam pojęcia. :-(
Daniel Bandeira
5
To wygląda na pytanie programistyczne (przepełnienie stosu). Nie wydaje się, aby dawka była tutaj dobrze umiejscowiona. (Przepraszam, ja miałem mniej podstron; jest tak podzielone. Ale tak to jest.)
ctrl-alt-delor
1
Najprostszy kod ponownego wprowadzania jest niezmienny.
ctrl-alt-delor
W pierwszej chwili myślę, że pytanie to będzie związane ze środowiskiem gcc i Linux. Na przykład ewolucja, np. Szeregowanie systemu operacyjnego (wykonywanie większej ilości tekstu programu po sygnale przerwania przed wywołaniem procedury obsługi).
Daniel Bandeira

Odpowiedzi:

12

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. datanie ma _Atomiclubvolatile , 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 dwa long long, a gcc używa pojedynczego movdqa16-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_intzmusił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ż, że volatilenie 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 tak volatilejest w praktyce przeważnie podobny do _Atomiczmemory_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ścievolatilenie ma żadnych gwarancji ze standardu ISO C w porównaniu do pisania kodu, który kompiluje się do tego samego asm przy użyciu _Atomici mo_relaxed.


Jeśli miałeś funkcję, która działała global_var++;na intlub long 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.hi _Atomicatrybut 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], 1bez lockprefiksu 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.

Peter Cordes
źródło
2
Byłem zmuszony zaakceptować twoją najlepszą odpowiedź, mimo że odpowiedź Icaru była dla mnie wystarczająca. Jasne koncepcje, które nam powiedziałeś, dają mi mnóstwo tematów do nauki przez cały dzień (i jeszcze dalej). W rzeczywistości nie mam prawie tego, co piszesz w pierwszych dwóch akapitach na pierwszy rzut oka. Dziękuję Ci! Jeśli publikujesz w Internecie artykuły o komputerach i programowaniu, podaj nam link!
Daniel Bandeira
17

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ładowania onesi zerosw pojedynczej instrukcji.

        mov     rax, QWORD PTR main::ones[rip]
        mov     QWORD PTR data[rip], rax

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 Cpoziomie 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.

Ikar
źródło
3
Spróbuj zmienić typ danych z intna long longi skompiluj do 32-bitowego. Lekcja polega na tym, że nigdy nie wiadomo, czy / kiedy się zepsuje.
ctrl-alt-delor
2
oznacza to, że w mojej maszynie przypisanie tych dwóch wartości jest operacją atomową? (biorąc pod uwagę kompilację dla architektury x86_64)
Daniel Bandeira
1
long longnadal kompiluje się do jednej instrukcji dla x86-64: 16 bajtów movdqa. Chyba że wyłączysz optymalizację, jak w twoim linku Godbolt. (Domyślnym -O0trybem GCC jest tryb debugowania, który jest pełen szumów związanych z przechowywaniem / przeładowywaniem i zwykle nie jest interesujący.)
Peter Cordes
Po przeczytaniu wszystkich komentarzy zmieniłem typ na „długi długi”. Wynik był interesujący: oczekiwane wyniki zostały osiągnięte, a ustawiając niektóre liczniki, był w stanie ulepszyć inne koncepcje, w jaki sposób reszta kodu wpływa na szybkość niedopasowanych danych. Dziękuję za wszelką pomoc!
Daniel Bandeira