Dlaczego ten program Java kończy działanie, mimo że najwyraźniej nie powinien (i nie zrobił)?

205

Czuła operacja w moim laboratorium dzisiaj poszła całkowicie nie tak. Siłownik mikroskopu elektronowego przekroczył granicę, a po łańcuchu wydarzeń straciłem sprzęt o wartości 12 milionów dolarów. Zawęziłem ponad 40 000 linii w wadliwym module do tego:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Niektóre próbki danych wyjściowych, które otrzymuję:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Ponieważ nie ma tutaj żadnej arytmetyki zmiennoprzecinkowej i wszyscy wiemy, że liczby całkowite ze znakiem zachowują się dobrze w przypadku przepełnienia w Javie, sądzę, że nie ma nic złego w tym kodzie. Jednak pomimo danych wyjściowych wskazujących, że program nie osiągnął warunku wyjścia, osiągnął warunek wyjścia (został osiągnięty i nie został osiągnięty?). Czemu?


Zauważyłem, że tak się nie dzieje w niektórych środowiskach. Korzystam z OpenJDK 6 na 64-bitowym systemie Linux.

Pies
źródło
41
12 milionów sprzętu? jestem naprawdę ciekawy, jak to się mogło stać ... dlaczego używasz pustego bloku synchronizacji: synchronized (this) {}?
Martin V.
84
Nie jest to nawet bezpieczne dla wątków.
Matt Ball
8
Warto zauważyć: dodanie finalkwalifikatora (który nie ma wpływu na wygenerowany kod bajtowy) do pól xi y„rozwiązuje” błąd. Chociaż nie wpływa to na kod bajtowy, pola są z nim oznaczone, co prowadzi mnie do wniosku, że jest to efekt uboczny optymalizacji JVM.
Niv Steingarten
9
@Eugene: To nie powinno się kończyć. Pytanie brzmi „dlaczego to się kończy?”. Point pKonstrukcja A jest spełniająca p.x+1 == p.y, a następnie do wątku odpytywania jest przekazywane odwołanie . W końcu wątek odpytywania decyduje się wyjść, ponieważ uważa, że ​​warunek nie jest spełniony dla jednego z Pointotrzymywanych komunikatów, ale następnie dane wyjściowe konsoli wskazują, że powinien był zostać spełniony. Brak volatiletutaj oznacza po prostu, że wątek wyborczy może utknąć, ale najwyraźniej nie jest to problemem.
Erma K. Pizarro
21
@JohnNicholas: Prawdziwy kod (który oczywiście nie jest taki) miał 100% pokrycia testowego i tysiące testów, z których wiele testowało rzeczy w tysiącach różnych zamówień i permutacji ... Testowanie nie znajduje magicznie każdego przypadku krawędzi spowodowanego niedeterministycznym JIT / pamięć podręczna / harmonogram. Prawdziwy problem polega na tym, że programista, który napisał ten kod, nie wiedział, że konstrukcja nie nastąpi przed użyciem obiektu. Zauważ, że usunięcie pustego miejsca synchronizedpowoduje, że błąd się nie zdarza? To dlatego, że musiałem losowo pisać kod, dopóki nie znalazłem takiego, który odtworzyłby to zachowanie deterministycznie.
Pies

Odpowiedzi:

140

Oczywiście zapis do currentPos nie ma miejsca - przed jego przeczytaniem, ale nie rozumiem, jak to może być problem.

currentPos = new Point(currentPos.x+1, currentPos.y+1);robi kilka rzeczy, w tym zapisuje wartości domyślne do xi y(0), a następnie zapisuje swoje wartości początkowe w konstruktorze. Ponieważ obiekt nie został bezpiecznie opublikowany, te 4 operacje zapisu mogą być dowolnie zmieniane przez kompilator / JVM.

Tak więc z punktu widzenia wątku czytającego jest legalnym wykonaniem, aby czytać xz nową wartością, ale yna przykład z domyślną wartością 0. Zanim dotrzesz do printlninstrukcji (która, nawiasem mówiąc, jest zsynchronizowana i dlatego wpływa na operacje odczytu), zmienne mają swoje wartości początkowe, a program wypisuje wartości oczekiwane.

Oznaczenie currentPosjako volatilezapewni bezpieczną publikację, ponieważ Twój obiekt jest faktycznie niezmienny - jeśli w twoim rzeczywistym przypadku obiekt zostanie zmutowany po budowie, volatilegwarancje nie będą wystarczające i ponownie zobaczysz niespójny obiekt.

Alternatywnie możesz zrobić Pointniezmienne, które zapewni również bezpieczną publikację, nawet bez użycia volatile. Aby osiągnąć niezmienność, wystarczy zaznaczyć xi yzakończyć.

Jako dodatkowa uwaga i jak już wspomniano, synchronized(this) {}JVM może potraktować ją jako zakaz operacji (rozumiem, że uwzględniłeś ją w celu odtworzenia zachowania).

assylias
źródło
4
Nie jestem pewien, ale czy zakończenie X i Y na koniec nie miałoby takiego samego efektu, unikając bariery pamięci?
Michael Böckling,
3
Prostsza konstrukcja to niezmienny obiekt punktowy, który testuje niezmienniki konstrukcyjne. Dlatego nigdy nie ryzykujesz opublikowaniem niebezpiecznej konfiguracji.
Ron
@BuddyCasino Tak, rzeczywiście - dodałem to. Szczerze mówiąc, nie pamiętam całej dyskusji 3 miesiące temu (w komentarzach zaproponowano użycie finału, więc nie jestem pewien, dlaczego nie uwzględniłem go jako opcji).
assylias,
2
Niezmienność sama w sobie nie gwarantuje bezpiecznej publikacji (gdyby x i y były prywatne, ale ujawnione tylko za pomocą programów pobierających, nadal istniałby ten sam problem z publikacją). ostateczny lub lotny to gwarantuje. Wolałbym ostateczne niż niestabilne.
Steve Kuo,
@SteveKuo Niezmienność wymaga ostateczności - bez ostateczności najlepsza z możliwych jest skuteczna niezmienność, która nie ma tej samej semantyki.
assylias
29

Ponieważ currentPosjest zmieniany poza wątkiem, należy go oznaczyć jako volatile:

static volatile Point currentPos = new Point(1,2);

Bez niestabilności nie ma gwarancji, że wątek będzie czytał aktualizacje programu currentPos, które są tworzone w głównym wątku. Tak więc nowe wartości są nadal zapisywane dla currentPos, ale wątek nadal używa poprzednich wersji buforowanych ze względu na wydajność. Ponieważ tylko jeden wątek modyfikuje currentPos, możesz uciec bez blokad, co poprawi wydajność.

Wyniki wyglądają znacznie inaczej, jeśli odczytasz wartości tylko raz w wątku, aby użyć ich w porównaniu i późniejszym ich wyświetleniu. Kiedy to robię, poniższe xzawsze są wyświetlane jako 1i yróżnią się między 0pewną dużą liczbą całkowitą. Myślę, że jego zachowanie w tym momencie jest nieco niezdefiniowane bezvolatile słowa kluczowego i możliwe jest, że kompilacja kodu JIT przyczynia się do tego, że działa on w ten sposób. Również jeśli skomentuję pusty synchronized(this) {}blok, kod również działa i podejrzewam, że dzieje się tak, ponieważ blokowanie powoduje wystarczające opóźnienie, currentPosa jego pola są ponownie odczytywane, a nie używane z pamięci podręcznej.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}
Ed Plese
źródło
2
Tak, a także mógłbym po prostu wszystko zablokować. O co ci chodzi?
Pies
Dodałem dodatkowe wyjaśnienie dotyczące użycia volatile.
Ed Plese,
19

Masz zwykłą pamięć, odniesienie „currentpos” oraz obiekt Point i jego pola za nim, współdzielone między 2 wątkami, bez synchronizacji. Zatem nie ma zdefiniowanego porządku między zapisem, który ma miejsce w tej pamięci w głównym wątku, a odczytami w utworzonym wątku (nazwij go T).

Główny wątek wykonuje następujące zapisy (zignorowanie początkowej konfiguracji punktu, spowoduje, że px i py będą miały wartości domyślne):

  • do px
  • do py
  • do currentpos

Ponieważ w tych zapisach nie ma nic specjalnego pod względem synchronizacji / barier, środowisko wykonawcze może dowolnie pozwolić, aby wątek T zobaczył, że występują one w dowolnej kolejności (główny wątek oczywiście zawsze widzi zapisy i odczyty uporządkowane zgodnie z kolejnością programów) i występują w dowolnym punkcie między odczytami w T.

Więc T robi:

  1. czyta currentpos do p
  2. odczytać px i py (w dowolnej kolejności)
  3. porównaj i weź oddział
  4. odczytać px i py (jedno zamówienie) i wywołać System.out.println

Biorąc pod uwagę, że nie ma powiązań porządkowych między zapisami w main, a odczytami w T, istnieje oczywiście kilka sposobów, dzięki którym można uzyskać wynik, ponieważ T może zobaczyć zapis main w currentpos przed zapisami w currentpos.y lub currentpos.x:

  1. Najpierw odczytuje currentpos.x, przed wystąpieniem zapisu x - dostaje 0, a następnie odczytuje currentpos.y przed wystąpieniem zapisu y - dostaje 0. Porównaj evals z prawdą. Zapisy stają się widoczne dla T. System.out.println nazywa się.
  2. Najpierw odczytuje currentpos.x, po wystąpieniu zapisu x, a następnie odczytuje currentpos.y przed wystąpieniem zapisu y - dostaje 0. Porównaj evals z prawdą. Zapisy stają się widoczne dla T ... itd.
  3. Najpierw odczytuje currentpos.y, zanim nastąpi zapis y (0), a następnie odczytuje currentpos.x po zapisie x, zmienia się na true. itp.

i tak dalej ... Jest tu wiele wyścigów danych.

Podejrzewam, że błędne założenie polega na tym, że zapisy, które wynikają z tego wiersza, są widoczne we wszystkich wątkach w kolejności programowej wykonywanego wątku:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java nie daje takiej gwarancji (byłoby to fatalne z punktu widzenia wydajności). Coś więcej trzeba dodać, jeśli twój program potrzebuje gwarantowanej kolejności zapisów w stosunku do odczytów w innych wątkach. Inni sugerowali, aby pola x, y były ostateczne, lub alternatywnie uczynić prądy prądu zmiennymi.

  • Jeśli ustawisz pola x, y jako końcowe, wówczas Java gwarantuje, że zapis ich wartości będzie widoczny przed powrotem konstruktora we wszystkich wątkach. Tak więc, ponieważ przypisanie do currentpos następuje po konstruktorze, wątek T gwarantuje, że zapisy będą we właściwej kolejności.
  • Jeśli sprawisz, że currentpos będzie niestabilny, wówczas Java gwarantuje, że jest to punkt synchronizacji, który zostanie w całości uporządkowany względem innych punktów synchronizacji. Jak w zasadzie zapisy do xiy muszą się zdarzyć przed zapisem do currentpos, to każdy odczyt currentpos w innym wątku musi także zobaczyć zapisy x, y, które miały miejsce wcześniej.

Korzystanie z parametru final ma tę zaletę, że sprawia, że ​​pola są niezmienne, a zatem umożliwia buforowanie wartości. Korzystanie z niestabilnych prowadzi do synchronizacji przy każdym zapisie i odczycie prądów prądowych, co może zaszkodzić wydajności.

Szczegółowe informacje na ten temat można znaleźć w rozdziale 17 specyfikacji języka Java: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(Początkowa odpowiedź zakładała słabszy model pamięci, ponieważ nie byłem pewien, czy gwarantowana zmienność JLS jest wystarczająca. Odpowiedź edytowana w celu odzwierciedlenia komentarza od assylias, wskazując, że model Java jest silniejszy - zdarza się, zanim jest przechodni - i tak niestabilny na prądach również wystarcza ).

paulj
źródło
2
To moim zdaniem najlepsze wytłumaczenie. Wielkie dzięki!
skyde
1
@skyde, ale myli się co do semantyki niestabilności. niestabilne gwarancje, że w odczytach zmiennej lotnej zobaczysz najnowszy dostępny zapis zmiennej lotnej, a także jakikolwiek poprzedni zapis . W takim przypadku, jeśli currentPoszostanie zmienione, przypisanie zapewnia bezpieczną publikację currentPosobiektu, a także jego członków, nawet jeśli same nie są niestabilne.
assylias,
Cóż, mówiłem, że nie jestem w stanie zobaczyć dokładnie, w jaki sposób JLS gwarantuje, że lotność stanowi barierę dla innych, normalnych odczytów i zapisów. Technicznie nie mogę się mylić;). Jeśli chodzi o modele pamięci, rozsądnie jest założyć, że zamówienie nie jest gwarantowane i jest błędne (nadal jesteś bezpieczny) niż na odwrót i być błędne i niebezpieczne. Świetnie, jeśli zmienność zapewnia tę gwarancję. Czy możesz wyjaśnić, w jaki sposób zapewnia je 17 JLS?
paulj
2
W skrócie, Point currentPos = new Point(x, y)masz 3 zapisy: (w1) this.x = x, (w2) this.y = yi (w3) currentPos = the new point. Kolejność programów gwarantuje, że hb (w1, w3) i hb (w2, w3). Później w programie, który czytasz (r1) currentPos. Jeśli currentPosnie jest lotny, nie ma hb między r1 a w1, w2, w3, więc r1 mógłby zaobserwować dowolną (lub żadną) z nich. Dzięki lotnym wprowadzasz hb (w3, r1). I relacja hb jest przechodnia, więc wprowadzasz także hb (w1, r1) i hb (w2, r1). Jest to podsumowane w praktyce Java Concurrency w praktyce (3.5.3. Bezpieczne idiomy publikacji).
assylias
2
Ach, jeśli hb jest w ten sposób przechodnie, to jest to wystarczająco silna „bariera”, tak. Muszę powiedzieć, że nie jest łatwo ustalić, że 17.4.5 JLS definiuje hb na posiadanie tej właściwości. Z pewnością nie ma go na liście właściwości podanej na początku 17.4.5. Przejściowe zamknięcie jest wspomniane dopiero po kilku uwagach wyjaśniających! W każdym razie dobrze wiedzieć, dziękuję za odpowiedź! :) Uwaga: zaktualizuję swoją odpowiedź, aby odzwierciedlić komentarz Assylias.
paulj
-2

Możesz użyć obiektu do synchronizacji zapisów i odczytów. W przeciwnym razie, jak powiedziano wcześniej, zapis do currentPos nastąpi w środku dwóch odczytów p.x + 1 i py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}
Germano Fronza
źródło
Właściwie to działa. Podczas pierwszej próby umieściłem odczyt w zsynchronizowanym bloku, ale później zdałem sobie sprawę, że tak naprawdę nie jest to konieczne.
Germano Fronza
1
-1 JVM może udowodnić, że semnie jest udostępniony, i traktować zsynchronizowaną instrukcję jako brak możliwości ... Fakt, że rozwiązuje problem, jest czystym szczęściem.
assylias
4
Nienawidzę programowania wielowątkowego, zbyt wiele rzeczy działa z powodu szczęścia.
Jonathan Allen,
-3

Uzyskujesz dostęp do currentPos dwa razy i nie dajesz żadnej gwarancji, że nie zostanie on zaktualizowany pomiędzy tymi dwoma dostępami.

Na przykład:

  1. x = 10, y = 11
  2. wątek roboczy ocenia px jako 10
  3. główny wątek wykonuje aktualizację, teraz x = 11 i y = 12
  4. wątek roboczy ocenia py jako 12
  5. wątek roboczy zauważa, że ​​10 + 1! = 12, więc drukuje i wychodzi.

Zasadniczo porównujesz dwa różne punkty.

Zwróć uwagę, że nawet zmienność currentPos nie ochroni cię przed tym, ponieważ są to dwa osobne odczyty dla wątku roboczego.

Dodaj

boolean IsValid() { return x+1 == y; }

metoda do twojej klasy punktów. Zapewni to, że podczas sprawdzania x + 1 == y zostanie użyta tylko jedna wartość currentPos.

użytkownik2686913
źródło
currentPos jest odczytywany tylko raz, jego wartość jest kopiowana do p. p jest odczytywane dwukrotnie, ale zawsze będzie wskazywać tę samą lokalizację.
Jonathan Allen,