Ein Rant zum Thema Promises in JavaScript

Man hat eine Funktion wie

function computeSomethingA() {
    const value = computeSomethingB();
    processA(value);
    return value;
}

Und eine Funktion wie

function computeSomethingB() {
    const value = computeSomethingC();
    processB(value);
    return value;
}

Und eine Funktion wie

function computeSomethingC() {
    const value = computeSomethingD();
    processC(value);
    return value;
}







Und eine Funktion wie

function computeSomethingY() {
    const value = computeSomethingZ();
    processY(value);
    return value;
}

Und nun ändert man die Funktion computeSomethingZ() so, dass man dort eine externe library verwendet, die nicht direkt das result zurückgibt, sondern eine Promise auf das result.

Kein Problem, wir haben ja async/await, was …

:angry: :fu: :bomb: :face_with_symbols_over_mouth: … ja, hallo, Leute, ihr habt’s verbockt, und meint, eine „„Sprache““ verwenden zu müssen, die kein Konzept für mehrere Threads habt, und murxt jetzt so einen Mist zusammen, damit Leute sich weiterhin einreden sie würden mit dieser „Sprache“ ‚Software entwickeln‘ …
… da die angemessene Lösung darstellt.

Also schreibt man halt einfach

function computeSomethingY() {
    const value = await computeSomethingZ();
    processY(value);
    return value;
}

und das Problem ist gelöst :slight_smile:

Schön. Idiomatisch. Kompakt. :slight_smile:

Nun.

Jetzt muss man computeSomethingY auch async machen und beim Aufruf await davorschreiben.
Und dann computeSomethingX auch async machen, und bei ihrem Aufruf await davorschreiben.
Und dann computeSomethingW auch async machen, und bei ihrem Aufruf await davorschreiben.



Und dann computeSomethingC auch async machen, und bei ihrem Aufruf await davorschreiben.
Und dann computeSomethingB auch async machen, und bei ihrem Aufruf await davorschreiben.
Und dann computeSomethingA auch async machen, und bei ihrem Aufruf await davorschreiben.

Aber dann ist das Problem gelöst :slight_smile:

Schön. Idiomatisch. Kompakt. :slight_smile:

Echt jetzt?

Das ganze ist komplett synchron. Die ganze API suggeriert Asynchronität, die faktisch nicht vorhanden ist. Die Promises erfüllen dort keinen Zweck. Sie erzeugen eine Komplexität die im Vergleich zur vernünftigen Lösung geradezu lächerlich ist. Es ist unmöglich, das Vorhandensein irgendeiner Promise an irgendeiner Stelle des Codes im Vorfeld zu antizipieren, und wenn irgendwo eine auftaucht, bricht die gesamte Codebasis in sich zusammen wie ein Kartenhaus.

Wie wäre es mit einer „„Sprache““ Namens „JavaScript 2.0“, die sich von JavaScript nur dadurch unterscheidet, dass alle Funktionen per Default async sind, und jeder Aufruf per default mit await stattfindet?

const value = computeSomething();
// Here, 'value' is automatically a promise, and the actual 
// value will be logged as soon as the promise is resolved:
console.log(value);

:woozy_face:

Was ist da draußen denn eigentlich falsch gelaufen?

1 „Gefällt mir“

Das hat aber nichts mit JS zu tun. In C# z.B. ist es genau so wie du beschreibst. Eine async Methode von einer non-async Methode aufzurufen ist eine Wissenschaft für sich, die nur falsche Antworten kennt.

In Kotlin habe ich wegen den Erfahrungen aus C# von async/await erstmal Abstand genommen, und weil die Codebasis ganz klassisch Workerthreadpools verwendet. Ist es dort besser?

Wo ist da die Wissenschaft?

public T computeSomethingY() {
    // Vorher:
    //T value = computeSomethingZ();
    // Nachher:
    Future<T> future = computeSomethingZ();
    T value = future.get();
    processY(value);
    return value;
}

Der Aufrufer wartet, bis das Ergebnis da ist :man_shrugging:

Erst dadurch, dass ~„das halt nicht geht, weil das Future nicht beendet werden kann, weil’s halt keine Threads gibt“, wird das ganze zu einer „Wissenschaft“. Und das ist ein Charakteristikum von JavaScript (oder… kennst du noch eine (richtige) Sprache, die das Konzept von „Threads“ nicht kennt? Selbst C++ hat es inzwischen, auch wenn die sich lange Zeit gelassen haben, das ‚formal‘ zu machen, in dem Sinne, dass sie ein Memory Model definiert haben. Und ja, ich weiß, in JavaScript könnte man irgendwelche setTimeout(... try again ...)-Gräuel verwenden, aber … nee).

Ich verstehe ja grob, wo das ganze herkommt: ~„Requests sind asynchron“. Aber das ist ein dünnes Argument eine irreführende Aussage. Asynchron ist alles, worauf man nicht explizit wartet. Eine Webseite über’s Netz anzufordern ist genauso viel oder wenig asynchron, wie eine Datei von der Festplatte zu lesen, oder ein Byte aus dem Speicher.

Wenn das Warten kaschiert werden soll (d.h. wenn die Latenzen versteckt werden sollen), dann kann man das - mit einer vernünftigen Sprache - um jede „synchrone“ Operation ziemlich trivial drumwickeln.

Sowas wie async/await ist schön und idiomatisch und kompakt (und das war oben nur halb sarkastisch). Aber die genaue Umsetzung, und dass sich async-Ness „viral“ im Code verbreitet und die API verseucht (die ansonsten komplett synchron sein könnte) ist … blöd, und wie gesagt, sehr spezifisch für JavaScript.

1 „Gefällt mir“

Und blockt solange den Thread. Dann kannst du dir das auch gleich sparen.

async/await =/= Multithreading. Der Aufruf einer async Methode spawnt keinen Thread, sondern eine Statemachine und erlaubt dem aufrufenden Thread den Code in beliebiger Reihenfolge abzuarbeiten. Mit deiner Methode würde man in C# einen Deadlock provozieren, wenn der async Aufruf den gleichen Kontext hat wie der aufrufende Thread. Das ist z.B. auf dem Main-Thread oder dem UI Thread der Fall, von dem es nur einen gibt.

Diese Unsicherheit würde ich nie programmieren und führt eben zu dem idiomatischen Henne-Ei Konstrukt: Wenn man auf eine async-Methode warten will, ohne den Thread zu blockieren, muss die aufrufende Methode selbst auch async sein…

Es gibt noch andere Lösungen in C#, aber die haben alle ihre Nachteile.

Und blockt solange den Thread. Dann kannst du dir das auch gleich sparen.

Erstens kann man versuchen, den get()-Aufruf so weit wie möglich nach hinten zu verschieben (also z.B. processY so zu verändern, dass es mit einem Future arbeitet), und zweitens kann man versuchen, die Arbeit auf mehrere Futures zu verteilen.

Wenn computeSomehingZ() rein hypothetisch mit async/await umgesetzt wurde - wovon ich in dem Kontext dieses Beitrages ausgehe - dann ist das erstmal ein Deadlock. Völlig egal wann du get() aufrufst. Denn async/await =/= Multithreading, der Thread würde auf sich selbst warten.

In C# müsste man explizit sagen, dass die async Methode nach dem await (also der Callback) auch von einem anderen Thread fortgeführt werden kann. Dann würde das mit get zumindest keinen Deadlock mehr auslösen.

Hier funktioniert das aber nicht, garantierter Deadlock:

public void onButtonClick() {
     Future<String> answer = fetchAnswerFromServer(); // async
     gui.label.setText(answer.get());
}

Nur so offensichtlich wie hier, ist das nicht immer.

Der Thread wird halt erstmal geparkt. Und es gibt nicht notwendigerweise eine Deadlock - das hängt davon ab, wer das future completed. Die Frage nach der Sinnhaftigkeit der Annahme, dass der aufrufende Thread (also der, der das Future bekommt) dafür verantwortlich sein sollte, das Future zu completen, kann man wohl überspringen, und sagen: Natürlich muss das dann ein anderer Thread aus der aufrufende machen. Darauf bezog sich das

Wenn das Warten kaschiert werden soll (d.h. wenn die Latenzen versteckt werden sollen), dann kann man das - mit einer vernünftigen Sprache - um jede „synchrone“ Operation ziemlich trivial drumwickeln.

Ich als Implementierer der Methode entscheide, ob sie blockt oder nicht, oder ob sie einen Thread spawnt oder nicht, oder ob sie Arbeit in einen Pool auslagert oder nicht, oder ob sie ein Future zurückgibt oder nicht. Wenn ich will, dass der aufrufende Thread dort nicht blockiert wird, schreibe ich halt

public Future<T> computeSomethingY() {
    Future<T> future = computeSomethingZ();
    CompletableFuture<T> completable = ...;
    runInOwnTherad(() => {
        T value = future.get();
        processY(value);
        completable.complete(value);
    });
    return completable;
}

(Irgendwann wird’s halt quirky - sowas sollte man sich halt schon vorher überlegen. Aber zumindest hat man alle Möglichkeiten, anstatt die komplette Kette runter mit async/await zupflastern zu müssen).

Schau dir das Beispiel nochmal an:

OnButtonClick wird vom UI-Thread aufgerufen sobald der Nutzer auf einen Button klickt. Da nur der GUI Thread das Label editieren sollte, wird der GUI Thread jetzt solange blockiert, bis das Ergebnis vom Request da ist. Und solange reagiert die GUI nicht mehr. Das ist, mit Verlaub, beschissenes Design…

Eine bessere Möglichkeit wäre es, das Ganze so zu programmieren:

public void onButtonClick() {
     Pool.run( () -> {
          String answer = fetchAnswerFromServer();
          GUIThread.run( () -> { gui.label.setText(answer); } ):
     });
}

Wir übergeben den Request einem eigenen Thread der für uns wartet, und kehren danach auf den GUI Thread zurück. Und eben das kann man mit async/await sehr viel leichter schreiben

public async void onButtonClick() {
     await string answer = fetchAnswerFromServer().ConfigureAwait(true); // zurück zum GUIThread
     gui.label.setText(answer);
}

Wir pausieren die Ausführung der Methode mit await und geben den Thread wieder frei. Der kann sich solange anderen Aufgaben, Events, Animationen widmen (die GUI bleibt responsive). Sobald ein Ergebnis da ist, wird die Ausführung auf dem Thread fortgeführt.

async/await =/= Multithreading.

Ich fang’ mal an, zu zählen: III

Die Problematik bei blockierten UI-Therads ist mir mehr als bewußt (ich hab’ genug Zeit in GitHub - javagl/SwingTasks: Utility classes for task execution in Swing gesteckt).

Hier geht es aber nicht um GUI. Es geht um irgendwas, was halt eine Weile dauert (das kann File-IO, ein Netzwerkrequest, oder eine Berechnung sein), und in der Zwischenzeit kann nichts sinnvolles anderes passieren.

Vielleicht ist es eine sehr grundsätzliche, subjektive Sicht auf die Dinge: Eine Funktion ist für mich erstmal etwas, was ggf. eine Eingabe bekommt, vom aufrufenden Thread ausgeführt wird, und (ggf. mit einem Rückgabewert) beendet wird. Jede Abweichung davon braucht eine Rechtfertigung. (Und die kann sein: Ja, die Funktion wird durch einen Buttonklick ausgelöst, kann 400ms dauern, und in der Zeit soll das UI nicht hängen).

Aber bei praktisch JEDER Funktion besteht die Möglichkeit, dass man sie ~auf einem anderen Thread ausführen" oder ~„nicht auf das Ergebnis warten“ will. Selbst bei sowas wie

static double computeSum(double input[]) {
    double sum = 0;
    for (int i=0; i<input.length; i++) sum += input[i];
    return sum;
}

könnte jemand sagen: Ja neee, ich will nicht diese drölfzig Mikrosekunden warten, machen wir das lieber mit

Future<Double> computeSumAsync(double input[]) {
    return Pool.exec(() -> computeSum(input));
}

Die umgekehrte Richtung ist dann halt

Double computeSumSync(Future<Double> d) {
    return d.get();
}

An jedem Punkt kann man sich das aussuchen. Es ist eine bewußte Entscheidung bei dem Design der API und der Frage, wie sich das eine oder andere als „user experience“ äußert.

1 „Gefällt mir“

Volle Zustimmung, verwendetn man IRGENDWO was asnychrones, dann muss man auch Alles Andere asynchron machen ws dmait in irgendwelchen komishcen Umwegen in verbindung steht…

Und da hat mich kürzlich jemand gefragt wie ich darauf kam auch eine in einer if bedingung benutzte methode asynch zu machen.
Na, weils in 90% der Fälle nötig ist.

Ist genauso ein msit wie in Java wo man irgendwo mal ein Thread.sleep benutzt und man nun erst die betreffende Methoden mit throws Exception beschriften kann.
Und dann die Methode, die diese aufruft, etc.

Also wegen einer scheiß zeile ÜBERALL nun ein throws Exception bei JEDER Methodendeklaration dranschmeissen muss.

Oder man macht halt try catch.

Da werden aus 1 zeile mit 2 wörten dann um die 5 Zeilen.

Wehe, wenn man noch öfter thread.sleep benutzt, so wie ich so alle paar zeilen.
da siehst du nur noch try catches überall.

Oder , was ich mittlerweile mache weils anders scheiße aussieht und unleserlich ist, einfahc gleich in eienr Methode Alles in ein großes try catch.

Erfüllt dann zwar nicht mehr den nie gewollten Sinn, aber egal.

Mein kurzer Input-rant zum Ursprungsrant :slight_smile: