Stack na lockach szybszy od lock-free?

0

Mam dwie implementacje stosu, jedną opartach na lockach, a drugą non-blocking:

package ds;

import java.util.concurrent.atomic.AtomicInteger;

public class BlockingStack<E> {

    private Node<E> head = new Node<>();

    private final AtomicInteger size = new AtomicInteger();

    public synchronized void push(E e) {
        final Node<E> newNode = new Node<>();
        newNode.value = e;
        newNode.next = head;
        head = newNode;
        size.getAndIncrement();
    }

    public synchronized E pop() {
        final Node<E> oldHead = head;
        if (oldHead == null) {
            return null;
        }
        head = oldHead.next;
        size.getAndDecrement();
        return oldHead.value;
    }

    public int size() {
        return size.get();
    }

    class Node<E> {
        E value;
        Node<E> next;
    }
}

package ds;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;

public class NonBlockingStack<E> {

    private final AtomicReference<Node<E>> head =
            new AtomicReference<>();

    private final AtomicInteger size = new AtomicInteger();

    public int size() {
        return size.get();
    }

    public void push(E e) {
        Node<E> newNode = new Node<>();
        newNode.value = e;
        Node<E> oldHead;
        do {
            oldHead = head.get();
            newNode.next = oldHead;
        } while (!compareAndSet(oldHead, newNode));
        size.getAndIncrement();
    }

    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;

        do {
            oldHead = head.get();
            if (oldHead == null) {
                return null;
            }
            newHead = oldHead.next;
        } while (!compareAndSet(oldHead, newHead));
        size.getAndDecrement();
        return oldHead.value;
    }


    public boolean compareAndSet(final Node<E> current, final Node<E>  next) {
        if (head.compareAndSet(current, next)) {
            return true;
        } else {
            LockSupport.parkNanos(1);
            return false;
        }
    }

    class Node<E> {
        E value;
        Node<E> next;
    }
}


Chciałem przetestować wydajność obu implementacji przy różnych ilościach wątków/operacji:

package buffers;

import ds.BlockingStack;
import ds.NonBlockingStack;

import java.util.concurrent.CountDownLatch;

public class Main {

    private static final int NUM_OF_THREADS = 8;
    private static final int OPERATIONS = 100_000;
    private static final Thread[] THREADS = new Thread[NUM_OF_THREADS];

    public static void main(String[] args) throws InterruptedException {
        final BlockingStack<Integer> ints = new BlockingStack<>();
//        final NonBlockingStack<Integer> ints = new NonBlockingStack<>();

        final CountDownLatch cdl = new CountDownLatch(NUM_OF_THREADS + 1);

        for (int i = 0; i < NUM_OF_THREADS; i++) {
            THREADS[i] = new Thread(() -> {
                try {
                    cdl.countDown();
                    cdl.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int j = 0; j < OPERATIONS; j++) {
                    ints.push(j);
                }
            });
        }

        for (Thread t : THREADS) {
            t.start();
        }

        long start = System.nanoTime();
        cdl.countDown();
        for (Thread t : THREADS) {
            t.join();
        }
        long elapsed = System.nanoTime() - start;

        System.out.println("elapsed: " + elapsed);
        System.out.println("size: " + ints.size());

        long nsPerItem = elapsed / (NUM_OF_THREADS * (long) OPERATIONS);
        System.out.print("throughput: " + nsPerItem + " ns/item");
    }

}

Efekt jest taki, że przykładowo dla 8 wątków, z 100.000 operacjami stack z lockami jest szybszy/taki sam niż/jak ten nieblokujący. Czemu?

0

Zrobiłem kilka przeróbek i teraz nieblokujący stos jest u mnie lekko szybszy:

package threads;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;

public class ConcurrentStack {
    public static class BlockingStack<E> {

        private Node head = new Node();

        private volatile int size = 0;

        public synchronized void push(E e) {
            final Node newNode = new Node();
            newNode.value = e;
            newNode.next = head;
            head = newNode;
            size++;
        }

        public synchronized E pop() {
            final Node oldHead = head;
            if (oldHead == null) {
                return null;
            }
            head = oldHead.next;
            size--;
            return oldHead.value;
        }

        public int size() {
            return size;
        }

        class Node {
            E value;
            Node next;
        }
    }

    public static class NonBlockingStack<E>
            extends AtomicReference<NonBlockingStack<E>.Node> {
        public NonBlockingStack() {
            Node nil = new Node();
            nil.size = 0;
            set(nil);
        }

        public int size() {
            return get().size;
        }

        public void push(E e) {
            Node newNode = new Node();
            newNode.value = e;
            Node oldHead;
            do {
                oldHead = get();
                newNode.next = oldHead;
                newNode.size = oldHead.size + 1;
            } while (!compareAndSet(oldHead, newNode));
        }

        public E pop() {
            Node oldHead;
            Node newHead;

            do {
                oldHead = get();
                if (oldHead.size == 0) {
                    return null;
                }
                newHead = oldHead.next;
            } while (!compareAndSet(oldHead, newHead));
            return oldHead.value;
        }

        class Node {
            E value;
            Node next;
            int size;
        }
    }

    private static final int NUM_OF_THREADS = 8;
    private static final int OPERATIONS = 1_000_000;
    private static final Thread[] THREADS = new Thread[NUM_OF_THREADS];

    public static void main(String[] args) throws InterruptedException {
//        final BlockingStack<Integer> ints = new BlockingStack<>();
        final NonBlockingStack<Integer> ints = new NonBlockingStack<>();

        final CountDownLatch cdl = new CountDownLatch(NUM_OF_THREADS + 1);

        for (int i = 0; i < NUM_OF_THREADS; i++) {
            THREADS[i] = new Thread(() -> {
                try {
                    cdl.countDown();
                    cdl.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int j = 0; j < OPERATIONS; j++) {
                    ints.push(j);
                }
            });
        }

        for (Thread t : THREADS) {
            t.start();
        }

        long start = System.nanoTime();
        cdl.countDown();
        for (Thread t : THREADS) {
            t.join();
        }
        long elapsed = System.nanoTime() - start;

        System.out.println("elapsed: " + elapsed);
        System.out.println("size: " + ints.size());

        long nsPerItem = elapsed / (NUM_OF_THREADS * (long) OPERATIONS);
        System.out.print("throughput: " + nsPerItem + " ns/item");
    }
}

W nieblokującym stosie size wstawiłem do Node żeby było poprawnie. Wcześniej mogła nastąpić utrata spójności danych jeśli np między head.compareAndSet i size.getAndIncrement zaszło przełączenie wątku.

0
Wibowit napisał(a):

Zrobiłem kilka przeróbek i teraz nieblokujący stos jest u mnie lekko szybszy:

No moze i jest lekko szybszy dla 1mln operacji, ale dla 100k blokujacy dalej jest wydajniejszy :D

0

Możliwe, że dla 100k JVMka większość czasu spędza w interpreterze i to wpływa mocno na wyniki. Kod blokujący jest krótszy, a to jest ważne podczas interpretowania.

3
  1. nie testujesz stosu - testujesz TYLKO operację push - to pierwszy problem
  2. Testowanie nie uwzględnia pułapek JVM - polecam użyć wzmiankowanego już JMH, inaczej możesz od razu wyniki wyrzucać na śmieci
  • w szczególnopści jak twoje 100k operacji trwa poniżej pół sekundy (za krótko) to w zasadzie cały powiar jest niszczony przez elementy spoza kodu:
    szybkośc Thead.join(), rozpędzanie JVM, wypadki gc itd.

Przetestuj porządnie na sensownym scenariuszu, mozesz robić 100k operacji, ale zrób też pop i powtórz je w petli 100 razy np. JMH robi takie rzeczy dla Ciebie. (plus dużo innych).
Poczytaj o blackHole - bo to (a w zasadzie brak) może całkiem niszczyć twoje pomiary.
(Poprawka: przez to, że czytasz size() - dodatkiowy blackhole nie jest CI potrzebny - przypadkiem akurat to jest dobrze :-) ).

JVM to świnia - szczególnie w benchmarkach - możesz zacząć tu : https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html, ale jest materiałów na miesiące uczenia (w tym temacie).

0

Na szybko, z blackhole i bez i dla porównania pusta matoda. Warto sobie puścić żeby się przekonać, że bez blackhole może być tak wydajnie jak pusta metoda.

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

@State(Scope.Thread)
public class WithAndWithoutBlackhole {

    @Benchmark
    @Fork(1)
    @Warmup(iterations = 1)
    @Measurement(iterations = 1)
    public void measureEmptyMethod(Blackhole blackhole) {

    }

    @Benchmark
    @Fork(1)
    @Warmup(iterations = 1)
    @Measurement(iterations = 1)
    public void measureAddMethod(Blackhole blackhole) {

        int sum = 1 + 1;
//        blackhole.consume(sum);
    }

    @Benchmark
    @Fork(1)
    @Warmup(iterations = 1)
    @Measurement(iterations = 1)
    public void measureAddMethodWithALittleHelpFromBlackhole(Blackhole blackhole) {

        int sum = 1 + 1;
        blackhole.consume(sum);
    }
}
2

Przy okazji - lock free nie zawsze musi być szybsze. Na obecnym sprzęcie przeważnie będzie, ale jest trochę: to zależy, co robimy, ile rdzeni, jak często są kolizje.
W szczególności przydałoby się żebyś miał jakieś operacje typu READ (to nie jest konieczne, ale pomocne).
Mógłbyś dopsiac klasyczną operacje top, (oprócz pop, size i push), zapuścić jakiś ciekawszy scenariusz - gdzie wszystkie będą użyte, i wtedy zobacz.

0

Ogólnie dzięki za odzew, sporo cennych informacji jeśli chodzi o wiarygodne mierzenie. Na pewno przyjrzę się JMH.
@jarekr000000 W mojej obecnej implementacji faktycznie - jeśli porobię jakieś dodatkowe jak np pop() to nieblokujacy stack jest szybszy. - Przy czym ważnym aspektem jest chyba LockSupport.parkNanos(1) w momencie gdy CAS sie nie udal. A wynika to z tego https://dzone.com/articles/wanna-get-faster-wait-bit

0
BialyMis napisał(a):

Przy czym ważnym aspektem jest chyba LockSupport.parkNanos(1) w momencie gdy CAS sie nie udal. A wynika to z tego https://dzone.com/articles/wanna-get-faster-wait-bit

Wynik jednego benchmarka niekoniecznie jest ma przełożenie na inne obliczenia. Ogólna zasada z benchmarkami jest taka: jeśli chcesz osiągnąć wydajność jak w benchmarkach, pisz kod dokładnie taki jaki jest w benchmarkach. W benchmarku są idiotyczne obliczenia - cóż, jeśli chcesz mieć identyczną wydajność to musisz robić identycznie idiotyczne obliczenia. 1 nanosekunda postoju to strasznie mało. Jeśli masz coś wielokrotnie dłuższego do obliczenia to być może trzeba ten odstęp czasowy zwiększyć. Wszystko zależy od konkretnego przypadku. Najlepiej skup się na wąskich gardłach w rzeczywistych zastosowaniach niż na maksymalizowaniu wydajności w sztucznym i bezużytecznym kodzie.

Wracając do kombinacji z kodem to sprawy można też podejść inaczej. Zamiast ponawiać obliczenia można mieć spin-wait, tzn zamiast:

volatile State state;
synchronized void next() {
    state = fn(state);
}

czy:

final AtomicReference<State> state = ...;
void next() {
  state.updateAndGet(state -> fn(state));
}

Możesz dorobić coś w rodzaju semafora:

volatile State state;
final AtomicBoolean permit = new AtomicBoolean(true);
void next() {
  while (!permit.compareAndSet(true, false)) {
    Thread.onSpinWait(); // dostępne od Javy 9
  }
  state = fn(state);
  permit.set(true);
}

Takie coś symuluje synchronized tylko zamiast usypiania wątku jest busy-loop.

0
Wibowit napisał(a):

1 nanosekunda postoju to strasznie mało.

"Ziarnistość" czasu w JVM Windows to chyba ok. 300 ns (Unix, Linux trochę mniej).

0
BraVolt napisał(a):
Wibowit napisał(a):

1 nanosekunda postoju to strasznie mało.

"Ziarnistość" czasu w JVM Windows to chyba ok. 300 ns (Unix, Linux trochę mniej).

Zegarów jest wiele rodzajów. System.nanoTime czy System.currentTimeMillis muszą być globalnie monotoniczne natomiast parkNanos teoretycznie nie musi spełniać takich wymagań.

Tak czy siak wątek z busy-loop czeka dokładnie tyle ile trzeba (o ile go system operacyjny nie wywłaszczy).

0
Wibowit napisał(a):

Zegarów jest wiele rodzajów. System.nanoTime czy System.currentTimeMillis muszą być globalnie monotoniczne natomiast parkNanos teoretycznie nie musi spełniać takich wymagań.

Nie mam doświadczenia, pamiętam tylko z jakiejś prezentacji jug'owej, żeby czasu nie brać (najlepiej) poniżej nastu-mikrosekund. Implementacje, błąd pomiarowy itd.

1

Z eksperymentów tutaj https://hazelcast.com/blog/locksupport-parknanos-under-the-hood-and-the-curious-case-of-parking/ wynika iż okres parkowania na Linuksie jest domyślnie zaokrąglany w górę do wielokrotności aż 50 mikrosekund. Myślę, że warto sprawdzić jakie jest zużycie CPU przez poszczególne wątki za pomocą https://docs.oracle.com/javase/8/docs/api/java/lang/management/ThreadMXBean.html Być może to LockSupport.parkNanos sprawia, że wątki większość czasu śpią zamiast walczyć o wspólne zasoby.

1 użytkowników online, w tym zalogowanych: 0, gości: 1