Większa skuteczność dzięki podziale na wątki

Umiejętne korzystanie z wątków na Androidzie może pomóc w zwiększeniu wydajności aplikacji. Na tej stronie omawiamy kilka aspektów pracy z wątkami: pracę z interfejsem użytkownika (głównym) wątkiem, związek między cyklem życia aplikacji i priorytetem wątków oraz używane przez platformę metody zarządzania złożonością wątków. Na tej stronie opisujemy potencjalne problemy i strategie ich unikania.

Wątek główny

Gdy użytkownik uruchomi Twoją aplikację, Android utworzy nowy proces Linuksa wraz z wątkiem wykonania. Ten wątek główny, nazywany też wątkiem UI, jest odpowiedzialny za wszystko, co dzieje się na ekranie. Wiedza o tym, jak to działa, może pomóc Ci zaprojektować aplikację tak, aby korzystała z wątku głównego, aby uzyskać jak największą wydajność.

Wewnętrzne

Wątek główny ma bardzo prostą konstrukcję: jego jedynym zadaniem jest przyjmowanie i wykonywanie bloków pracy z bezpiecznej dla wątku kolejki roboczej do momentu zakończenia działania aplikacji. Niektóre z tych bloków pracy są generowane w różnych miejscach. Należą do nich wywołania zwrotne powiązane z informacjami o cyklu życia, zdarzeniami użytkownika, takimi jak dane wejściowe, oraz zdarzeniami pochodzącymi z innych aplikacji i procesów. Ponadto aplikacja może samodzielnie dodawać do kolejki bloki, bez korzystania z platformy.

Prawie każdy blok kodu wykonywany przez aplikację jest powiązany z wywołaniem zwrotnym zdarzenia, np. dane wejściowe, inflacja układu lub rysowanie. Gdy coś wywoła zdarzenie, wątek, w którym miało ono miejsce, wypcha je z siebie samego do kolejki wiadomości w wątku głównym. Wątek główny może wówczas obsługiwać zdarzenie.

Podczas animacji lub aktualizacji ekranu system próbuje wykonać blok pracy (odpowiadający za rysowanie ekranu) co około 16 ms, aby renderować się płynnie z szybkością 60 klatek na sekundę. Aby system mógł osiągnąć ten cel, hierarchia UI/View musi być zaktualizowana w wątku głównym. Jeśli jednak kolejka wiadomości w wątku głównym zawiera zadania, które są zbyt liczne lub zbyt długie, aby wątek główny mógł ukończyć aktualizację dostatecznie szybko, aplikacja powinna przenieść tę pracę do wątku roboczego. Jeśli wątek główny nie może dokończyć wykonywania bloków pracy w ciągu 16 ms, użytkownik może zauważyć przerwy w działaniu, opóźnienia lub brak reakcji interfejsu na dane wejściowe. Jeśli wątek główny blokuje się na około 5 sekund, system wyświetla okno Aplikacja nie odpowiada (ANR), aby umożliwić użytkownikowi bezpośrednie zamknięcie aplikacji.

Przeniesienie wielu lub długich zadań z wątku głównego, by nie zakłócało płynnego renderowania i szybkiego reagowania na dane wejściowe użytkownika, to najważniejszy powód, dla którego warto wdrożyć w aplikacji wątki.

Odwołania do wątków i obiektów UI

Z założenia obiekty Android View nie są bezpieczne w wątkach. Aplikacja powinna tworzyć, używać i niszczyć obiekty UI – a wszystko to w wątku głównym. Jeśli spróbujesz zmodyfikować obiekt interfejsu lub nawet się do niego odwołasz w wątku innym niż wątek główny, efektem mogą być wyjątki, awarie, awarie i inne niezdefiniowane nieprawidłowe działanie.

Problemy z plikami referencyjnymi dzielą się na 2 różne kategorie: odniesienia jawne i pośrednie.

Wulgarne odniesienia

Wiele zadań w wątkach innych niż główne ma na celu aktualizację obiektów UI. Jeśli jednak jeden z wątków uzyskuje dostęp do obiektu w hierarchii widoków danych, może to skutkować niestabilnością aplikacji: jeśli wątek instancji roboczej zmieni właściwości tego obiektu w tym samym czasie, gdy dowolny inny wątek odwołuje się do obiektu, wyniki będą nieokreślone.

Weźmy na przykład aplikację, która zawiera bezpośrednie odniesienie do obiektu interfejsu w wątku instancji roboczej. Obiekt w wątku instancji roboczej może zawierać odniesienie do elementu View, ale przed zakończeniem pracy element View jest usuwany z hierarchii widoków. Gdy te 2 działania odbywają się jednocześnie, odwołanie zachowuje obiekt View w pamięci i ustawia dla niego właściwości. Użytkownik nigdy nie zobaczy tego obiektu, a aplikacja usunie obiekt, gdy odniesienie do niego zniknie.

W innym przykładzie obiekty View zawierają odwołania do działania, do którego należą. Jeśli ta aktywność zostanie zniszczona, ale pozostanie w wątku blok pracy, który się do niej odnosi – bezpośrednio lub pośrednio – kolektor śmieci nie pobierze działania, dopóki ten blok pracy nie zostanie wykonany.

Może to być przyczyną problemu, gdy praca z wątkami może działać w czasie, gdy występują pewne zdarzenia cyklu życia aktywności, takie jak obrót ekranu. System nie będzie w stanie przeprowadzić odśmiecania, dopóki nie dobiegnie końca. W związku z tym do czasu odśmiecania mogą być w pamięci 2 obiekty Activity.

W takich sytuacjach zalecamy, aby aplikacja nie zawierała wyraźnych odniesień do obiektów UI w wątkowych zadaniach służbowych. Unikanie takich odniesień pomaga uniknąć tego typu wycieków pamięci, a jednocześnie uniknąć rywalizacji o wątki.

We wszystkich przypadkach aplikacja powinna aktualizować obiekty interfejsu tylko w wątku głównym. Oznacza to, że należy opracować zasadę negocjacji, która umożliwia wielu wątkom komunikowanie pracy z powrotem do wątku głównego, co obejmuje działania najwyższego poziomu lub fragment z aktualizacją rzeczywistego obiektu UI.

Odwołania pośrednie

Typowy błąd w projekcie kodu dotyczący obiektów z wątkami można zobaczyć we fragmencie kodu poniżej:

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

Wadą tego fragmentu jest to, że kod deklaruje obiekt wątków wątków MyAsyncTask jako niestatyczną klasę wewnętrzną jakiegoś działania (lub klasę wewnętrzną w Kotlin). Ta deklaracja tworzy niejawne odwołanie do otaczającego ją wystąpienia Activity. W związku z tym obiekt zawiera odwołanie do aktywności, dopóki nie zakończy się praca związana z wątkami, co opóźni zniszczenie tej aktywności. To z kolei wywiera większą presję na pamięć.

Bezpośrednim rozwiązaniem tego problemu jest zdefiniowanie przeciążonych instancji klas jako klas statycznych lub we własnych plikach, co spowoduje usunięcie niejawnego odwołania.

Innym rozwiązaniem jest anulowanie i wyczyszczenie zadań w tle w odpowiednim wywołaniu zwrotnym cyklu życia Activity, np. onDestroy. Takie podejście może być jednak uciążliwe i podatne na błędy. Zgodnie z ogólną zasadą nie należy umieszczać bezpośrednio w działaniach złożonych logiki, która nie jest interfejsem użytkownika. Poza tym standard AsyncTask został wycofany i nie zalecamy używania go w nowym kodzie. Więcej informacji o dostępnych podstawowych elementach równoczesności znajdziesz w sekcji Wątki na urządzeniach z Androidem.

Cykle życia wątków i aplikacji

Cykl życia aplikacji może wpływać na sposób działania wątków w aplikacji. Być może okaże się, że wątek powinien lub nie powinien być utrzymywany po zniszczeniu aktywności. Musisz też pamiętać o zależności między priorytetem wątków a działaniem działania na pierwszym planie czy w tle.

Trwałe wątki

Wątki pozostają bez zmian przez cały okres aktywności, która je wywołała. Wątki będą nadal działać bez zakłóceń niezależnie od utworzenia lub zniszczenia aktywności, ale zostaną zakończone wraz z procesem aplikacji, gdy nie będzie już aktywnych komponentów aplikacji. W niektórych przypadkach taka trwałość jest pożądana.

Przeanalizujmy przypadek, w którym działanie generuje zestaw wątkowych bloków pracy, a następnie jest niszczone, zanim wątek instancji roboczej może wykonać bloki. Co aplikacja powinna zrobić z unoszącymi się blokami?

Jeśli blokady miałyby zaktualizować interfejs użytkownika, który już nie istnieje, nie ma powodu, aby kontynuować tę pracę. Jeśli np. celem jest wczytanie informacji o użytkowniku z bazy danych, a następnie zaktualizowanie widoków, ten wątek nie jest już potrzebny.

Z kolei pakiety robocze mogą mieć pewne korzyści, które nie są całkowicie związane z interfejsem użytkownika. W takim przypadku warto utrwalić wątek. Pakiety mogą na przykład czekać na pobranie obrazu, buforować go na dysku i zaktualizować powiązany obiekt View. Chociaż obiekt już nie istnieje, pobieranie i zapisywanie obrazu w pamięci podręcznej może być nadal pomocne na wypadek, gdyby użytkownik wrócił do zniszczonej aktywności.

Ręczne zarządzanie odpowiedziami cyklu życia w przypadku wszystkich obiektów z wątkami może być bardzo złożone. Jeśli nie będziesz nimi zarządzać, Twoja aplikacja może mieć problemy z pamięcią i wydajnością. Połączenie parametrów ViewModel i LiveData pozwala wczytywać dane i otrzymywać powiadomienia o zmianach bez obaw o cykl życia. Jednym z rozwiązań tego problemu są obiekty ViewModel. Modele ViewModel są zachowywane niezależnie od zmian konfiguracji, co ułatwia przechowywanie danych widoku. Więcej informacji o obiektach ViewModel znajdziesz w przewodniku po modelu ViewModel. Więcej informacji o LiveData znajdziesz w przewodniku po LiveData. Więcej informacji o architekturze aplikacji znajdziesz w przewodniku po architekturze aplikacji.

Priorytet wątku

Jak opisano w sekcji Procesy i cykl życia aplikacji, priorytet otrzymywany przez wątki aplikacji zależy częściowo od jej lokalizacji w jej cyklu życia. Tworząc wątki i zarządzając nimi w aplikacji, musisz ustawić ich priorytety, aby właściwe wątki przyjmowały odpowiednie priorytety we właściwym czasie. Jeśli ustawisz zbyt wysoką wartość, wątek może przerwać wątek UI i RenderThread, co spowoduje, że aplikacja będzie pomijać klatki. Jeśli ustawisz zbyt niską wartość, możesz spowolnić wykonywanie zadań asynchronicznych (np. wczytywanie obrazów).

Za każdym razem, gdy tworzysz wątek, musisz wywołać metodę setThreadPriority(). Harmonogram wątków systemu preferuje wątki o wysokich priorytetach, zapewniając równowagę między tymi priorytetami a koniecznością zrealizowania wszystkich zadań w końcu. Wątki z grupy na pierwszym planie zajmują około 95% łącznego czasu wykonywania na urządzeniu, a w grupie w tle – około 5%.

System przypisuje też każdemu wątkowi własną wartość priorytetu za pomocą klasy Process.

Domyślnie system ustawia dla wątku taki sam priorytet i członkostwo w grupie co wątek otwierający. Aplikacja może jednak wyraźnie dostosować priorytet wątku za pomocą polecenia setThreadPriority().

Klasa Process pomaga uprościć przypisywanie wartości priorytetów, ponieważ udostępnia zestaw stałych, których aplikacja może używać do ustalania priorytetów wątków. Na przykład THREAD_PRIORITY_DEFAULT reprezentuje wartość domyślną wątku. Aplikacja powinna ustawić priorytet wątku na THREAD_PRIORITY_BACKGROUND w przypadku wątków wykonujących mniej pilne zadanie.

Aplikacja może używać stałych THREAD_PRIORITY_LESS_FAVORABLE i THREAD_PRIORITY_MORE_FAVORABLE jako przyrostków do ustawiania względnych priorytetów. Listę priorytetów wątków znajdziesz w stałych THREAD_PRIORITY w klasie Process.

Więcej informacji o zarządzaniu wątkami znajdziesz w dokumentacji klas Thread i Process.

Klasy pomocnicze wątków

Programistom, których głównym językiem jest Kotlin, zalecamy korzystanie z kotlin. Korutyny zapewniają wiele korzyści, m.in. pisanie kodu asynchronicznego bez wywołań zwrotnych oraz ustrukturyzowaną równoczesność do określania zakresu, anulowania i obsługi błędów.

Platforma udostępnia też te same klasy i obiekty podstawowe Java, które ułatwiają powstawanie wątków, takie jak klasy Thread, Runnable i Executors, a także dodatkowe, takie jak HandlerThread. Więcej informacji znajdziesz w artykule Threading na Androidzie.

Klasa HandlerThread

Wątek modułu obsługi to tak naprawdę długi wątek, który pozyskuje pracę z kolejki i na niej wykonuje działanie.

Typowym wyzwaniem jest pobieranie ramek podglądu z obiektu Camera. Gdy zarejestrujesz ramki podglądu kamery, otrzymasz je w wywołaniu zwrotnym onPreviewFrame(), które jest wywoływane w wątku zdarzenia, z którego zostało wywołane. Gdyby to wywołanie zwrotne zostało wywołane w wątku interfejsu, zadanie radzenia sobie z ogromnymi tablicami pikselowymi zakłóciłoby pracę z renderowaniem i przetwarzaniem zdarzeń.

W tym przykładzie, gdy aplikacja deleguje polecenie Camera.open() do bloku zadań w wątku modułu obsługi, powiązane wywołanie zwrotne onPreviewFrame() trafia do wątku modułu obsługi, a nie do wątku interfejsu użytkownika. Jeśli więc zamierzasz długo pracować nad pikselami, może to być dla Ciebie lepsze rozwiązanie.

Gdy aplikacja tworzy wątek za pomocą funkcji HandlerThread, nie zapomnij ustawić priorytetu wątku na podstawie typu wykonywanej pracy. Pamiętaj, że procesory obsługują tylko niewielką liczbę wątków równolegle. Ustawienie priorytetu informuje system o właściwych sposobach planowania pracy, gdy wszystkie inne wątki walczą o uwagę.

Klasa ThreadPoolExecutor

Niektóre rodzaje pracy można ograniczyć do bardzo równoległych, rozproszonych zadań. Jednym z takich zadań jest obliczenie filtra dla każdego bloku 8 x 8 z obrazu o wymiarach 8 megapikseli. Przy dużej liczbie pakietów roboczych sprawia to, że HandlerThread nie jest odpowiednią klasą do użycia.

ThreadPoolExecutor to klasa pomocnicza, która ułatwia ten proces. Ta klasa zarządza tworzeniem grupy wątków, określa ich priorytety i zarządza sposobem podziału pracy między te wątki. W miarę jak ilość pracy rośnie lub maleje, klasa wiruje lub niszczy kolejne wątki, aby dostosować się do tego zadania.

Ta klasa pomaga również aplikacji wygenerować optymalną liczbę wątków. Podczas tworzenia obiektu ThreadPoolExecutor aplikacja ustawia minimalną i maksymalną liczbę wątków. W miarę jak zwiększa się obciążenie zadania ThreadPoolExecutor, klasa uwzględnia zainicjowaną minimalną i maksymalną liczbę wątków na koncie i uwzględnia ilość oczekujących zadań do wykonania. Na podstawie tych czynników ThreadPoolExecutor określa, ile wątków powinno pozostać aktywnych w danym momencie.

Ile wątków musisz utworzyć?

Choć z poziomu oprogramowania Twój kod może tworzyć setki wątków, może to powodować problemy z wydajnością. Twoja aplikacja udostępnia ograniczone zasoby procesora usługom w tle, mechanizmowi renderowania, silnikowi audio, sieci i innym funkcjom. Procesory tak naprawdę są w stanie obsługiwać niewielką liczbę wątków równolegle. Wszystko, co znajduje się powyżej, prowadzi do problemu z priorytetem i harmonogramem. Dlatego ważne jest, aby utworzyć tyle wątków, ile potrzebuje Twoje zadanie.

W praktyce za to odpowiada wiele zmiennych, ale wybranie wartości (np. 4 na początek) i przetestowanie jej w Systrace to strategia jak z innej platformy. Metodą prób i błędów możesz wykryć minimalną liczbę wątków, jaką można wykorzystać bez napotykania problemów.

Przy podejmowaniu decyzji o liczbie wątków weź pod uwagę to, że nie są one bezpłatne, zajmują pamięć. Każdy wątek kosztuje co najmniej 64 tys. pamięci. Liczba ta szybko rośnie wśród wielu aplikacji zainstalowanych na urządzeniu, zwłaszcza w sytuacjach, gdy stosy wywołań znacznie wzrastają.

Wiele procesów systemowych i bibliotek zewnętrznych często tworzy własne pule wątków. Jeśli Twoja aplikacja może ponownie wykorzystywać istniejącą pulę wątków, może to poprawić wydajność, ograniczając rywalizację o pamięć i zasoby przetwarzania.