„Lagujący Android” i mit powolnej maszyny wirtualnej

Czytając komentarze, a czasami też artykuły na blogach, można przeczytać, że Android działa „wolno” (cokolwiek by to miało znaczyć), bo „jest źle zoptymalizowany” (cokolwiek by to miało znaczyć), „używa powolnej Javy” (cokolwiek by to miało znaczyć) czy „działa w maszynie wirtualnej” (cokolwiek by to miało znaczyć).

W tych zwrotach być może jest małe ziarenko prawdy, ale w większości są to tylko słowa-wytrychy obnażające niewiedzę dyskutantów i nieznajomość technologii, w jakich projektowane są współczesne mobilne systemy operacyjne. I nie mówię tego złośliwie – nie każdy musi interesować się technicznymi aspektami OS-ów, ale bez podstawowej wiedzy, dyskusje na temat wyższości iOS nad Androidem czy wyższością Androida nad WP są zazwyczaj całkowicie niekonstruktywne.

Rozprawmy się z mitem „wolnej Javy i maszyny wirtualnej”, które rzekomo zabijają wydajność Androida. Do porównania „szybkości” poszczególnych języków programowania wrócę później, ale na początek chciałbym zająć się kwestią wirtualizacji. Nie można jednak poruszyć tego tematu bez krótkiego przypomnienia jak działają aplikacje androidowe (oczywiście w pewnym uproszczeniu, bo wszystkich aspektów i narzędzi nie sposób omówić).

Dewelopera chleb powszedni

Zacznijmy od samego początku, czyli od ich tworzenia. Deweloper, używając wybranego zintegrowanego środowiska programistycznego (IDE, takie jak Android Studio czy Eclipse) tworzy kod źródłowy aplikacji używając do tego języka Java (oraz opcjonalnie C++, ale o tym w dalszej części), androidowych bibliotek oraz API dostarczanego przez Google. Po zakończeniu procesu tworzenia kodu, debuggowania (usuwania błędów) i testowania, ostateczny kod źrodłowy jest kompilowany. Co do zasady, można w zasadzie wyróżnić dwa rodzaje kompilacji – kompilację bezpośrednio do kodu maszynowego (Assembler – pojedyncze rozkazy dla procesora) oraz kompilację do kodu przejściowego (IL, bytecode), który później kompilowany jest przez maszynę wirtualną na konkretnym urządzeniu (np. smartfonie) do kodu maszynowego.

Języki, takie jak C czy C++ (również Objective-C i Swift używane w iOS i OSX), kompilowane są bezpośrednio do kodu maszynowego na maszynie programisty. Zapewnia to w teorii lepszą wydajność, bo usuwa jedną warstwę pośrednią w systemie, ale ogranicza przenośność kodu, bo z uwagi na różne architektury procesorów, każdy czip musi dostać kod maszynowy dostosowany do jego zbioru rozkazów czy długości rejestrów. Z tego powodu każda rodzina urządzeń musiałaby dostać własną paczkę ze skompilowaną aplikacją – dostosowaną do architektury odpowiedniego czipu. W przypadku urządzeń Apple, gdzie firma ma pełną kontrolę nad sprzętem i jego architekturą, język kompilowany do kodu maszynowego ma rację bytu i jest preferowany właśnie ze względu na lepszą wydajność.

Języki takie jak Java (używana do tworzenia aplikacji dla Androida) czy C# (używany do tworzenia aplikacji dla Windows Phone czy aplikacji uniwersalnych dla W10) kompilowane są do kodu przejściowego (bytecode), co pozwala na ich dużą „przenośność”. O co chodzi? Android czy Windows muszą wspierać setki czy tysiące urządzeń o najróżniejszych architekturach. Bezpośrednia kompilacja kodu źródłowego do kodu maszynowego nie miałaby tu racji bytu, bo musiałyby powstać dziesiątki wersji tej samej paczki dla poszczególnych aplikacji – wersji dostosowanych do każdej z architektur (choć podstawowych architektur jest tylko kilka, to każdy model wykorzystuje dodatkową optymalizację kompilatora, wykorzystując w pełni unikalną budowę procesora). Po kompilacji do kodu przejściowego na maszynie programisty (np. twórcy aplikacji), kod przejściowy jest kompilowany do kodu maszynowego, ale odpowiada za to maszyna wirtualna działająca na konkretnym pojedynczym urządzeniu (użytkownika końcowego). Zatem ostateczna kompilacja do rozkazów procesora odbywa się lokalnie na przykład na smartfonie – zwykle w czasie rzeczywistym, czyli w momencie uruchamiania poszczególnych aplikacji.

Wróćmy jednak do naszego dewelopera, który tworzy androidową aplikację. Skończyliśmy na skompilowaniu naszej gotowej aplikacji do kodu przejściowego. Co dalej? Wynikiem kompilacji jest paczka .apk, która wysyłana jest bezpośrednio do sklepu Play poprzez odpowiednie narzędzia (zwykle przeglądarkę z zalogowanym kontem deweloperskim). Paczka ta to zwykłe archiwum typu .zip, które możemy otworzyć i podejrzeć zgromadzone tam pliki. Co w niej znajdziemy? Oprócz grafik, logotypów, opisów czy bibliotek (i kodu maszynowego skompilowanego z C++, o czym później) – czyli tak zwanych „zasobów”, zobaczymy tam binarne pliki .dex zawierające kod przejściowy (bytecode) dla maszyny wirtualnej Javy, czyli środowiska uruchomieniowego obecnego na każdym urządzeniu z tym systemem. Co dalej? Po kolei. Deweloper wrzuca appkę do weryfikacji i jeśli proces ten przejdzie pomyślnie, dostępna ona będzie do pobrania w sklepie Play. Użytkownik mający urządzenie z Androidem może, poprzez sklep, pobrać taką aplikację (w praktyce pobiera plik z paczką .apk) i zainstalować ją na swoim smartfonie czy tablecie.

ART_Dalvik_architecture.svg

W tym momencie sytuacja się rozgałęzia. Wersje Androida do 4.4 włącznie korzystały ze środowiska uruchomieniowego o nazwie Dalvik, które wykorzystywało maszynę wirtualną typu JIT. JIT, czyli just-in-time, oznacza, że maszyna wirtualna kompiluje kod przejściowy do kodu maszynowego w czasie rzeczywistym. Poszczególne bloki kodu są kompilowane wtedy, gdy wymaga tego uruchamiana aplikacja czy działanie wewnątrz niej. Co prawda już od wersji 2.2 (Froyo) Dalvik stosował tak zwany trace-based-JIT, czyli prekompilację do kodu maszynowego pewnych często wykorzystywanych bloków kodu, w celu przyspieszenia wydajności. Co do zasady jednak, całość była kompilowana „na bieżąco”, w trakcie uruchamiania i korzystania z danej aplikacji.

Android 5.0 domyślnie zastąpił Dalvika środowiskiem uruchomieniowym ART (Android Runtime), którego maszyna wirualna, w odróżnieniu od poprzednika, działa w trybie AOT, czyli ahead-of-time („z góry”). W praktyce oznacza to, że aplikacja nie kompiluje się do kodu maszynowego przy jej uruchomieniu, a już na etapie jej pierwszej instalacji na urządzeniu. Pozwala to na usunięcie narzutu i konieczności kompilacji w czasie rzeczywistym, ale zwiększa zajętość pamięci wewnętrznej, bo system musi przechowywać na stałe zarówno plik .apk, jak i skompilowany kod maszynowy. Benchmarki i inne testy pokazały jednak, że przejście z Dalvika na ART nie przyniosło żadnych spektakularnych skoków wydajności, a przy wykonywaniu niektórych algorytmów wręcz obniżyło to wydajność.

I to w zasadzie tyle – niżej jest już tylko linuksowe jądro systemu wraz z systemami bezpieczeństwa, sandboksami, managerami wejścia/wyjścia, kontrolą procesów i innymi niskopoziomowymi zadaniami systemu operacyjnego. Dlaczego więc mówi się, że Android jest wolniejszy od Windows Phone? Czy wina leży własnie po stronie maszyny wirtualnej? Powolnej Javy?

Android a Windows Phone (i 10 Mobile) – realne różnice

Największym zaskoczeniem dla niektórych będzie pewnie informacja, że Windows Phone, Windows 10 czy Windows 10 Mobile, do aplikacji dotykowych działających w środowisku uruchomieniowym WinRT (Windows Runtime), również wykorzystuje maszynę wirtualną. Tak, C# jest językiem, który kompilowany jest do kodu pośredniego (bytecode) dokładnie tak jak Java. Dopiero Common Language Runtime (CLR), czyli maszyna wirtualna tego środowiska, kompiluje kod przejściowy do kodu maszynowego. Dokładnie tak jak w Androidzie.

Jakim cudem więc jednordzeniowe smartfony z Windowsem Phone 7 chodziły często płynniej niż androidowe kombajny z 4 rdzeniami? Słowo klucz to „płynniej”. A płynności nie możemy mylić z szybkością czy mocą obliczeniową. Jeśli porównalibyśmy benchmarki procesora albo grafiki, cztery rdzenie Androida rozgromiłyby sprzęt z WP7 w każdym podejściu. Tak samo Android miałby niekwestionowaną przewagę w grach 3D. Nie da się przeskoczyć fizycznego braku mocy obliczeniowej. O co więc chodzi z tą płynnością? Microsoft osiągnął ten efekt kosztem wielu wyrzeczeń. Aplikacje miały bardzo duże ograniczenia w komunikacji między sobą i z systemem, ograniczone było ich działanie w tle, brakowało powiadomień, dostępu do systemu plików, itd… Pozorna płynność dawnego Windows Phone wynikała z zamkniętości i ograniczeń tego systemu.

800px-CLR_diag.svg

Potem jednak przyszedł WP8, WP8.1 i zbliża się W10 Mobile i… różnica w płynności staje się coraz mniej zauważalna. Microsoft wyposaża swój system w coraz to nowe funkcje i potrzebuje do tego równie mocnego sprzętu, co Android. Czasy płynnych jednordzeniowców już dawno się skończyły. Co prawda nowy Windows dalej ma (moim zdaniem) pewne przewagi wydajnościowe nad Androidem, dzięki uważniejszej kontroli procesów i ostrzejszą politykę dostępu do niższych warstw systemu oraz działania w tle, ale Google nie śpi i stopniowo łata swój system, wprowadzając zmiany na poziomie architektury, kolejkowania zadań czy zarządzania procesami. Android zaczynał od systemu maksymalnie otwartego, pozwalającego aplikacjom na wszystko (stąd problemy z wydajnością i baterią), a Windows odwrotnie – od systemu w pełni zamkniętego i szczelnie kontrolującego swoje aplikacje. Wygląda na to, że oba te systemy spotkają się wkrótce gdzieś w połowie drogi.

Czy język programowania może być szybki?

Przyjęło się mówić, że języki interpretowane są wolniejsze od kompilowanych. W największym skrócie – to prawda. Języki, które na lokalnej maszynie są interpretowane bezpośrednio z kodu źródłowego (tekstowego) do kodu maszynowego działają zazwyczaj wolniej. Tak jest w przypadku JavaScriptu (choć nie zawsze jest on interpretowany), PHP czy Pythona. Nie wszędzie jednak wydajność jest tak cenna jak pełna przenośność kodu. Trzeba jednak pamiętać, że Java czy C# nie są językami interpretowanymi. Są to jak najbardziej języki kompilowane, z tym że kompilacja jest dwuetapowa – najpierw kompiluje się do kodu przejściowego, a dopiero potem do kodu maszynowego (w trybie JIT lub AOT). Oczywiście konieczność lokalnej kompilacji to jest pewien narzut zasobów (w tym wykorzystania przestrzeni adresowej, stosu czy pamięci RAM), ale różnice nie są tak duże, jakby się można było tego spodziewać. W skrócie – nie w tym leży problem „wydajności Androida”. Mało tego – kompilacja JIT (just-in-time) daje pewne przewagi, z uwagi na to że lokalny kompilator „zna” obecny stan systemu i jego kontekst, dzięki czemu wynikowy kod maszynowy może być dodatkowo optymalizowany uwzględniając lokalne uwarunkowania w danym momencie.

Ale to jeszcze nie wszystko. Zarówno aplikacje dla Androida jak i Windowsa mogą zawierać biblioteki i kod pisany w C++, czyli języku bezpośrednio kompilowanym do kodu maszynowego. I jest to bardzo częsta praktyka. Gotowe algorytmy do przetwarzania grafiki, wideo, obliczania najkrótszej drogi w nawigacji, gry oraz inne zasobożerne zadania zwykle pisane są właśnie w C++. W teorii nic więc nie stoi na przeszkodzie, żeby programy te działały tak samo szybko, jak na iOS (gdzie Objective-C i Swift są kompilowane od razu do kodu maszynowego). W praktyce nie jest to do końca prawdą, choćby ze względu na dostępne frameworki. Metal dla iOS pozwala na „dobranie się” do niskopoziomowych zasobów procesora i grafiki, co sprawia, że teoretyczne słabsze procesory w urządzeniach Apple’a, osiągają lepsze wyniki w benchmarkach niż ich 8-rdzeniowi rywale. Ale Apple to Apple -mając zaledwie kilka smartfonów w swojej ofercie, na dodatek wyposażonych w taką samą architekturę, można sobie na coś takiego pozwolić. W przypadku Windowsa 10, który rozszerza wsparcie nie tylko dla Snapdragonów, ale też intelowskich Atomów, taka sprzętowa optymalizacja będzie coraz trudniejsza. Nie wspominając o Androidzie, który musi wspierać dziesiątki czy setki rożnych czipów.

Od czego więc zależy szybkość języka programowania? Język sam w sobie nie jest szybki i tak naprawdę ten sam język może być zarówno interpretowany, jak i kompilowany. Powiedzieliśmy już, że zazwyczaj kompilacja działa szybciej niż interpretowanie. Idąc dalej, kompilacja bezpośrednio do kodu maszynowego (co do zasady) działa szybciej niż dwuetapowa kompilacja z pośrednim etapem kodu przejściowego. Pozostałe kwestie „szybkości” języków w dużej mierze zależą od jakości kompilatora czy interpretera. To tutaj powstaje duże pole do popisu dla twórców tych narzędzi. A optymalizacja kompilatora w pewnej mierze zależy też od samej składni i możliwości języka. Okazuje się, że dzięki bardziej rozbudowanym strukturom danym, C++ daje się lepiej „optymalizować” niż zwykły C, bazujący na prostych strukturach i wskaźnikach. Do tego dochodzą kwestie narzutu pamięci związanego z zarządcą pamięci (GC, garbage collector) czy wczytywaniem niepotrzebnych bibliotek. Ale w tym momencie wchodzimy w szczegóły, które tak naprawdę nie mają większego znaczenia dla prawdziwej wydajności większości aplikacji. Bo powiedzmy sobie szczerze – zaledwie mały procent appek rzeczywiście wymaga uzasadnionego użycia „szybkiego” C++.

benchmarksJan2009Final

Analizy kodu i wydajności aplikacji dla Androida pokazały, że największym problemem są… źle napisane aplikacje. Wąskim gardłem okazywał się nie procesor czy pamięć RAM, a zbyt dużo niepotrzebnych odwołań do pamięci, co szczególnie w przypadku słabszych urządzeń z wolniejszymi modułami, może się okazać bardzo niekorzystne. Oczywiście Google i jego Android, ale również Microsoft z Windowsem dalej powinny walczyć nad poprawkami w środowisku uruchomieniowym, zarządzaniu pamięcią czy w końcu kompilatorach, ale to często użytkownik instalując kiepsko napisane, zasobożerne aplikacje i przypinając wiele „niepotrzebnych” widgetów sam utrudnia sobie życie. Osobną kwestią jest to, że takie aplikacje przechodzą pozytywną weryfikację w sklepie, a samo Google ma dość luźną politykę uprawnień i dostępu do funkcji systemu dla appek firm trzecich.

Do tej pory nie wspomniałem o wyjątkowo słabo napisanych nakładkach UI na Androida, które często są głównymi winowajcami, jeśli chodzi o spowolnienie działania urządzenia. Trzeba pamiętać, że są to nakładki producentów sprzętu, czyli firm, które rzadko specjalizują się w pisaniu dobrego, szybkiego kodu i dostrajania go. Firmy te zmagają się z ciągłym wyścigiem z czasem – po premierze nowej wersji Androida mają kilka miesięcy na stworzenie autorskiej nakładki i dystrybucję jej do urządzeń użytkowników. A poszczególnych modeli mają często dziesiątki. Im więcej warstw w systemie, im więcej firm trzecich, które mogą w to ingerować, tym więcej miejsc, gdzie coś może pójść nie tak. I to pewnie dlatego Apple nieprzypadkowo uznawany jest za producenta szybkich i stabilnych urządzeń o tak naprawdę przeciętnej specyfikacji.