Bessere Leistung durch Threading

Durch den geschickten Einsatz von Threads unter Android kannst du die Leistung deiner App steigern. Auf dieser Seite werden verschiedene Aspekte der Arbeit mit Threads erläutert: das Arbeiten mit der UI oder Hauptthread, die Beziehung zwischen Anwendungslebenszyklus und Thread-Priorität sowie Methoden, die die Plattform zur Verwaltung der Thread-Komplexität bietet. In jedem dieser Bereiche werden auf dieser Seite potenzielle Fallstricke und Strategien zu deren Vermeidung beschrieben.

Hauptthread

Wenn der Nutzer Ihre App startet, erstellt Android einen neuen Linux-Prozess zusammen mit einem Ausführungsthread. Dieser Hauptthread, auch UI-Thread genannt, ist für alles, was auf dem Bildschirm passiert, verantwortlich. Wenn Sie die Funktionsweise verstehen, können Sie Ihre Anwendung so gestalten, dass für die bestmögliche Leistung der Hauptthread verwendet wird.

Intern

Der Hauptthread hat ein sehr einfaches Design: Er muss Arbeitsblöcke aus einer Thread-sicheren Arbeitswarteschlange annehmen und ausführen, bis die zugehörige Anwendung beendet wird. Das Framework generiert einige dieser Arbeitsblöcke von verschiedenen Stellen aus. Dazu gehören Callbacks, die mit Lebenszyklusinformationen verknüpft sind, Nutzerereignisse wie Eingaben oder Ereignisse, die aus anderen Apps und Prozessen stammen. Außerdem kann die Anwendung Blöcke explizit selbst in die Warteschlange stellen, ohne das Framework zu verwenden.

Fast jeder Codeblock, den Ihre App ausführt, ist mit einem Ereignis-Callback verknüpft, z. B. Input, Layout-Inflation oder Zeichnen. Wenn ein Ereignis ein Ereignis auslöst, wird es durch den Thread, in dem das Ereignis aufgetreten ist, automatisch in die Nachrichtenwarteschlange des Hauptthreads verschoben. Der Hauptthread kann das Ereignis dann bereitstellen.

Während eine Animation oder eine Bildschirmaktualisierung durchgeführt wird, versucht das System, etwa alle 16 ms einen Arbeitsblock auszuführen, der für das Zeichnen des Bildschirms verantwortlich ist, damit ein reibungsloses Rendering mit 60 Bildern pro Sekunde möglich ist. Damit das System dieses Ziel erreichen kann, muss die UI-/Ansichtshierarchie im Hauptthread aktualisiert werden. Wenn die Nachrichtenwarteschlange des Hauptthreads jedoch Aufgaben enthält, die entweder zu viele oder so lang sind, dass der Hauptthread die Aktualisierung schnell genug abschließen kann, sollte die Anwendung diese Arbeit in einen Worker-Thread verschieben. Wenn der Hauptthread die Ausführung von Arbeitsblöcken nicht innerhalb von 16 ms abschließen kann, kann es sein, dass der Nutzer ein hängt, verzögert reagiert oder die UI nicht schnell genug reagiert. Wenn der Hauptthread etwa fünf Sekunden lang blockiert wird, zeigt das System das Dialogfeld App antwortet nicht (ANR) an, über das der Nutzer die Anwendung direkt schließen kann.

Der Hauptgrund für die Einführung von Threading in Ihrer Anwendung ist es, zahlreiche oder lange Aufgaben aus dem Hauptthread zu verschieben, damit ein reibungsloses Rendering und eine schnelle Reaktion auf Nutzereingaben nicht beeinträchtigt werden.

Threads und UI-Objektverweise

Android View-Objekte sind standardmäßig nicht Thread-sicher. Von einer Anwendung wird erwartet, dass sie UI-Objekte erstellt, verwendet und löscht, und zwar alles im Hauptthread. Wenn Sie versuchen, ein UI-Objekt in einem anderen Thread als dem Hauptthread zu ändern oder sogar darauf zu verweisen, kann dies zu Ausnahmen, stillen Fehlern, Abstürzen und anderen undefinierten Fehlverhalten führen.

Probleme mit Referenzen fallen in zwei verschiedene Kategorien: explizite Referenzen und implizite Referenzen.

Explizite Verweise

Viele Aufgaben in anderen Threads haben das Ziel, UI-Objekte zu aktualisieren. Wenn jedoch einer dieser Threads auf ein Objekt in der Ansichtshierarchie zugreift, kann dies zu Anwendungsinstabilität führen. Wenn ein Worker-Thread die Attribute dieses Objekts ändert, während ein anderer Thread auf das Objekt verweist, sind die Ergebnisse nicht definiert.

Angenommen, eine Anwendung enthält einen direkten Verweis auf ein UI-Objekt in einem Worker-Thread. Das Objekt im Worker-Thread kann einen Verweis auf ein View enthalten. Bevor die Arbeit jedoch abgeschlossen ist, wird das View aus der Ansichtshierarchie entfernt. Wenn diese beiden Aktionen gleichzeitig ausgeführt werden, bleibt das View-Objekt durch den Verweis im Arbeitsspeicher und es werden Eigenschaften dafür festgelegt. Der Nutzer sieht dieses Objekt jedoch nie. Die App löscht das Objekt, sobald der Verweis darauf entfernt ist.

In einem anderen Beispiel enthalten View-Objekte Verweise auf die Aktivität, deren Inhaber sie sind. Wenn diese Aktivität gelöscht wird, aber ein Arbeitsblock mit Threads verbleibt, der auf sie direkt oder indirekt verweist, erfasst die automatische Speicherbereinigung die Aktivität erst, wenn dieser Arbeitsblock ausgeführt wurde.

Dieses Szenario kann in Situationen zu Problemen führen, in denen Arbeit mit Threads noch ausgeführt wird, während ein Ereignis im Aktivitätslebenszyklus stattfindet, z. B. eine Bildschirmdrehung. Das System kann erst dann eine automatische Speicherbereinigung durchführen, wenn die laufende Arbeit abgeschlossen ist. Daher befinden sich möglicherweise zwei Activity-Objekte im Arbeitsspeicher, bis die automatische Speicherbereinigung ausgeführt werden kann.

Bei Szenarien wie diesen sollte Ihre App keine expliziten Verweise auf UI-Objekte in Aufgaben mit Threads enthalten. Wenn Sie solche Verweise vermeiden, können Sie diese Art von Speicherlecks vermeiden und Threading-Konflikte vermeiden.

In allen Fällen sollte Ihre Anwendung nur UI-Objekte im Hauptthread aktualisieren. Das bedeutet, dass Sie eine Verhandlungsrichtlinie erstellen sollten, die es mehreren Threads ermöglicht, Arbeit zurück an den Hauptthread zu kommunizieren. Dieser wiederum verwaltet die höchste Aktivität oder das höchste Fragment mit der Arbeit, das eigentliche UI-Objekt zu aktualisieren.

Implizite Verweise

Ein häufiger Fehler im Codedesign bei Objekten mit Threads ist im folgenden Code-Snippet zu sehen:

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) {...}
  }
}

Der Fehler in diesem Snippet ist, dass der Code das Threading-Objekt MyAsyncTask als nicht statische innere Klasse einer Aktivität (oder als innere Klasse in Kotlin) deklariert. Diese Deklaration erstellt einen impliziten Verweis auf die einschließende Activity-Instanz. Daher enthält das Objekt einen Verweis auf die Aktivität, bis die Thread-Arbeit abgeschlossen ist, was zu einer Verzögerung beim Löschen der referenzierten Aktivität führt. Diese Verzögerung setzt wiederum mehr Druck auf den Arbeitsspeicher aus.

Eine direkte Lösung für dieses Problem besteht darin, die überlasteten Klasseninstanzen entweder als statische Klassen oder in ihren eigenen Dateien zu definieren und so den impliziten Verweis zu entfernen.

Eine andere Lösung wäre, Hintergrundaufgaben immer im entsprechenden Activity-Lebenszyklus-Callback wie onDestroy abzubrechen und zu bereinigen. Dieser Ansatz kann jedoch mühsam und fehleranfällig sein. Im Allgemeinen sollten Sie komplexe Nicht-UI-Logik nicht direkt in Aktivitäten einsetzen. Außerdem wurde AsyncTask inzwischen verworfen und wird nicht mehr zur Verwendung in neuem Code empfohlen. Weitere Informationen zu den Ihnen zur Verfügung stehenden Gleichzeitigkeitsprimitiven finden Sie unter Threading unter Android.

Threads und Lebenszyklen von App-Aktivitäten

Der Anwendungslebenszyklus kann sich darauf auswirken, wie Threading in Ihrer Anwendung funktioniert. Möglicherweise müssen Sie entscheiden, dass ein Thread nach dem Löschen einer Aktivität erhalten bleiben soll oder nicht. Außerdem sollten Sie die Beziehung zwischen der Thread-Priorisierung kennen und wissen, ob eine Aktivität im Vordergrund oder im Hintergrund ausgeführt wird.

Persistente Threads

Threads bleiben über die Lebensdauer der Aktivitäten bestehen, die sie hervorgerufen haben. Threads werden unabhängig von der Erstellung oder dem Löschen von Aktivitäten weiterhin unterbrechungsfrei ausgeführt. Sie werden jedoch zusammen mit dem Anwendungsprozess beendet, sobald keine aktiven Anwendungskomponenten mehr vorhanden sind. In einigen Fällen ist diese Persistenz wünschenswert.

Angenommen, eine Aktivität erzeugt eine Reihe von Arbeitsblöcken mit Threads und wird dann gelöscht, bevor ein Worker-Thread die Blöcke ausführen kann. Was soll die App mit den aktiven Blöcken machen?

Wenn durch die Blöcke eine nicht mehr vorhandene Benutzeroberfläche aktualisiert werden sollte, gibt es keinen Grund, die Arbeit fortzusetzen. Wenn beispielsweise Nutzerinformationen aus einer Datenbank geladen und dann Ansichten aktualisiert werden, ist der Thread nicht mehr erforderlich.

Im Gegensatz dazu können die Arbeitspakete einige Vorteile haben, die nicht vollständig mit der Benutzeroberfläche zusammenhängen. In diesem Fall sollten Sie den Thread dauerhaft speichern. Beispiel: Die Pakete warten darauf, ein Image herunterzuladen, im Cache zu speichern und das zugehörige View-Objekt zu aktualisieren. Obwohl das Objekt nicht mehr vorhanden ist, können das Herunterladen und Speichern des Bildes weiterhin hilfreich sein, falls der Nutzer zur gelöschten Aktivität zurückkehrt.

Die manuelle Verwaltung von Lebenszyklusantworten für alle Threading-Objekte kann sehr komplex werden. Wenn sie nicht ordnungsgemäß verwaltet werden, kann es bei Ihrer Anwendung zu Speicherkonflikten und Leistungsproblemen kommen. Wenn Sie ViewModel mit LiveData kombinieren, können Sie Daten laden und sich bei Änderungen benachrichtigen lassen, ohne sich um den Lebenszyklus kümmern zu müssen. Eine Lösung für dieses Problem sind ViewModel-Objekte. ViewModels werden über Konfigurationsänderungen hinweg verwaltet und bieten eine einfache Möglichkeit, Ihre Ansichtsdaten zu speichern. Weitere Informationen zu ViewModels finden Sie im Leitfaden zu ViewModel. Weitere Informationen zu LiveData finden Sie im LiveData-Leitfaden. Weitere Informationen zur Anwendungsarchitektur finden Sie im Leitfaden zur Anwendungsarchitektur.

Thread-Priorität

Wie unter Prozesse und Anwendungslebenszyklus beschrieben, hängt die Priorität, die die Threads Ihrer Anwendung erhalten, teilweise davon ab, wo sich die Anwendung im Anwendungslebenszyklus befindet. Beim Erstellen und Verwalten von Threads in Ihrer Anwendung ist es wichtig, deren Priorität festzulegen, damit die richtigen Threads zur richtigen Zeit die richtigen Prioritäten erhalten. Bei zu hoher Einstellung unterbricht der Thread möglicherweise den UI-Thread und RenderThread, wodurch Frames in Ihrer App verworfen werden. Wenn der Wert zu niedrig ist, können Ihre asynchronen Aufgaben (z. B. das Laden von Bildern) langsamer als nötig ablaufen.

Jedes Mal, wenn Sie einen Thread erstellen, sollten Sie setThreadPriority() aufrufen. Der Thread-Planer des Systems bevorzugt Threads mit hohen Prioritäten und sorgt für ein Gleichgewicht zwischen diesen Prioritäten und der Notwendigkeit, letztendlich die gesamte Arbeit zu erledigen. Im Allgemeinen erhalten Threads in der Vordergrundgruppe etwa 95% der gesamten Ausführungszeit vom Gerät, während die Hintergrundgruppe etwa 5 % erhält.

Außerdem weist das System jedem Thread mithilfe der Klasse Process einen eigenen Prioritätswert zu.

Standardmäßig legt das System die Priorität eines Threads auf die gleiche Priorität und die gleichen Gruppenmitgliedschaften wie der erzeugende Thread fest. Ihre Anwendung kann jedoch die Thread-Priorität mithilfe von setThreadPriority() explizit anpassen.

Mit der Klasse Process wird die Komplexität beim Zuweisen von Prioritätswerten verringert, indem eine Reihe von Konstanten bereitgestellt wird, mit denen Ihre Anwendung Thread-Prioritäten festlegen kann. Beispielsweise stellt THREAD_PRIORITY_DEFAULT den Standardwert für einen Thread dar. Die Anwendung sollte die Priorität des Threads für Threads, die weniger dringende Aufgaben ausführen, auf THREAD_PRIORITY_BACKGROUND setzen.

Ihre Anwendung kann die Konstanten THREAD_PRIORITY_LESS_FAVORABLE und THREAD_PRIORITY_MORE_FAVORABLE als Inkrementeller verwenden, um relative Prioritäten festzulegen. Eine Liste der Thread-Prioritäten finden Sie in den THREAD_PRIORITY-Konstanten in der Process-Klasse.

Weitere Informationen zum Verwalten von Threads finden Sie in der Referenzdokumentation zu den Klassen Thread und Process.

Hilfsklassen für Threading

Entwicklern, die Kotlin als Hauptsprache verwenden, empfehlen wir die Verwendung von Coroutinen. Koroutinen bieten eine Reihe von Vorteilen, darunter das Schreiben von asynchronem Code ohne Callbacks sowie strukturierte Nebenläufigkeit für den Umfang, den Abbruch und die Fehlerbehandlung.

Das Framework bietet auch die gleichen Java-Klassen und Primitive, um das Threading zu erleichtern, z. B. die Klassen Thread, Runnable und Executors sowie zusätzliche Klassen wie HandlerThread. Weitere Informationen finden Sie unter Threading unter Android.

Die HandlerThread-Klasse

Ein Handler-Thread ist im Grunde ein lang andauernder Thread, der Aufgaben aus einer Warteschlange abruft und in dieser verarbeitet.

Sehen Sie sich eine häufige Herausforderung beim Abrufen von Vorschauframes aus dem Camera-Objekt an. Wenn Sie sich für Kameravorschauframes registrieren, erhalten Sie diese im onPreviewFrame()-Callback, der im Ereignisthread aufgerufen wird, von dem aus er aufgerufen wurde. Würde dieser Callback im UI-Thread aufgerufen, würde die Verarbeitung der riesigen Pixelarrays das Rendering und die Ereignisverarbeitung beeinträchtigen.

Wenn die Anwendung in diesem Beispiel den Befehl Camera.open() an einen Arbeitsblock des Handler-Threads delegiert, erreicht der zugehörige onPreviewFrame()-Callback den Handler-Thread und nicht den UI-Thread. Wenn Sie also länger an den Pixeln arbeiten, ist dies möglicherweise die bessere Lösung für Sie.

Wenn Ihre Anwendung einen Thread mit HandlerThread erstellt, vergessen Sie nicht, die Priorität des Threads anhand der Art der ausgeführten Arbeit festzulegen. Denken Sie daran, dass CPUs nur eine kleine Anzahl von Threads parallel verarbeiten können. Wenn Sie die Priorität festlegen, kann das System diese Arbeit richtig planen, wenn alle anderen Threads um Aufmerksamkeit kämpfen.

Die ThreadPoolExecutor-Klasse

Es gibt bestimmte Arten von Arbeit, die auf hoch parallele, verteilte Aufgaben reduziert werden können. Eine dieser Aufgaben besteht beispielsweise darin, einen Filter für jeden 8x8-Block eines 8-Megapixel-Bilds zu berechnen. Aufgrund der enormen Menge an Arbeitspaketen, die dabei entstehen, ist HandlerThread nicht die geeignete Klasse für die Verwendung.

ThreadPoolExecutor ist eine Hilfsklasse, die diesen Prozess vereinfachen soll. Diese Klasse verwaltet die Erstellung einer Gruppe von Threads, legt ihre Prioritäten fest und verwaltet die Verteilung der Arbeit auf diese Threads. Bei steigender oder abnehmender Arbeitslast werden mehr Threads gestartet oder zerstört, um sich an die Arbeitslast anzupassen.

Diese Klasse hilft Ihrer App auch dabei, eine optimale Anzahl von Threads zu erzeugen. Beim Erstellen eines ThreadPoolExecutor-Objekts legt die Anwendung eine minimale und eine maximale Anzahl von Threads fest. Wenn die dem ThreadPoolExecutor zugewiesene Arbeitslast zunimmt, berücksichtigt die Klasse die initialisierte Mindest- und Höchstanzahl der Threads und berücksichtigt die Menge der ausstehenden Arbeit, die noch zu erledigen ist. Basierend auf diesen Faktoren entscheidet ThreadPoolExecutor darüber, wie viele Threads zu einem bestimmten Zeitpunkt aktiv sein sollen.

Wie viele Threads sollten Sie erstellen?

Auf Softwareebene kann Ihr Code zwar Hunderte von Threads erstellen, dies kann jedoch zu Leistungsproblemen führen. Ihre App teilt begrenzte CPU-Ressourcen mit Hintergrunddiensten, dem Renderer, der Audio-Engine, dem Netzwerk und mehr. CPUs können wirklich nur eine kleine Anzahl von Threads parallel verarbeiten. Alles darüber, was zu einem Prioritäts- und Planungsproblem führt. Daher ist es wichtig, nur so viele Threads zu erstellen, wie Ihre Arbeitslast benötigt.

In der Praxis spielen dafür eine Reihe von Variablen eine Rolle, aber die Auswahl eines Werts (z. B. 4 für den Anfang) und das Testen mit Systrace ist genauso gut wie jede andere Strategie. Sie können durch Ausprobieren die Mindestanzahl von Threads ermitteln, die Sie ohne Probleme verwenden können.

Ein weiterer Aspekt bei der Entscheidung über die Anzahl der Threads ist, dass sie nicht kostenlos sind: Sie verbrauchen Arbeitsspeicher. Jeder Thread kostet mindestens 64.000 Arbeitsspeicher. Dies summiert sich schnell zu den vielen auf einem Gerät installierten Apps, insbesondere in Situationen, in denen die Aufrufstacks erheblich wachsen.

Viele Systemprozesse und Bibliotheken von Drittanbietern erstellen oft eigene Threadpools. Wenn Ihre Anwendung einen vorhandenen Threadpool wiederverwenden kann, kann diese Wiederverwendung zur Leistungssteigerung beitragen, da Konflikte bei Arbeitsspeicher- und Verarbeitungsressourcen reduziert werden.