Lambda dla ubogich

Z mojego doświadczenia wynika, że większość programistów posiada jakieś swoje własne projekciki na boku. A to jakiś analizator giełdy, a to jakaś apka do ogarniania smogu, a to parsowanie rozkładów jazdy pociągów, względnie sterowanie żarówkami we własnym smart domu.

Takie projekciki charakteryzują się myślą przewodnią: byle szybko zakodzić i byle działało. Z założenia mogą działać tylko w optymistycznych flow i nie posiadać żadnych testów. Mają być radosnym programowanie i sprawdzeniem się, czy jeszcze potrafimy coś zaprogramować w innych „niepracowych” technologiach.

Takimi też przesłankami kierowałem się tworząc swój mini projekcik o roboczej nazwie:

Lambda dla ubogich

Potrzeba była następująca: posiadam wirtualkę w cloudzie i chciałbym posiadać na niej dużo swoich aplikacji, jednak mam tylko 1GB pamięci i 8GB miejsca na dysku… Bardziej problematyczne jest pierwsze ograniczenie… A Java – jak to Java – zjada tyle pamięci, ile się jej przydzieli.

Można oczywiście skorzystać z Graala, skorzystać z Micronauta/Quarkusa i skompilować projekt do kodu natywnego. Problem pojawia się, gdy dziergamy nasze tajne projekty w Spring Boot od kilku lat i jest za dużo kodu do przepisania.

Zatem gdyby tylko istniało jakieś rozwiązanie, które pozwala na uruchamianie naszych serwisów, gdy są potrzebne, a gdy nie są potrzebne, to je ubija. I gdyby jeszcze było w miarę lekkie, nieobciążające zbytnio pamięci ani procesora. Takie Heroku tylko na VPSie.

Trochę poszukałem w internecie takie rozwiązania i oczywiście nie znalazłem 😛

No dobra, gdybym szukał trochę dłużej, to bym znalazł. Jednak taki własny projekt zawsze czegoś uczy, domena jest inna niż ta w pracy, więc może warto spróbować samemu coś takiego napisać…

Development

Z założenia rozwiązanie miało być przenaszalne, crossplatformowe oraz w miarę proste, zatem wybór padł na Dockera, jako sposób zarządzania aplikacjami. Serwerem proxy jest Nginx (wcześniej już chciałem go nieco poznać). Jednak jak połączyć te dwa światy, żeby za proxy była warstwa zarządzająca kontenerami Dockera?

Okazuje się, że istnieje takie rozwiązanie jak OpenResty, który rozszerza Nginx o możliwość wykonywania skryptów (w języku Pascalo-podobnym o nazwie Lua). Zawsze nowy język do CV 😀 Warto mieć jakieś Api dockerowe i panel administracyjny zatem pożeniłem to wszystko z Portainer.

Zatem poświęciłem kilka wieczorów i oto jest: lambda-for-vps. Jeżli ktoś chciałby spróbować, jak działa, wystarczy zklonować repo i uruchomić:

docker-compose -f docker-compose.yml up

Domyślnie uruchamia się projekt z przykładowym hello-world, który jest dostępny na http://localhost/hello-world.

Działanie

Co nieco o działaniu jest opisane na githubie, jednak po angielsku. Po polsku to w skrócie:

  1. Po uderzeniu na endpoint /hello-world za pomocą Portainer sprawdzany jest stan aplikacji (określonej za pomocą docker-compose.yml).
  2. Jeśli kompozycja nie istnieje, to wszystkie kontenery są automagicznie zaciągane.
  3. Jeśli kompozycja jest nieżywa, wówczas uruchamia całą kompozycję. Po uruchomieniu kontenerów istnieje opcja czekania określonego czasu od wstania kompozycji.
  4. Skoro wszystko działa, to request jest obsługiwany.
  5. Następnie zlecamy ubicie kompozycji o ile nie będzie w między czasie nowych żądań. Innymi słowy kompozycja jest ubijana po podanej liczbie sekund od ostatniego requesta.

Prawdopodobnie projekt nie jest doskonały. Jednak posiada kilka fajnych cech:

  • wg docker images całość waży 181 MB,
  • wg docker stats obydwa kontenery zużywają 12 MB pamięci i szczątkową część procesora,
  • jest w nowym nieznanym mi języku Lua,
  • konfiguracja w popularnym Nginx,
  • zarządza apkami trzymanymi w Dockerze.

zatem jak na mały projekt jest przynajmniej Good enough.

Oceń wpis

Wpływ Garbage Collectorów na działanie JVMa – odczyty z pamięci

Poprzedni wpis był o tworzeniu obiektów w JVMie w zależności od wybranego garbage collectora. W tym wpisie skupię się na przedstawieniu wpływie wybranego gc na odczyt istniejących obiektów.

Benchmark

Przed uruchomieniem Benchmarku tworzona jest statyczna tablica. Następnie w bloku statycznym jest ona wypełniana przykładowymi danymi. Tworzone obiekty to wrappery na int.
Benchmark uruchamiany jest dla młodej oraz starej generacji (o ile istnieje taki podział). Aby wymusić przynależność do starej generacji JVM jest proszony o wykonanie GC kilkakrotnie.

    private static final IntWrapper[] WRAPPERS = new IntWrapper[100000];

    static {
        for (int i = 0; i < WRAPPERS.length; i++) {
            WRAPPERS[i] = new IntWrapper(i);
        }
        for (int i = 0; i < 20; i++) {
//            Runtime.getRuntime().gc();
        }
    }

Następnie w benchmarku zliczana jest suma pierwszych 2000 wartości we wspomnianych obiektach.

    @Benchmark
    public long method() {
        long sum = 0;
        for (int i = 0; i < 2000; i++){
            sum += WRAPPERS[i].getI();
        }
        return sum;
    }

UWAGA! Jak widać w powyższym benchmarku bazujemy jedynie na zmiennych lokalnych (czyt. trzymanych na stosie wątku). Jednocześnie w trakcie benchmarku nie tworzymy ani jednego nowego obiektu. Badamy jedynie wpływ gc na odczyty, a nie wydajność samego gc.

UWAGA 2! We wpisie zakładam, że czytelnik przeczytał poprzedni wpis, a także, że jego treść była w miarę zrozumiała. 😉

Epsilon GC

Na początek EpsilonGC, czyli brak działań. Zarówno wynik dla wariantu bez gc oraz z wymuszonym gc jest taki sam – około 1708 ns/op (wymuszanie gc nie ma w tym przypadku sensu i jest ignorowane, stąd te same wyniki).

W tym wpisie uznaję ten wynik uznać za „wzorcowy” i do niego będę porównywał inne rezultatu.

ParallelGC

W przypadku ParallelGC program działa nieprzerwanie aż do momentu braku miejsca na nowe obiekty (MinorGC) lub braku miejsca na promocję obiektów do starej generacji (FullGC). Wszystkie metadane niezbędne do gc wyliczane są w czasie STW bez dodatkowego narzutu na „runtime”. Zatem można spodziewać się wyniku analogicznego do EpsilonGC.

Tak też się dzieje w przypadku obiektów w młodej generacji – w tym przypadku wynik to 1703 ns/op .

W przypadku starej generacji mamy niespodziankę – wynik 1579 ns/op (szybciej o 8%). Z czego wynika szybsze wykonanie benchmarku?

Otóż przy kopiowaniu danych z młodej do starej generacji wykonywana jest analiza, która pozwala na uporządkowanie danych w bardziej logiczny sposób niż przy tworzeniu obiektów. O ile wcześniej elementy tablicy mogły być rozdzielone innymi obiektami (szczególnie, że statyczna inicjalizacja działa się przy ładowaniu klas), o tyle po wykonaniu gc elementy tablicy prawdopodobnie trzymane są razem.

ConcMarkSweepGC

W tym przypadku również młoda generacja wypada podobnie do EpsilonGc – 1694 ns/op. W przypadku starej generacji wynik to 1837 ns/op, czyli 7% wolniej. Jednak podobnie jak w poprzednim wpisie zostawię tę analizę historykom, skoro w OpenJDK 14 CMSa już nie uświadczymy 😉

G1GC

W przypadku G1Gc nie widać różnicy w porównaniu do EpsilonGc – 1701 ns/op oraz 1698 op/s. Prawdopodobnie wynika to z tego, że zarówno młoda generacja, jak i stara znajdują się w takiej samej liczbie segmentów, które niekoniecznie muszą być zlokalizowane blisko siebie. Aczkolwiek, to tylko domysł, który trudno zweryfikować…

O ile G1Gc korzysta z barier zapisu, o tyle z barier odczytu nie korzysta, stąd też brak dodatkowego narzutu na odczyt.

ZGC

Jak już wspominałem w poprzednim wpisie ZGC korzysta z barier odczytu. Wspomniałem również, że aktualnie nie ma podziału na młodą i starą generację.

Oba te fakty widoczne są w wynikach benchmarku – 1996 ns/op w wersji z gc oraz 2025 ns/op w wersji bez gc. Zatem w tym benchmarku narzut spowodowany korzystaniem z ZGC to 16%. Dość dużo, biorąc pod uwagę, że ten gc nie został ani razu uruchomiony w czasie benchmarkowania…

Shenandoah

O ile Shenandoah w OpenJDK 13 również nie posiada podziału o tyle wyniki dwóch benchmarków się znacząco różnią.

W przypadku braku wcześniejszych gc, wynik jest porównywalny do EpsilonGC (~1700 ns/op).

W drugim przypadku – gdy został wcześniej wymuszony gc – wynik był 4041 ns/op. To jest bardzo duży narzut – 136% więcej w porównaniu do EpsilonGC.

Taka różnica może wynikać z głównego założenia działania Shenandoah. Przed każdym obiektem zawsze znajduje się dodatkowy wskaźnik na ten obiekt, lub na nowe miejsce w pamięci, gdzie został ten obiekt przeniesiony. W pesymistycznym przypadku najpierw odczytujemy wartość wskaźnika (jednocześnie ładując do Cache’u procesora obiekt), po czym okazuje się, że załadowany obiekt nie jest tym, którego szukamy. Wówczas musimy pobrać obiekt z innej lokalizacji. Jednak wydaje się dość nieprawdopodobne, że taka sytuacja by była cały czas.

Trzeba pamiętać, że ten gc jest w wersji 13 eksperymentalny. Plany są takie, by ten gc mógł działać produkcyjnie w wersji 15. Zatem jest szansa, że ten przypadek będzie usprawniony.

Podsumowanie

Wybór Garbage Collectora może mieć wpływ nie tylko na samo czyszczenie pamięci, lecz również na działanie aplikacji. Garbage Collector może przyspieszyć wykonywanie kodu (przypadek ParallelGC) lub spowolnić (Shenandoah, ZGC).

Warto wspomnieć, że Shenandoah i ZGC wejdą produkcyjnie dopiero w OpenJDK 15 zatem można oczekiwać pewnych poprawek.

Standardowo ludziom z nadmiarem czasu polecam prezentacje na Youtube. Bardzo dobre podsumowanie wydajności wykorzystania procesora oraz pamięci – Sergey Kuksenko z Oracle. Ponadto, dla ludzi chcących zaznajomić się z Shenandoah polecam prezentację Alexey’a Shipilev’a.

Przy okazji życzę wszystkiego dobrego na święta Wielkanocne 😉

Oceń wpis