AI-United » Allgemein » Asynchrone Programmierung: Grundlagen, Ausführung, Beispiele

Asynchrone Programmierung: Grundlagen, Ausführung, Beispiele

Asynchrone Programmierung

Wie unterscheidet sich die asynchrone Programmierung von der synchronen? Welche Aufgaben hat sie? Wie wird die Asynchronität in verschiedenen Sprachen implementiert? In diesem Artikel versuchen wir, diese Frage zu beantwortet.

Computerprogramme haben oft mit andauernden Prozessen zu tun. Beispielsweise bekommen sie die Daten aus der Datenbank oder machen komplizierte Berechnungen. Während eine Operation ausgeführt wird, können mehrere vollendet werden. Und die Untätigkeit führt zu der Reduzierung der Produktivität und den Verlusten. Die asynchrone Programmierung erhöht die Leistungsfähigkeit, da sie das Blockieren des Haupt-Threads der Ausführung verhindert.

Trends

Asynchronität ist keine neue Erscheinung, aber in den letzten Jahren ist dieser Entwicklungsstil besonders beliebt geworden. Alle modernen Sprachen haben die Werkzeuge zu seiner Umsetzung und ständiger Verbesserung. Zum Beispiel sind die Versprechen an Stelle von Ereignissen und Rückruffunktionen getreten. Heutzutage gibt es viele Bibliotheken der Asynchronität, beispielsweise bedient ReactiveX solche Sprachen wie Java, C#, Swift, JavaScript und verschiedene andere Sprachen.

In unserer heutigen Welt, in der niemand gerne wartet, kann man nicht einen Code synchron schreiben! Um mit den gegenwärtigen Trends Schritt zu halten, muss man die asynchrone Programmierung beherrschen.

Menschen in der synchronen Welt

Ein beschäftigter junger Mann hatte sich abends mit einem Mädchen verabredet. Er liegt ihm viel daran, dass alles perfekt läuft, aber dazu müssen noch einige Dinge getan werden:

  • Arbeitsunterlagen müssen zu Ende bearbeitet werden;
  • Ein Anzug muss von der Reinigung geholt werden;
  • ein Blumenstrauß aus Lilien muss vom Blumenladen geholt werden;
  • und vor allem muss er die Mutter darum bitten, ihren besonderen Kuchen zu backen.

Ohne einen Kuchen, einen Blumenstrauß, einen Anzug und einen Stapel erledigter Arbeitsunterlagen kann das Wiedersehen definitiv nicht stattfinden.

Der junge Mann lebt in der synchronen Welt: Er kann nicht zur nächsten Angelegenheit übergehen, bis die vorherige Angelegenheit vollendet wurde.

Zuerst muss der Kuchen bestellt werden, da die Zubereitung einige Stunden dauert. Er ruft seine Mutter an und sie beginnt sofort, den Teig anzurühren. Sicherlich wird der Kuchen bis zum Abend fertig sein. Der junge Mann hat leider keine Zeit, die anderen Angelegenheiten zu erledigen, und die Verabredung mit dem Mädchen findet nicht statt. Dies ist damit verbunden, dass er die ganze Zeit mit dem Hörer am Ohr verbracht hat, indem er auf die Bestätigung der Erfüllung der Anfrage gewartet hat. Die herzlose synchrone Welt gab ihm keine Möglichkeit, zu arbeiten und einen Blumenstrauß zu kaufen.

Bei der Lösung dieses Problems kann asynchrone Programmierung helfen. Damit kann man den Sperrvorgang des Backens der Mutter aus dem Ablauf der Vorbereitung zum Treffen entfernen.

In der asynchronen Welt ist eine Person nicht von einem Kuchen abhängig. Der junge Mann bittet seine Mutter zurückzurufen und holt selbst seinen Galaanzug von Reinigung. Wenn die letzte Kirsche auf den Kuchen gelegt ist, startet die Mutter das Ereignis „Kuchen fertig“. Ein eleganter junger Mann greift den Blumenstrauß und läuft zum Wiedersehen.

Asynchrone Programmierung

Da jede Operation im synchronen Code auf das Ende des vorherigen wartet, kann das gesamte Programm abstürzen, wenn einer der Befehle zu lange durchgeführt wird.

Nachdem der synchrone Code die Sperroperation aus dem Hauptprogramm-Thread entfernt hat, wird sie weiter ausgeführt, aber an einer anderen Stelle, und der Handler kann weitergehen. Einfach gesagt, wird vom Hauptprozess die Aufgabe gestellt und an einen anderen unabhängigen Prozess weitergeleitet.

Datenabfrage

Die asynchrone Programmierung löst erfolgreich sehr viele Aufgaben. Eine der wichtigsten ist die Zugänglichkeit des Benutzer-Interfaces.

Ein gutes Beispiel ist eine Anwendung, die einen Film nach den angegebenen Kriterien aussucht. Nachdem die Parameter vom Benutzer bestimmt worden sind, schickt das Programm eine Anfrage an den Server. Dort findet die Auswahl geeigneter Filme statt.

Natürlich kann die Verarbeitung ziemlich lange dauern. Bei der synchronen Arbeit der Anwendung kann der Benutzer erst dann mit der Seite zusammenwirken, wenn das Ergebnis da ist. Er ist sogar nicht imstande, zu scrollen!

Dank des asynchronen Codes kann man diese unangenehmen Effekte vom Benutzer verdecken und die Beweglichkeit der Seite behalten. Nach dem Laden von Daten werden sie vom Programm auf den Bildschirm ausgegeben.

In diesem Fall findet die Aufteilung des Hauptausführungsthreads in zwei Zweigen statt. Einer davon beschäftigt sich mit dem Interface weiter und der andere führt die Anforderung durch.

// creating and sending request to server
let xhr = new XMLHttpRequest();
xhr.open('GET', '/my/url', true);
xhr.send();
 
// callback handler
xhr.onreadystatechange = function() {
  if (this.readyState != 4) return;
 
  if (this.status != 200) {
    console.log( 'error' );
    return;
  }
 
  let result = this.responseText;
  ...
}
Schließen einer asynchronen Operation

Hier entsteht ein Problem. Wie erfährt der Hauptzweig, dass die Anforderung im Zusatzzweig schon abgeschlossen worden ist? Wie kehrt man den eingegangenen Wert an den Haupt-Thread zurück, wenn es notwendig ist? Dieses Problem wird vom Ereignis und Mechanismus des Rückrufs gelöst.

Bei der asynchronen Ausführung der Anforderung werden alle über ihr Schließen informiert. Das Programm bestellt diese Nachricht und meldet einen Handler dafür an. Zur richtigen Zeit erstellt die Anforderung ein Ereignis und benachrichtigt die Besteller.

Der Handler führt den nachfolgenden Code so lange aus, bis eine Nachricht ankommt. Dann unterbricht der Handler seine Arbeit und bearbeitet den Code.

Man kann mehrere asynchrone Operationen im Programm ausführen. Um mit zahlreichen Ereignissen zurechtzukommen, gibt es eine spezielle Warteschlange. Ihr Arbeitsprinzip lautet: „Wer zuerst kommt, mahlt zuerst“.

Um den Rückrufmechanismus im Detail zu erlernen, betrachten wir Node.JS. In der Mitte dieser Plattform befindet sich die Bibliothek LibUV. Sie ist in der Programmiersprache C geschrieben und ist fähig, mit verschiedenen Betriebssystemen Kontakt aufzunehmen. Die Bibliothek fühlt sich unter Windows, Linux oder MacOS wie ein Fisch im Wasser.

Von LibUV wird die grundlegende Aufgabe der Verwaltung von Eingangs- und Ausgangsoperationen in Node.JS übernommen. Dabei erfolgt das Zusammenwirken mit dem Programmierer über mehrere Vermittler:

  • Zuerst formuliert der JavaScript-Code die Aufgabe und übergibt sie dann an die Plattform Node.JS. Dies kann beispielsweise das Lesen einer Datei  oder das Schicken einer Anforderung über das Netzwerk sein.
  • Da Node.JS als ein echter Leiter betrachtet wird, delegiert sie die Aufgabe von LibUV, indem sie sich völlig auf ihre Fähigkeiten verlässt.
  • Die Bibliothek passt sich einem bestimmten Betriebssystem an und übergibt den Befehl.

Jedes Skript in Node.JS mit einem Thread wird im Zyklusmodus gestartet: Die Ausführung des synchronen JavaScript-Code wechselt sich ständig mit asynchronen Ereignissen (wie beispielsweise mit E / A-Verarbeitung oder Timern) ab. Bis es die zu bearbeitenden Daten gibt, stoppt dieser Zyklus nicht.

In die Tiefen von Rückrufen / Callbacks

Starten wir einen einfachen Server und verfolgen an diesem Beispiel, wie die Übergabe der Steuerung durchläuft.

var http = require('http');
var fs = require('fs');

var server = new http.Server();

server.on('request', function(req, res){ 
  // processing event request
});

server.listen(3000);

Am Anfang bevormundet JavaScript im Programm. Ihre Aufgaben bestehen darin, die Module zu verbinden, das Serverobjekt zu erstellen und den Ereignishandler request festzulegen. Die Daten, die sich in der Rückruffunktion befinden, sind im Moment unwichtig.

Alle diese Vorgänge laufen im globalen Kontext der Codeausführung.

Zum Schluss wird die Methode des Servers listen aufgerufen. Da sie mit Netzwerkverbindungen arbeitet, wird die Steuerung an die Plattform Node.JS übergeben. Eine Reihe von internen Methoden verarbeitet den Befehl, bis die LibUV-Bibliothek erreicht wird. Dank der Methode uv_listen  wird der angegebene Port gefunden und die Beobachtung gesetzt.

Nun entgeht kein einziges Port-Ereignis der Aufmerksamkeit von LibUV. Aber jetzt wird von der Bibliothek einfach eine Nachricht geschickt, dass alles gut ausgeführt wurde. Die Steuerung wird zuerst an Node.JS und dann an den JavaScript-Code übergeben.

Da die letzte Zeile schon zu Ende ausgeführt ist und die Methode server.listen abgeschlossen ist, hat der JS-Interpreter nichts mehr zu tun. Der Code-Handler ist schon wieder im globalen Ausführungskontext. Muss das Programm beendet werden? Aber was ist dann mit der Verfolgung des Ereignisses request zu tun?

Bevor die Arbeit beendet wird, fragt Node.JS die LibUV, ob noch interne Beobachter geblieben sind. Wenn nichts mehr zu verfolgen ist, wird der Zyklus beendet. In unserem Beispiel ist jedoch noch ein Beobachter geblieben. Statt herunterzufahren schläft das Programm einfach ein.

Das Schlafen dauert so lange, bis das Betriebssystem die Verbindung zum Port anzeigt. Der LibUV-Handler wird aktiviert und nach einigen Schritten gelangt das Signal in Node.JS und den JavaScript-Code als Ereignis request  am Serverobjekt. Darauf aktiviert JavaScript die Rückruffunktion.

Der Start eines Rückrufs ist keinesfalls zeitlich mit seiner Registrierung verbunden. Die Asynchronität besteht darin, dass mehrere Stunden zwischen diesen Ereignissen liegen können!

Aus dem globalen Kontext wird die die Anforderung bearbeitende Funktion gestartet. Gerade dort war der Interpreter nach dem Start des Servers. Nachdem die Bearbeitung zu Ende gewesen ist, kehrt er zurück und signalisiert, dass das Programm zu beenden ist. LibUV beginnt seine Beobachter wieder zu überprüfen. Der Zyklus wiederholt sich.

Probleme der Rückrufe

Ein charakteristisches Merkmal des Ereignis-Handlers besteht darin, dass er selbst den Thread der Codeausführung blockieren kann, beispielsweise bei synchroner Ausführung von komplizierten Operationen. Wir stehen schon wieder vor dem Problem des Wartens und Hängens des Programms.

Zum Vermeiden des Blockierens kann man noch einen Rückruf ausführen. Tatsächlich kann es technisch eine beliebige Anzahl von Schachtelungsebenen geben. Aber eine Vielzahl von Funktionen verwirrt sehr. Diese Erscheinungen werden Rückrufhölle genannt und als schlechter Code-Stil betrachtet.   

function getPassport(data, callback) {...}
function getVisa(data, callback) {...}
function buyTickets(data, callback) {...}
function bookHotel(data, callback) {...}

getPassport(
    data, 
    getVisa(
        passport,
        buyTickets(
            visa, 
            bookHotel(
                ticket, 
                function() { console.log("You can go on vacation!"); }
            )
        ) 
    )
);

Im Code sind 4 asynchrone Funktionen zu finden: getPassportgetVisabuyTicketsbookHotel. Jede von ihnen nimmt Eingabedaten und eine Rückruffunktion an.

Das allgemeine Schema des Programms kann so dargestellt werden:

  • Dokumente für den Reisepass einreichen.
  • Ein Visum machen lassen.
  • Flugtickets besorgen.
  • Ein Hotel buchen.
  • Die Nachricht anzeigen, dass man in den Urlaub fahren kann.
Andere Lösungen

Ausführungsereignisse und Rückrufe werden als das klassische Schema eines asynchronen Modells betrachtet, das in den meisten Sprachen verwirklicht ist. Aber es hat einige Nachteile.

Heutzutage stehen praktischere Werkzeuge für die Arbeit mit der Asynchronität zur Verfügung, die in zwei Gruppen unterteilt werden.

Die Werkzeuge der ersten Gruppe senden die „Versprechen“ zurück. Das sind deferred, promises und futures.

Die Werkzeuge der zweiten Gruppe verwirklichen die Asynchronität von Berechnungen. Diese Konstruktionen enthalten die Schlüsselwörter async / await. Obwohl diese Architektur zum ersten Mal in C# erschien, wurden ihre Vorteile von anderen Sprachen jedoch schnell geschätzt.

Noch einige Begriffe

In Bezug auf die Asynchronität sind drei weitere Begriffe zu betrachten: die Wettbewerbsfähigkeit (Gleichzeitigkeit), Parallelität (parallele Ausführung) und das Multithreading. Alle sind mit der gleichzeitigen Ausführung von Aufgaben verbunden, aber dies ist nicht dasselbe.

Wettbewerbsfähigkeit (Gleichzeitigkeit)

Der Begriff der gleichzeitigen Ausführung ist sehr allgemein. Dies bedeutet buchstäblich, dass viele Aufgaben gleichzeitig gelöst werden. Anders gesagt gibt es im Programm mehrere logische Threads – einen für jede Aufgabe.

Die Threads können dabei physisch gleichzeitig ausgeführt werden. Dies ist jedoch nicht notwendig.

Da die Aufgaben miteinander nicht verbunden sind, spielt es keine Rolle, welche Aufgabe früher und welche später endet.

Parallelität

Die parallele Ausführung wird gewöhnlich zur Unterteilung einer Aufgabe in Teile verwendet, um Berechnungen zu beschleunigen.

Beispielsweise soll ein Farbbild schwarzweiß gemacht werden. Gewiss unterscheidet sich die Verarbeitung der oberen Hälfte nicht von der Verarbeitung der unteren Hälfte. Deshalb kann man diese Aufgabe in zwei Teile unterteilen und sie auf verschiedene Threads verteilen. Auf solche Weise wird die Ausführung zweimal beschleunigt.

Auf einem Computer mit einem Prozessor (Prozessorkern) ist es unmöglich, diesen Verfahren auszuführen. Deshalb ist das Vorhandensein von zwei physikalischen Threads von entscheidender Bedeutung.

Multithreading

Die Besonderheit des Multithreadings besteht darin, dass der Thread eine Abstraktion ist, unter der sich sowohl ein separater Prozessorkern als auch ein OS-Thread verstecken kann. Es ist zu betonen, dass einige Sprachen sogar eigene Thread-Objekte haben. Dieses Konzept kann also eine grundlegend andere Verwirklichung haben.

Asynchronität

Die Idee der asynchronen Ausführung besteht darin, dass der Anfang und das Ende einer Operation zu unterschiedlichen Zeitpunkten in verschiedenen Teilen des Codes erfolgen. Um ein Ergebnis zu erhalten, muss man warten. Dabei ist die Wartezeit unvorhersehbar.

Schablonen der Asynchronität

Man unterscheidet drei beliebte Schemas der asynchronen Anforderungen. Betrachten wir ihre Implementierung mit Hilfe von Versprechen (JavaScript) und Schlüsselwörtern async-await (C#).

JavaScript:

function getPromise(returnValue) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(returnValue);
        }, 300);
    });
}

Für die Demonstration werden Testfunktionen benötigt, die die Rückgabe der notwendigen Objekte mit Verzögerung nachbilden.

C#:

public static async Task GetStringTask(String toReturn)
{
    await Task.Delay(300);
    return toReturn;
}
 
public static async Task GetIntTask(int toReturn)
{
    await Task.Delay(300);
    return toReturn;
}
Konsequente Ausführung

Sie wird für verbundene Aufgaben verwendet, die nacheinander zu starten sind. Beispielsweise bekommt die erste Anforderung die Namen der Filme und die zweite die Informationen dazu.

JavaScript:

getPromise('value1')
    .then((result) => {
        return getPromise(result + 'value2');
    }).then((result) => {
        return getPromise(result + 'value3');
    }).then((result) => {
        console.log(result); // => value1value2value3
    });

Jede Funktion gibt ein neues Promise zurück, dessen Ausführung man auch zurückverfolgen kann. Das Endergebnis ist eine bequeme einschichtige Kette von Versprechen.

C#:

var str = await GetStringTask("Hello world");
var len = await GetIntTask(str.Length);
var res = await GetStringTask("Len: " + len);
Console.Out.WriteLine(res);

Die Variable str bekommt den Wert nur dann, wenn die Funktion GetStringTask zu Ende ausgeführt wird. Erst danach kann die Ausführung vom Code-Handler fortgesetzt werden.

Parallele Ausführung

Parallele Ausführung wird zur Lösung von unabhängigen Problemen verwendet, wenn die Ausführung aller Anforderungen von großer Bedeutung ist. Beispielsweise werden die Daten einer Webseite von drei Servern geladen und danach beginnt das Rendering.

JavaScript:

Promise.all([
    promise1,
    promise2,
    promise3
]).then((results) => {
    ...
}

Der Parameter results ist ein Array, wo sich die Ergebnisse aller drei ausgeführten Operationen befinden.

C#:

var tasks = new Task[3];
tasks[0] = GetIntTask(1);
tasks[1] = GetIntTask(2);
tasks[2] = GetIntTask(3);
Task.WaitAll(tasks);
for (int i = 0; i < 3; i++)
{
    Console.Out.WriteLine("Res " + i + ": " + tasks[i].Result);
}

Die Methode WaitAll der Klasse Task dient zum Zusammensammeln von drei Anforderungen.

Gleichzeitige Ausführung

Gleichzeitige Ausführung wird zur Lösung von unabhängigen Problemen verwendet, wenn es wichtig ist, dass mindestens eine Anforderung ausgeführt wird. Beispielsweise werden identische Anforderungen an verschiedene Server geschickt.

JavaScript:

Promise.race([
    promise1,
    promise2,
    promise3
]).then((result) => {
    ...
})

In den Parameter result geriet das erste von drei zurückgekehrten Ergebnissen.

C#:

var tasks = new Task[3];
tasks[0] = GetIntTask(1);
tasks[1] = GetIntTask(2);
tasks[2] = GetIntTask(3);
int firstResult = Task.WaitAny(tasks);
Console.Out.WriteLine("Res " + firstResult);

Die Methode WaitAny wartet auf die allererste Ausführung, um sie in die Variable firstResult abzulegen.

Dies sind nur einfache Beispiele, wie asynchrone Werkzeuge in verschiedenen Sprachen verwendet werden. Wenn Sie lernen möchten einen effektiven und verständlichen Code zu schreiben, dann müssen Sie ihn näher kennenlernen.

Wenn Sie mehr über die asynchrone Programmierung erfahren wollen, können Sie sich an das Team von AI-United.de per Mail oder Q&A wenden.

Quelle

AI-United-Redaktion

Kommentar hinzufügen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.