Parallelisierung im Mobile-Bereich

Reactive Android

Tam Hanna
©Shutterstock/Kirill__M

In der Anfangszeit der Informatik war das Leben des Programmierers bequem: Jede neue Generation der zugrunde liegenden Hardware brachte eine wesentliche Performancesteigerung. Die immer effizienter werdenden Prozessorkerne führten die Routinen immer schneller aus, ohne dass von Seiten des Codierers Hilfe notwendig war. Leider ist die Fertigung von Halbleitern ein von diversen Naturgesetzen eingeschränkter Prozess.

Mittlerweile sind die Strukturen im Desktopbereich so klein, dass schnellere Taktraten nur noch durch enorme Steigerung des Energieverbrauchs realisierbar sind. Chiphersteller reagieren auf diese geänderte Situation durch das Anbieten von Prozessoren, die immer mehr Kerne auf einmal mitbringen. Die Nutzung der dadurch gebotenen Mehrleistung ist leider alles andere als einfach, da sie auf Seiten der Informatiker ein Umdenken voraussetzt. Sega machte diese Erfahrung mit der Saturn: Die enorm leistungsfähige Konsole wurde nur von wenigen Entwicklern voll ausgelastet.

Reaktivität durch Parallelisierung

Im Mobile-Bereich war die Parallelisierung schon lange vor dem Aufkommen der ersten Mehrkernprozessoren ein brisantes Thema: Anders als am Desktop kommt es beim Handy aufgrund der viel kürzeren Sitzungen auf Reaktivität an. Das ist in mehrerlei Hinsicht kritisch: Erstens sind Handys an sich für schnelle Reaktionen nicht sonderlich geeignet. Das liegt unter anderem an der drahtlosen Funkverbindung, die bei umfangreicheren Netzwerkoperationen zu starken Latenzen führt.

Mobilbetriebssysteme realisieren ihre Benutzerschnittstelle meist in einem einzelnen, als GUI-Thread bezeichneten, Prozess. Er ist unter anderem für die Ausführung des Event Handlings zuständig: Die diversen Ereignisverarbeitungsroutinen laufen allesamt in ihm ab.

Wenn Ihr Programm nun in Reaktion auf einen Knopfdruck eine Netzwerkoperation beginnt, so bleibt im schlimmsten Fall der ganze Handheld stehen. Das ist aus Sicht der User höchst ärgerlich und wird mit schlechten Bewertungen im App Store quittiert.

Flucht aus dem GUI-Thread

Google animiert seine Entwickler durch den ANR-Dialog zum Erstellen von reaktiven Anwendungen. Er erscheint, wenn ein Programm auf einen Eingabebefehl fünf Sekunden lang nicht reagiert. Leider führt nicht jede Blocksituation zu einem ANR. In Listing 1 sehen Sie ein Code-Snippet, das ein Android-Smartphone lahmlegt – ein ANR-Dialog erscheint indes erst dann, wenn Sie auf einen anderen Button klicken.

public void onClick(View arg0) 
{
  if(arg0==myAnrButton)
  {
    int i=0;
    while(1==1)
    {
      i++;
      myView.setText(String.valueOf(i));
    }
  }
  
}

Dieses Programm verhält sich geradezu primitiv. Wird der Button angeklickt, so startet eine Endlosschleife. Diese verbrennt so lange Rechenleistung, bis der in Abbildung 1 gezeigte Dialog erscheint.

Besonders ärgerlich ist, dass der Nutzer die aktualisierten Informationen nie zu Gesicht bekommt. Der Button erscheint nach wie vor markiert, und die TextView aktualisiert sich nicht – wie sollte sie das auch tun, wenn sie keine Rechenleistung bekommt?

Abb. 1: Der ANR-Dialog erlaubt dem Benutzer das sofortige Beenden des Programms

Beide Probleme lassen sich umgehen, indem Sie den Rechenprozess auf einen im Hintergrund rackernden Thread auslagern. In einer idealen Welt müssten Sie dazu nur den Code auslagern und mit einem Pointer auf das Formular versehen – der Rest ist die alleinige Aufgabe des Betriebssystems.

Leider ist das Erstellen eines threadsicheren GUI-Stacks alles andere als einfach. Aus diesem Grund umgehen die Betriebssystemhersteller dieses Problem nur allzu gern, indem sie einen GUI-Stack anbieten, der beim Aufruf einer Methode nur prüft, von wo aus die Invokation erfolgt. Ist der Aufrufer im falschen Thread beheimatet, so folgt eine Exception.

Um im Hintergrund arbeitenden Threads die Möglichkeit zur Aktualisierung des GUI zu geben, stellen die Entwickler zudem eine Spezialfunktion bereit. Diese nimmt ein Runnable entgegen, das danach am GUI-Thread ausgeführt wird.

Mit diesen beiden Werkzeugen können Sie Ihre Applikation ohne Weiteres parallelisieren. Leider artet das in Arbeit aus, die von Drittanbietern gern vermieden wird. Zur Erleichterung offeriert Google den Background Worker. Dabei handelt es sich um eine Klasse, die den Gutteil der dazu notwendigen Logik mitbringt. Die Minimalversion der Klasse sieht wie in Listing 2 aus.

import android.os.AsyncTask;
public class MyAsyncTask extends AsyncTask<Params, Progress, Result> 
{
  protected Result doInBackground(Params... arg0) 
  {
    // TODO Auto-generated method stub
    return null;
  }
  
  protected void onPostExecute(Result arg0) 
  {
    // TODO Auto-generated method stub
  }
}

AsyncTask ist eine Templateklasse, die erst durch Vererbung ansprechbar wird. Die drei Parameter legen die Typen der an die Methoden übergebenen Werte fest. Params dient als Typ des Parameterarrays für die Arbeitsmethode doInBackground. Diese arbeitet die Jobs im Hintergrund ab und läuft nicht im GUI-Thread ab. Der zweite Parameter ist erst im nächsten Abschnitt von Bedeutung.

Nach dem erfolgreichen Abarbeiten von doInBackground folgt ein Aufruf von onPostExecute. Dieser erwartet als Parameter ein Objekt vom Typ Result, das am Ende von doInBackground zurückgegeben werden muss. OnPostExecute wird immer im GUI-Thread ausgeführt, darf also direkt auf die diversen Steuerelemente und Widgets zugreifen.

Aufmacherbild: Android holding a glowing earth globe von Shutterstock / Urheberrecht: Kirill__M

[ header = Seite 2: Das eigentliche Implementieren ]

Das eigentliche Implementieren

Die Verwendung der resultierenden Klasse ist vergleichsweise einfach. Im GUI-Thread erstellen Sie eine neue Instanz der Klasse. Nach dem erfolgreichen Abarbeiten des Konstruktors setzen Sie eventuelle globale Member und rufen danach die execute-Methode auf. Ab diesem Zeitpunkt macht sich der AsyncTask selbsttätig an die Arbeit – die erfolgreiche Abarbeitung wird durch das Aufrufen der Methode onPostExecute angezeigt.

Mehr AsyncTask!

Beim Erstellen eines im Hintergrund ablaufenden Vorgangs ist es oft wünschenswert, den Nutzer über den Fortschritt der Operation zu informieren. AsyncTask bietet dafür eine eigene Methode an, die sich aus dem Arbeitsthread heraus aufrufen lässt. Als praktisches Beispiel dafür wollen wir einen AsyncTask erstellen, der im Hintergrund nutzlos herumrechnet und den Benutzer über eine TextView über seinen Zustand informiert. Sein Korpus sieht so aus:

public class MyAsyncTask extends AsyncTask<Integer, Float, String> 
{
  public TextView myView;

Von AsyncTask abgeleitete Klassen brauchen normalerweise keinen Konstruktor. Wenn Sie nur einen oder zwei Parameter übergeben möchte, so genügt es, diese als globale Variablen auszuführen – in unserem Fall reichen wir eine Referenz auf die in der Activity befindliche TextView in die Klasse. Die eigentliche Rechenarbeit erfolgt abermals in doInBackground (Listing 3).

@Override
protected String doInBackground(Integer... arg0) 
{
  for(int i=0;i<10000;i++)
  {
    if(i%100==0)
    {
      publishProgress((float)i/10000);
      
    }
    try {Thread.sleep(1);}
    catch (InterruptedException e) {}
  }
  return "Arbeit erledigt";
}


Im Vergleich zum Pseudocodebeispiel sticht hier die Verwendung der Methode publishProgress heraus. Sie erlaubt Ihnen, das Benutzerinterface über Fortschritte in der Abarbeitung des AsyncTasks zu informieren. Nach dem erfolgreichen Abrackern der Schleife wird ein Wert zurückgegeben, der von onPostExecute in die View geschrieben wird.

OnProgressUpdate wird immer dann aufgerufen, wenn publishProgress zur Weiterreichung von Statusinformationen angewiesen wird. In unserem Beispiel beschränkt sich die Funktion darauf, die anfallenden Daten 1:1 weiterzuschreiben:

protected void onProgressUpdate(Float... values) 
{
  myView.setText(values[0].toString());
}


Komplexere AsyncTasks verarbeiten oft Gruppen von Aufträgen. In diesem Fall dürfen Sie mehrere Parameter übergeben – ideal ist es, für jedes Auftragsobjekt ein Fortschrittsobjekt zu retournieren.

Beachten Sie, dass Fortschrittsberichte ebenfalls Rechenleistung in Anspruch nehmen. Wenn Sie den Benutzer zu oft informieren, verlängert sich die Gesamtrechnungsdauer. Das Verwenden eines Zählers im Zusammenspiel mit dem Modulo-Operator ist eine häufig angewandte Methode zur „Verwaltung“ der Aktualisierungshäufigkeit.

Abarbeitung mehrerer Anfragen?

Entwickler verwenden AsyncTasks normalerweise zur Auslagerung von Netzwerkzugriffen oder IO-Operationen. Zur Vereinfachung der Implementierung ließ Google in der Anfangszeit von Android alle AsyncTasks in einem Thread ablaufen – die Anfragen wurden nacheinander abgearbeitet.

Android-Versionen von 1.6 bis ausschließlich 3.0 nutzen stattdessen mehrere Threads, wodurch die sequenzielle Abarbeitung der doInBackground-Payloads nicht sichergestellt ist. Aufgrund diverser Probleme – laut Gerüchten waren sogar Teile des Betriebssystems auf diese Parallelität nicht vorbereitet – wurde das alte Verhalten ab Version 3.0 wieder eingeführt.

Wenn Sie Ihre AsyncTasks unter Android 3.0 parallel ausführen möchten, so dürfen Sie auf die Methode executeOnExecutor zurückgreifen. Diese nimmt eine Executor-Instanz entgegen, die die eigentliche Abarbeitung der Payload erledigt – die in der Klasse vordefinierte Konstante THREAD_POOL_EXECUTOR sorgt für parallelisierte Abarbeitung.

[ header = Seite 3: Geschwindigkeitsgewinn durch bessere Ressourcenauslastung ]

Geschwindigkeitsgewinn durch bessere Ressourcenauslastung

Die Verwendung eines BackgroundWorkers sorgt für eine bessere Reaktivität. Sofern Ihr Programm nicht mehrere Instanzen auf einmal loslässt, ist der Performancegewinn minimal: Die Arbeit läuft im Hintergrund ab, der GUI-Thread ist währenddessen arbeitslos.

Wenn Sie einen Algorithmus beschleunigen möchten, so müssen Sie ihn von Hand parallelisieren. Dabei sind durchaus beeindruckende Performancesteigerungen möglich: Arbeiten die beiden Instanzen voneinander unabhängig, so kann sich auf einem Doppelkernprozessor eine Verdoppelung der Performance ergeben. Die maximale Leistungssteigerung folgt dabei Amdahls Gesetz. Dieses sagt kurz gefasst aus, dass ein Programm auch mit unendlich vielen Prozessorkernen nicht unendlich stark beschleunigt werden kann. Die maximal erreichbare Geschwindigkeit ist davon abhängig, wie viel Zeit für die Ausführung des nicht parallelisierbaren Teils der Applikation erforderlich ist. Dieser Zusammenhang ist in Abbildung 2 zusammengefasst.

Abb. 2: Amdahls Gesetz beschränkt den maximalen Rechenleistungsgewinn (Abbildung: WikiMedia Commons/Daniels220)

In diesem Zusammenhang darf ein weiterer Vorteil der Parallelisierung nicht unter den Tisch fallen. Manche Aufgaben setzen das Warten auf ein Ergebnis (z. B. das Eintreffen von Daten aus dem Remanentspeicher) voraus. Ein gutes Beispiel dafür ist die folgende Pseudocoderoutine:

void loadAndProc()
{
  res result = load(); //1000 ms delay
  return process (result); //1000 ms calc time
}

Das Parallelisieren dieser (zugegebenermaßen synthetischen) Aufgabe führt auch auf einem Telefon mit nur einem Kern zu einer wesentlichen Leistungssteigerung. Das liegt daran, dass zwei Threads ihre Arbeit im Idealfall exakt so aufteilen, dass stets einer wartet und der andere rechnet.

Threads von Hand

Unsere bisher besprochene AsyncTask-Klasse weist einige ärgerliche Schwächen auf. Neben dem nicht genau vorbestimmten Laufzeitverhalten ist sie immer an eine Activity gebunden – ein AsyncTask muss im GUI-Thread „leben“ und stirbt normalerweise mit seiner Activity.

Für komplexere Rechenaufgaben empfiehlt sich die Verwendung der von Java bekannten Threadklasse. Diese besteht im Grunde genommen nur aus einer run()-Methode, die nach dem Aufrufen von start() in einem neuen Thread abgerackert wird. Wie schon beim AsyncTask ist es auch hier ohne Weiteres möglich, dem Thread durch sein „Mutterobjekt“ weitere Informationen über die Ausführungsumgebung zukommen zu lassen.

Zum Aktualisieren der Steuerelemente müssen Sie beim Verwenden eines Threads auf Sondermethoden zurückgreifen. Diese nehmen normalerweise ein Runnable entgegen, das im GUI-Thread ausgeführt wird. Die mit Abstand am weitesten verbreitete ist die Funktion runOnGuiThread, die Sie in jeder Activity finden – übergeben Sie ihr einfach ein Runnable, es wird dann am GUI-Thread ausgeführt.

[ header = Seite 4: Achtung, Kollisionsgefahr! ]

Achtung, Kollisionsgefahr!

Solange die einzelnen Threads Ihres Programms nicht voneinander abhängig sind, treten normalerweise keine Probleme auf. Leider kommt es in der Praxis immer wieder zu so genannten Race Conditions, in denen mehrere Threads den Inhalt ein- und derselben Speicherstelle gegenseitig überschreiben und so zerstören.

Dieses Verhalten lässt sich am einfachsten anhand eines kleinen Beispiels erarbeiten. Der MyUnsafeThread ist eine absichtliche und besonders bösartige Implementierung, die das Problem besonders eindeutig demonstriert (Listing 4).

public class MyUnsafeThread extends Thread 
{
  MainActivity myMainActivity;
  public int myNumber;
  
  @Override
  public void run() 
  {
    while(1==1)
    {
      int counterCache=myMainActivity.myCounter;
      Log.d("com.tamoggemon.susmt", "Thread" + myNumber + "liest" + (counterCache));
      try {
        Thread.sleep(100);
      }
      catch (InterruptedException e) {
        e.printStackTrace();
      }
      myMainActivity.myCounter=counterCache+1;
      Log.d("com.tamoggemon.susmt", "Thread" + myNumber + "schreibt" + (counterCache+1));
    }
    
  }
}

Unser Thread beschafft sich den Wert des globalen Zählers, inkrementiert ihn und schreibt den geänderten Wert wieder zurück. Normalerweise würde dieser Prozess in „einem Rutsch“ ablaufen – auf einem Telefon mit einem Einkernprozessor wäre es sehr unwahrscheinlich, dass der Thread genau in der Mitte der drei Befehle unterbrochen wird. Aus diesem Grund fügen wir hier einen Delay ein, der für eine Verzögerung (und die Abarbeitung eines anderen Threads) sorgt. Losgelassen werden die Threads im Rahmen des Button-Handlers. Der dazu notwendige Code sieht so aus:

MyUnsafeThread[] someThreads=new MyUnsafeThread[5];
for(int i=0;i<5;i++)
{
  someThreads[i]=new MyUnsafeThread();
  someThreads[i].myNumber=i;
  someThreads[i].myMainActivity=this;
  someThreads[i].start();
  
}

An dieser Stelle ist eigentlich nur eine Sache wichtig: Sie dürfen zum Starten eines Java-Threads niemals seine run()-Methode aufrufen, da sonst kein neuer Thread angelegt wird – die richtige Funktion hört auf den Namen Start. Während der Programmausführung finden Sie in der Debuggerkonsole mehrere Zeilen nach dem folgenden Schema:

10-20 16:11:40.730: D/com.tamoggemon.susmt(11120): Thread 1 liest 1702
10-20 16:11:40.730: D/com.tamoggemon.susmt(11120): Thread 2 liest 1702
10-20 16:11:40.730: D/com.tamoggemon.susmt(11120): Thread 0 liest 1702
10-20 16:11:40.730: D/com.tamoggemon.susmt(11120): Thread 4 schreibt 1702
10-20 16:11:40.730: D/com.tamoggemon.susmt(11120): Thread 4 liest 1702

Hier lesen mehrere Threads die gleiche Information ein und schreiben sie erst mit wesentlicher Verzögerung zurück. Das führt dazu, dass viele Durchläufe des Zählthreads unter den Tisch fallen – im obigen Beispiel würde Thread vier die von den drei vorhergehenden Threads gemachten Inkrementierungen beim Zurückschreiben seines veralteten internen Werts zunichtemachen.

Leider ist es in der Praxis oft weitaus schwieriger, Race Conditions zu detektieren. Das Auftreten dieses Fehlers ist nämlich von mehreren Umgebungsbedingungen abhängig: Neben der Anzahl der Prozessoren des Telefons hängt die Fehlerwahrscheinlichkeit auch davon ab, wie stark das System ausgelastet ist und wie der Scheduler die Threads im Speicher anordnet.

Synchronisiere mich!

Unser obiges Problem lässt sich lösen, indem wir die Ausführung des kritischen Teils des Programms immer nur einem Thread gleichzeitig erlauben. Java stellt auch dafür eine Lösung bereit, deren Nutzung nicht allzu schwierig ist (Listing 5).

synchronized (myMainActivity) 
{
  int counterCache=myMainActivity.myCounter;
  Log.d("com.tamoggemon.susmt", "Thread" + myNumber + "liest" + (counterCache));
  try {
    Thread.sleep(100);
  }
  catch (InterruptedException e) {
    e.printStackTrace();
  }
  myMainActivity.myCounter=counterCache+1;
  Log.d("com.tamoggemon.susmt", "Thread" + myNumber + "schreibt" + (counterCache+1));
}

synchronized-Blöcke verlangen ein Objekt, das als „Mutex“ gilt – es wird nicht weiter behelligt, muss aber während der gesamten Programmausführung konstant bleiben. Der im synchronized-Block befindliche Code darf immer nur von einem Thread gleichzeitig durchlaufen werden. Sobald ein Thread aktiv ist, müssen alle anderen Threads, die auf dasselbe Objekt synchronisiert sind, warten. In unserem Fall führt das abermals zu interessantem Verhalten. Die Ausgaben erfolgen nun ausschließlich aus dem ersten Thread, während die anderen nicht zum Zug kommen. Das liegt daran, dass die Threadverarbeitung vom Betriebssystem immer während der Wartephase unterbrochen wird – da der 0-Thread zu diesem Zeitpunkt noch im synchronized-Block steckt, sind die anderen Threads arbeitslos.

Synchronisation verursacht Overhead und Flaschenhälse. Unser oben gezeigtes Programm ist – trotz der Vielzahl der parallel arbeitenden Threads – auf die Performance eines einzelnen Prozessorkerns beschränkt. Das liegt daran, dass der synchronized-Block immer nur von einem Thread gleichzeitig betreten werden kann. Schläft dieser, so müssen alle anderen Threads warten.

Dies ist ein durchaus häufiges Problem in der parallelen Programmierung. Wenn die Teile der Gesamtaufgabe voneinander unabhängig sind, so empfiehlt sich das vorherige Aufteilen und nachherige Zusammenführen der Einzelelemente.

In unserem Fall würde es genügen, dass jeder Thread einen eigenen Zähler hat – nach dem Abarbeiten aller anstehenden Aufgaben fasst das Mutterprogramm die Ergebnisse zusammen. Wenn für die Verarbeitung größere Mengen an Quelldaten erforderlich sind, so ist es ratsam, diese vorher aufzuteilen – der Zugriff auf eine gemeinsame Datenstruktur kann unter Umständen auch einen Flaschenhals darstellen.

[ header = Seite 5: Kleine Sondertricks ]

Kleine Sondertricks

Die Erstellung von Threads ist eine vergleichsweise teure Rechenoperation. Wenn Ihr Programm eine große Anzahl von trivialen Anfragen generiert, ist das Generieren der Klassen unter Umständen aufwändiger als die eigentliche Abarbeitung der Payload.

Dieses Problem lässt sich durch die Verwendung eines Threadpools umgehen. Dabei handelt es sich um eine Spezialklasse, die im Rahmen ihrer Erstellung eine Gruppe von Threads anlegt. Anstehende Arbeit wird in Runnables verpackt an die Threads verteilt, die diese danach abarbeiten. Zur Erleichterung der Kodierung stellt das Betriebssystem eine Gruppe von Hilfsfunktionen bereit, die unter [1] definiert sind.

Zur Kommunikation zwischen mehreren Threads empfiehlt sich die Verwendung eines Handlers. Dabei handelt es sich um eine Art Kanal zum Nachrichtenaustausch, der Ihnen das Hin- und Hersenden von Datenpaketen über Threadgrenzen hinaus ermöglicht.

Handler entstehen durch das Implementieren der abstrakten Handler-Mutterklasse, die eine Methode für den Nachrichteneingang bereitstellt. Diese wird aufgerufen, wenn ein Ereignis auftritt – der Inhalt der ausgetauschten Daten und die Reaktion liegt alleine im Ermessen des Programmierers.

Aufgrund der vergleichsweise effektiven Kommunikation werden Handler oft für die Kommunikation zwischen dem GUI-Thread und seinen Arbeitern eingesetzt. Weitere Informationen dazu finden Sie unter [2].

Fazit

Desktopprogrammierer sind im Moment noch in der Lage, der Parallelisierung ihres Codes aus dem Weg zu gehen. Im Mobil-Bereich sieht die Situation anders aus: Wer hier nicht parallelisiert, hat verloren. Das intelligente Aufteilen von Programmen ist ein faszinierendes Thema, das im akademischen Bereich seit Jahren für Neuerscheinungen und Promotionen sorgt. Für den durchschnittlichen Entwickler ist das wenig relevant: Die hier vorgestellten Codekonstrukte sollten 99 Prozent der in der Praxis notwendigen Einsatzfälle abdecken.

Geschrieben von
Tam Hanna
Tam Hanna
Tam Hanna befasst sich seit der Zeit des Palm IIIc mit der Programmierung und Anwendung von Handcomputern. Er entwickelt Programme für diverse Plattformen, betreibt Onlinenewsdienste zum Thema und steht unter tamhan@tamoggemon.com für Fragen, Trainings und Vorträge gern zur Verfügung.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: