Hakujemy maszynę wirtulaną - błędy widoczności

Wykrywanie błędów widoczności we współbieżnych aplikacjach Java.

Dzisiaj dowiesz się, czym jest błąd widoczności w języku Java, dlaczego powstaje i jak go odnaleźć. Błędy widoczności to trudno uchwytne błędy w programowaniu w języku Java.

Czym w ogóle jest błąd widoczności i kiedy występuję?

Błąd widoczności występuje wtedy, kiedy wątek w języku Java odczytuje nieaktualne wartości. W poniższym przykładzie wątek przetwarza pętlę while, dopóki index jest równy zero.

Błąd widoczności występuje, gdy wątek odczytuje nieaktualne wartości. W poniższym przykładzie wątek sygnalizuje inny wątek, aby zatrzymać przetwarzanie jego pętli while:


public class VisibleErrorExample {
   private int index;
   public void run() throws InterruptedException   {
       Thread workerThread = new Thread( () -> { 
           while(index == 0) {
               // spin
           }
       });
       workerThread.start();
       index = 1;
       workerThread.join();  // test might hang up here 
   }
 public static void main(String[] args)  throws InterruptedException {
       for(int i = 0 ; i < 1000 ; i++) {
           new VisibleErrorExample().run();
       }
   }    
}

Błąd polega na tym, że wątek roboczy może nigdy nie zobaczyć aktualizacji zmiennej index i dlatego działa wiecznie.

Jednym z powodów odczytu starych wartości jest pamięć podręczna rdzeni procesora. Każdy rdzeń współczesnych procesorów ma własną pamięć podręczną. Zatem jeśli wątek odczytu i zapisu działa na różnych rdzeniach, wątek odczytu widzi wartość w pamięci podręcznej, a nie wartość zapisaną przez wątek zapisu.

Jak odtworzyć błąd widoczności

Jeśli uruchomiłeś powyższy przykład, istnieje duże prawdopodobieństwo, że test się nie zawiesi. Test wymaga tak małej liczby cykli procesora, że oba wątki zwykle działają na tym samym rdzeniu, a gdy oba wątki działają na tym samym rdzeniu, odczytują i zapisują w tym samym buforze. Na szczęście OpenJDK udostępnia narzędzie jcstress, które pomaga w tego typu testach. jcstress używa wielu lew, aby wątki testów działały na różnych rdzeniach. Tutaj powyższy przykład jest przepisany jako test jcstress:


@JCStressTest(Mode.Termination)
@Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "Gracefully finished.")
@Outcome(id = "STALE", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Test hung up.")
@State
public class VisibleErrorExampleTest {
    int index;
    @Actor
    public void actor1() {
        while (index == 0) {
            // spin
        }
    }
    @Signal
    public void signal() {
        index = 1;
    }
}

Adnotując klasę adnotacją @JCStressTest, mówimy jcstress, że ta klasa jest testem jcstress. jcstress uruchamia metody z adnotacjami @Actor i @Signal w osobnym wątku. jcstress najpierw uruchamia wątek aktora, a następnie uruchamia wątek sygnałowy. Jeśli test zakończy się w rozsądnym czasie, jcstress zapisuje wynik „TERMINATED”; w przeciwnym razie wynik „STALE”.

jcstress uruchamia przypadek testowy wiele razy z różnymi parametrami JVM.

W przypadku parametru JVM-XX: -TieredCompilation wątek rozłącza się w 90 procentach wszystkich przypadków, ale w przypadku flag JVM -XX: TieredStopAtLevel = 1 i -Xint wątek kończy się we wszystkich uruchomieniach.

Jak uniknąć błędów widoczności

Po potwierdzeniu, że nasz przykład rzeczywiście zawiera błąd, jak możemy go naprawić?

Java ma wyspecjalizowane instrukcje, które gwarantują, że wątek zawsze widzi najnowszą zapisaną wartość. Jedną z takich instrukcji jest modyfikator volatile. Podczas odczytu zmiennego pola wątek gwarantuje, że aplikacja zobaczy ostatnią zapisaną wartość. Gwarancja dotyczy nie tylko wartości pola, ale wszystkich wartości zapisanych przez wątek zapisu przed zapisem do zmiennej volatile. Dodanie modyfikatora pola volatile do pola index z powyższego przykładu zapewnia, że pętla while zawsze się kończy, nawet jeśli zostanie uruchomiona w teście z jcstress.


public class VisibleErrorExample {
   volatile int index;
   // methods omitted
}

Modyfikator zmienności pola nie jest jedyną instrukcją, która daje takie gwarancje w przypadku błędów widoczności. Na przykład zsynchronizowana instrukcja i klasy w pakiecie java.util.concurrent dają te same gwarancje. Dobrą lekturą na temat technik unikania błędów widoczności jest książka „Java Concurrency in Practice” autorstwa Briana Goetza i in.

Po sprawdzeniu, dlaczego występują błędy widoczności oraz jak je reprodukować i unikać, przyjrzyjmy się, jak je znaleźć.

Jak znaleźć błędy widoczności

Specyfikacja języka Java Rozdział 17. Wątki i blokady formalnie określa gwarancje widoczności instrukcji Java. Ta specyfikacja definiuje tak zwaną relację „dzieje się przed” w celu zdefiniowania gwarancji widoczności:

Dwie czynności mogą być uporządkowane według relacji przed ich wystąpieniem. Jeśli jedna akcja wydarzy się przed drugą, pierwsza będzie widoczna i uporządkowana przed drugą.

A czytanie i pisanie na zmiennym polu tworzy taką relację przed wystąpieniem:

Zapis do zmiennego pola (§ 8.3.1.4) ma miejsce przed każdym kolejnym odczytem tego pola.

Korzystając z tej specyfikacji, możemy sprawdzić, czy program zawiera błędy widoczności, zwane w specyfikacji data condition.

Gdy program zawiera dwa konflikty dostępu (§17.4.1), które nie są uporządkowane według relacji zdarzającej się wcześniej, mówi się, że zawiera data condition. Mówi się, że dwa wejścia do (odczytuje lub zapisują) tej samej zmiennej nie mogą być w konflikcie, jeśli przynajmniej jednym z dostępów jest zapis.

Patrząc na nasz przykład, widzimy, że nie ma relacji przed odczytem i zapisem do zmiennej współdzielonej index, więc ten przykład zawiera wyścig danych zgodnie ze specyfikacją.

Oczywiście to rozumowanie można zautomatyzować. Poniższe dwa narzędzia używają tych reguł do automatycznego wykrywania błędów widoczności:

  1. ThreadSanitizer używa reguł modelu pamięci C ++ do wyszukiwania błędów widoczności w aplikacjach C ++. Model pamięci C ++ składa się z formalnych reguł określających gwarancje widoczności instrukcji C ++, podobnie jak specyfikacja języka Java dla instrukcji Java. Istnieje projekt propozycji rozszerzenia Java, w celu włączenia ThreadSanitizer do OpenJDK JVM. Korzystanie z ThreadSanitizer powinno być włączone poprzez odpowiednią flagę w wierszu poleceń.
  2. Vmlens to niezawodne narzędzie, którego używam, aby przetestować współbieżności w Javie, używa specyfikacji języka Java do automatycznego sprawdzania, czy uruchomienie testowe Java zawiera błędy widoczności.