Warunki wstępne powstania operacji atomowych

Rzućmy okiem na ten przykład, aby pomóc Ci zrozumieć, jak działają operacje atomowe:

public class Counter {
    int count;

    public void increment() {
        count++;
    }
}

Gdy mamy jeden wątek wszystko działa super, ale jak dodamy wielowątkowość to otrzymamy błędne wyniki, a wszystko przez to, że operacja increment to nie jedna operacja, a trzy: żądanie pobrania aktualnej wartościliczyć, a następnie zwiększ go o 1 i napisz ponownie doliczyć.

A kiedy dwa wątki chcą zwiększyć zmienną, najprawdopodobniej utracisz dane. Oznacza to, że oba wątki otrzymają 100, w wyniku czego oba napiszą 101 zamiast oczekiwanej wartości 102.

I jak to rozwiązać? Musisz użyć zamków. Synchronizowane słowo kluczowe pomaga rozwiązać ten problem, jego użycie daje gwarancję, że jeden wątek będzie miał dostęp do metody na raz.

public class SynchronizedCounterWithLock {
    private volatile int count;

    public synchronized void increment() {
        count++;
    }
}

Dodatkowo należy dodać słowo kluczowe volatile , które zapewnia poprawną widoczność referencji wśród wątków. Powyżej omówiliśmy jego pracę.

Ale nadal są minusy. Największym z nich jest wydajność, w momencie, gdy wiele wątków próbuje uzyskać blokadę, a jeden ma możliwość zapisu, reszta wątków zostanie zablokowana lub zawieszona do czasu zwolnienia wątku.

Wszystkie te procesy, blokowanie, przełączanie do innego stanu są bardzo kosztowne dla wydajności systemu.

Operacje atomowe

Algorytm wykorzystuje niskopoziomowe instrukcje maszynowe, takie jak porównaj i zamień (CAS, porównaj i zamień, co zapewnia integralność danych i jest już na ich temat wiele badań).

Typowa operacja CAS działa na trzech operandach:

  • Miejsce pamięci do pracy (M)
  • Istniejąca wartość oczekiwana (A) zmiennej
  • Nowa wartość (B) do ustawienia

CAS atomowo aktualizuje M do B, ale tylko wtedy, gdy wartość M jest taka sama jak A, w przeciwnym razie nie jest podejmowana żadna akcja.

W pierwszym i drugim przypadku zwrócona zostanie wartość M. Pozwala to połączyć trzy kroki, a mianowicie uzyskanie wartości, porównanie wartości i jej aktualizację. A wszystko to zamienia się w jedną operację na poziomie maszyny.

W momencie, gdy aplikacja wielowątkowa uzyska dostęp do zmiennej i spróbuje ją zaktualizować, a CAS zostanie zastosowany, jeden z wątków pobierze ją i będzie mógł ją zaktualizować. Ale w przeciwieństwie do blokad, inne wątki po prostu otrzymają błędy dotyczące niemożności zaktualizowania wartości. Wtedy przejdą do dalszej pracy, a zmiana jest całkowicie wykluczona w tego typu pracy.

W tym przypadku logika staje się trudniejsza ze względu na fakt, że musimy poradzić sobie z sytuacją, gdy operacja CAS nie zadziałała pomyślnie. Po prostu zamodelujemy kod, aby nie przechodził dalej, dopóki operacja się nie powiedzie.

Wprowadzenie do typów atomowych

Czy spotkałeś się z sytuacją, w której musisz ustawić synchronizację dla najprostszej zmiennej typu int ?

Pierwszym sposobem, który już omówiliśmy, jest użycie volatile + synchronized . Ale są też specjalne klasy Atomic*.

Jeśli używamy CAS, to operacje działają szybciej w porównaniu do pierwszej metody. A dodatkowo mamy specjalne i bardzo wygodne metody dodawania wartości oraz operacji zwiększania i zmniejszania.

AtomicBoolean , AtomicInteger , AtomicLong , AtomicIntegerArray , AtomicLongArray to klasy, w których operacje są atomowe. Poniżej przeanalizujemy pracę z nimi.

Atomowa liczba całkowita

Klasa AtomicInteger zapewnia operacje na wartości int , które można odczytywać i zapisywać niepodzielnie, oprócz udostępniania rozszerzonych operacji niepodzielnych.

Ma metody pobierania i ustawiania , które działają jak odczytywanie i zapisywanie zmiennych.

Oznacza to, że „dzieje się przed” przy każdym kolejnym otrzymaniu tej samej zmiennej, o której mówiliśmy wcześniej. Atomowa metoda CompareAndSet ma również te funkcje spójności pamięci.

Wszystkie operacje, które zwracają nową wartość, są wykonywane atomowo:

int addAndGet (int delta) Dodaje określoną wartość do bieżącej wartości.
boolean CompareAndSet(oczekiwana int, aktualizacja int) Ustawia wartość na podaną zaktualizowaną wartość, jeśli bieżąca wartość jest zgodna z wartością oczekiwaną.
int dekrementacjaAndGet() Zmniejsza bieżącą wartość o jeden.
int getAndAdd(int delta) Dodaje podaną wartość do bieżącej wartości.
int getAndDecrement() Zmniejsza bieżącą wartość o jeden.
int getAndIncrement() Zwiększa bieżącą wartość o jeden.
int getAndSet(int nowaWartość) Ustawia podaną wartość i zwraca starą wartość.
int incrementAndGet() Zwiększa bieżącą wartość o jeden.
lazySet(int nowaWartość) Ostatecznie ustaw na podaną wartość.
boolean słabyCompareAndSet(oczekiwano, zaktualizuj int) Ustawia wartość na podaną zaktualizowaną wartość, jeśli bieżąca wartość jest zgodna z wartością oczekiwaną.

Przykład:

ExecutorService executor = Executors.newFixedThreadPool(5);
IntStream.range(0, 50).forEach(i -> executor.submit(atomicInteger::incrementAndGet));
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.HOURS);

System.out.println(atomicInteger.get()); // prints 50