Korrektes Networking

Asynchron, sonst nichts

Wie Apple schreibt, hat man bei Netzwerkaufrufen prinzipiell drei Möglichkeiten: Synchron mit periodischer Abfrage, synchron blockierend und asynchron.

Synchron mit periodischer Abfrage bedeutet, daß man in Intervallen nachschaut, ob neue Daten vorliegen, die man angefragt hat. Wenn man zu oft nachfragt, verschwendet man CPU-Zeit. Wenn man zu selten nachfragt, verschwendet man Netzwerk-Leistung. Die richtige Abfrage-Häufigkeit kann man nicht erraten, weil sie völlig unvorhersebar schwanken kann jederzeit, und man liegt immer falsch. Wäre es nicht schöner, wenn man Bescheid bekommt, sobald Daten vorliegen?

Beim synchronen blockierenden Netzwerkaufruf hängt der Thread, der den Netzwerkaufruf macht solange, bis die angeforderten Daten komplett geliefert wurden. Daran sind mehrere Sachen schlecht (und mit "schlecht" meine ich schlecht im Ghostbusters-Sinn):

Bei einem asynchronen Netzwerkaufruf kehrt der Aufruf sofort zurück ohne daß ein weiterer Thread gestartet wird. Wenn Daten eintreffen, die Antwort vollständig ist oder ein Problem auftritt, werden entsprechende Callback-Methoden auf dem Thread aufgerufen, die diese Ereignisse dann behandeln. Ferner kann die Netzwerkanfrage jederzeit abgebrochen werden. Man verschwendet keine Ressourcen und hat die volle Kontrolle über den Request.

Von Natur aus asynchron

Netzwerkaufrufe sind von ihrer Natur aus asynchron. Man fragt etwas an und irgendwann kommt eine Antwort. Das ist vergleichbar mit einer Waren-Bestellung: Ich bestelle mir ein Buch und irgendwann wird es mir geliefert.

Bei Objective-C, also in iOS und OS X, steht dem Entwickler das letztgenannte Vorgehen als übliche und empfohlene Vorgehensweise zur Verfügung. Man bestellt einen Haufen Bücher und holt sie sich jeweils an der Haustür ab, wenn es klingelt.

Bei Java und damit auch auf Android ist die beste verfügbare Möglichkeit für den Entwickler jedoch diese: Man bestellt sich einen Haufen Bücher und stellt pro Buch einen Freund an die Haustür, der auf das jeweilige Buch wartet und einem dann bringt, wenn es angekommen ist.

Was im realen Leben als völlig kranke Idee sofort auffliegt, ist bei Java und Android der normale Wahnsinn. Die kennen das aber nicht besser und halten das deshalb für normal. Sie haben allerdings schon erkannt, daß es eine noch dämlichere Idee ist, wenn man sich die ganze Zeit selbst an die Haustür stellt. Im Programm entspräche das dem Blockieren des Main-Threads, also der GUI, bis die Anwort kommt.

Man kann auch in Objective-C synchrone Netzwerkaufrufe machen. Kann man. Muß man aber nicht. Sollte man auch nicht. Witzigerweise sind synchrone Aufrufe intern so umgesetzt: Sie blockieren den aufrufenden Thread und machen einen asynchronen Aufruf auf einem weiteren Thread. Totaler Käse. Aber warum?

Auf den ersten Blick sind synchrone Aufrufe leichter zu programmieren: Ein Zeile und man ist fertig. Man steht dann aber tagelang selbst an der Haustür. Bekommt keine Informationen über den Fortschritt der Lieferung. Und kann es auch nicht wieder abbestellen. Beim asynchronen Vorgehen muß man etwas mehr programmieren. Man muß loslaufen, wenn es an der Tür klingelt, Fortschrittsnachrichten kann man lesen oder wegwerfen und man kann abbestellen.

Das schmutzige Geheimnis von sendAsynchronousRequest:queue:completionHandler:

Mit iOS 5 hat Apple eine zusätzliche Variante eingeführt, die wie ein synchroner Request mit einem einzelnen Kommando auskommt, aber trotzdem asynchron sein soll:

+[NSURLConnection sendAsynchronousRequest:queue:completionHandler:]

Das soll eine Convenience-Funktion sein, die die Einfachheit synchroner Aufrufe mit der Ressourcen-Sparsamkeit asynchroner Aufrufe verbinden soll. Bis inklusive iOS 6 gab es dabei allerdings einen kleinen Schönheitsfehler:

sendAsyncRequest

Man sieht, daß die Methode, die asynchron im Namen trägt, intern einen synchronen Aufruf macht, nämlich diesen:

+[NSURLConnection sendSynchronousRequest:returningResponse:error:]

Damit ist man wieder beim blockiertem Hintergrund-Thread. Sehr unschön.

Mit iOS 7 hat Apple das dann zu meinem Entzücken behoben. Nun ist die Ausführung dieser Methode tatsächlich asynchron. Hier sind die Details, der Stacktrace, von iOS 6 und iOS 7 zu sehen, woraus man erkennen kann, was intern passiert:

 		
iOS 6.1

(lldb) bt
* thread #3: tid = 0x11da6b, 0x01112dc5 Foundation`createCFRequest, queue = 'com.apple.root.default-priority, stop reason = breakpoint 1.1
    frame #0: 0x01112dc5 Foundation`createCFRequest
    frame #1: 0x011c69fb Foundation`+[NSURLConnection sendSynchronousRequest:returningResponse:error:] + 100
    frame #2: 0x011c6c6a Foundation`__67+[NSURLConnection sendAsynchronousRequest:queue:completionHandler:]_block_invoke_0 + 77
			

Oben erkennt man die interne Verwendung von sendSynchronousRequest, unten gibt es die nicht. Oben sieht man, daß Thread Nummer 3, also ein Hintergrund-Thread verwendet wird, während unten beim asynchronen Request problemlos Thread Nummer 1, der Main- und GUI-Thread verwendet wird, ohne ihn zu blockieren.

	
iOS 7.0

(lldb) bt
* thread #1: tid = 0x11de25, 0x015142b4 Foundation`createCFRequest, queue = 'com.apple.main-thread, stop reason = breakpoint 1.2
    frame #0: 0x015142b4 Foundation`createCFRequest
    frame #1: 0x01647349 Foundation`+[NSURLConnection sendAsynchronousRequest:queue:completionHandler:] + 64
			

Technisch funktioniert der asynchrone Call so, daß der Run Loop des Threads verwendet wird. Ein Run Loop kann mehrere Input-Quellen haben, beispielsweise Timer, Maus-Klicks oder Touch-Events. Eine weitere Input Source sind Antworten auf einen Netzwerkaufruf. Der Run Loop kippt, sobald ein Input seiner Quellen reinkommt, diese in den Thread, so daß der Event bearbeitet werden kann. Das Reinreichen von Antworten von Netzwerkaufrufen kostet praktisch keine Zeit und fällt nicht weiter auf in dem Thread, der sich eh langweilt, weil User-Inputs im Vergleich zur CPU echt lahm sind.

Parallele asynchrone Downloads

Wie kann man vorgehen, wenn man asynchrone Netzwerkaufrufe machen möchte und zwar mehrere gleichzeitig?

Jeder Request hat ein Delegate-Objekt, an das die asynchron reinkommenden Daten und Events weitergereicht werden. Wenn man mehrere Requests hat, könnte man denselben Delegate für alle verwenden, käme dann aber in eine Komplexität, die man nicht will.

Die Lösung ist, ein dediziertes Delegate-Objekt als Delegate pro Request zu verwenden. Damit kommt man weiterhin mit (dem) einem (Main-) Thread aus und benötigt keinen Worker-Thread. Der jeweilige Delegate kennt seinen Request und weiß auch, was mit den Ergebnissen zu tun ist. Auf diese Weise erhält man ein ressourcenschonendes leicht verständliches und nach Aufgaben strukturiertes Programm.

Als gutes Praxisbeispiel kann man sich Lazy Table Images bei Apple ansehen. Das lädt Bilder einer Tabelle auf diese Weise separat nach. Zusätzliches Schmankerl: Während man die Tabelle scrollt, werden keine weiteren Bilder geladen, sondern erst, wenn feststeht, welche Zellen sichtbar sind. Also nur bei Bedarf und nur das Nötige.

Eine weiters interessantes Beispiel von Apple ist der LinkedImageFetcher.

Apples Beispiele setzen den Fokus immer nur auf einen Aspekt und machen den klar. Dabei werden andere Gesichtspunkte absichtlich vernachlässigt.

In diesem Fall wäre in der Praxis noch zu berücksichtigen, daß man die heruntergeladenen Daten nicht immer wieder runterlädt, sondern nur bei Änderung, die man an den geänderten oder unbekannten URLs der Bilder feststellen kann. Damit macht man die Anwendung schneller und spart sich viele Netzwerkanfragen, was den Akku schont.

In diesem Beispiel besteht die GUI nur aus einer Ansicht, die von einem Controller verwaltet wird. Das bedeutet, der Controller lebt solange wie die Anwendung und kann darum die dauerhafte Handhabung des Netzwerkverkehrs sicherstellen. Bei komplexeren Apps darf so ein Controller jedoch die Anfragen nicht selbst machen, sondern muß sie in Model-Objekte abgeben, die solange leben wie die Anwendung. Und das aus zwei Gründen: Der Controller könnte beseitigt werden, bevor die Antwort kommt, was ungenutzte Anworten und erneute Anfragen zur Folge hätte. Andererseits müssen die Daten eh im Model landen und das Model lebt solange wie die App. Daher ist es mehr als naheliegend, Netzwerkanfragen im Model zu machen. Das bespricht Apple auch, aber an anderer Stelle. Das Model ist eh für die Datenverwaltung zuständig und lebt lange genug, um Netzwerkaufrufe zu managen. Darum sind Model und Netzwerk ein perfektes Paar.

Blocks versus Delegate Callbacks

Wie man an meinem einfachen blockbasiertem Download-Beispiel sehen kann, sind blockbasierte asynchrone Netzwerk-Calls sehr einfach und kompakt. Allerdings eignen sich nur für simple Anwendungsfälle und haben ansonsten schnell ihre Grenzen erreicht:

Android Networking

Java im Allgemeinen und Android im Besonderen können keine asynchronen Netzaufrufe machen, denn Interaktionen mit URLs sind in Java inherent synchron. Java-Entwickler sind dazu verdammt, pro Download einen Thread zu blockieren. Bei einem Einzel-Download ist das schon schlimm, aber bei mehreren parallelen Downloads wird das ein Problem, da Ressourcen verschwendet werden, um exakt gar nichts zu tun.

Neben diesem Java-Problem hat Android noch ein Design-Problem: Typischerweise werden dort Netzaufrufe völlig falsch aufgehängt: Nicht passend zu ihrem Lebenszyklus am Daten-Modell, sondern mit einer sogenannten AsyncTask an Aktivitäten, also an Views. Das Problem: Der View (und die Activity) wird bei Android zum Beispiel durch Drehen des Gerätes neu geladen und das Layout neu berechnet. Die alte Instanz der Aktivität wird dabei wegeworfen, aber an der hängt der Netzwerk-Call. Damit geht die Antwort ins Leere und der Netzwerk-Aufruf ist verschwendet. (Bei iOS würde hingegen beim Drehen der View zwar auch verändert, aber der ViewController würde nicht neu geladen.)

Googles Dokumentation zur AsyncTask ist mehr als unglücklich, weil das Beispiel mit dem Netz dort den Anfänger glauben macht, die AsyncTask wäre erste Wahl für Netzwerkanfragen. Ist sie jedoch nicht, denn Netzwerk-Anfragen gehören nicht an den View gehängt, weil ein GUI-Objekt viel zu flüchtig und kurzlebig ist, so daß man Netz-Antworten verpaßt oder wiederholte Requests machen muß. Das kostet Zeit und Akku. Beides Dinge, die man vermeiden möchte.

Jedes Mal, wenn also Android die Activity wegwirft und eine neue Instanz anlegt, wird auch der Netz-Request erneut abgesetzt, weil die Netz-Kommunikation typischerweise an der Activity hängt mit einer Async-Task. Wobei die "Async-Task" einen zweiten Thread benutzt und diesen mit einem synchronen Netzaufruf blockiert.

Ursprünglich wurden AsyncTasks seriell auf einem einzigen Hintergrund-Thread ausgeführt. Man konnte also immer nur ein Buch bestellen und erst, wenn der Freund, der an der Haustür auf die Post warten muß, das Buch in Empfang nimmt, ein weiteres Buch in Auftrag geben.

Mit Android 1.6 verwendeten AsyncTasks einen Threadpool, um mehrere Hintergrund-Threads zu benutzen. Man konnte jetzt mehrere Bücher gleichzeitig bestellen und hatte pro Buch einen Freund an der Haustür stehen, der auf die jeweilige Lieferung wartet.

Ab Android 3.0 verwenden sie wieder einen einzelnen Thread, um Anwendungs-Programmierfehler zu vermeiden, die bei parallelen Threads auftreten könnnen. So steht es in Googles Dokumentation. Jetzt bestellt man die Bücher also wieder einzeln und streng nacheinander und stellt einen Freund vor die Haustür, der auf jedes einzelne Buch wartet. Das nächste wird erst bestellt, was das vorherige angekommen ist.

Wäre es nicht toll, wenn man ganz viele Bücher gleichzeitg bestellen könnte, und der Postbote klingelt irgendwann? Dann mußt Du zu Objective-C, iOS und OS X kommen. Wir haben gescheite APIs.

OS X 10.9 und iOS 7

Ab OS X 10.9 iOS 7 bietet es sich an, anstelle von NSURLConnection das neue NSURLSession zu verwenden. Danke an Tion für seinen Hinweis.

NSOperation

Zusätzlich kann man in dem Download-Delegate die nötigen Methoden für NSOperation implementieren. Dadurch lassen sich die Netzwerk-Operationen in eine NSOperationQueue stecken und komfortabel verwalten, was ihre Prioritäten und Abhängigkeiten angeht. Am interessantesten ist dabei jedoch, damit eine Gruppe von Downloads einfach abbrechen zu können.

Hier ist ein Beispiel-Projekt, das asynchrone Downloads in NSOperation verpackt und mit NSOperationQueue verwaltet und eine Beschreibung der Motivation, es so zu tun. (Hier ist ein Archiv der Seite). Das läßt sich mit meiner Anleitung auch auf NSURLSession umbauen.

Inzwischen habe ich das Beispiel-Projekt umgebaut, so daß es nun NSURLSession verwendet.

Verwandte Artikel

Mac and i 6 In der Developer's Corner von Mac & i Heft 6 ist ein Artikel von mir erschienen über Multithreading mit Grand Central Dispatch.

Man kann den Artikel bei heise als einzelnen Download kaufen.

In den Debugging-Tips beschreibe ich, wie man synchrone Requests komfortabel aufspüren kann.

Valid XHTML 1.0!

Besucherzähler


Latest Update: 03. November 2015 at 19:30h (german time)
Link: warcraft.realmacmark.de/dev/osx_dev_networking.php