1unitedpower: Projektvorstellung: MVar - low-level Bibliothek für Nebenläufigkeit

Beitrag lesen

problematische Seite

Vielen Dank euch Dreien für das Feedback. Es ist auf jedenfall klar geworden, dass da noch Erklärungsbedarf herrscht. Ich überlege mir derzeit noch eine sinnvolle Metapher und Anschungsbeispiele, die die dem Verständnis dienen.

Und ein ganz besonderer Dank an @Rolf B für den ausührlichen Code-Review.

ich habe mir das jetzt mal näher angeschaut, und, nein, ich verstehe den Sinn noch nicht so ganz. Es sei denn, der Zweck ist es, genau zwei parallel laufende Aktionen irgendwie zu syncen.

Vorweg: Die Library beschäftigt sich mit Concurrency, nicht mit Parallelität. Die beiden Themen werden in der Literatur leider häufig miteinander vermengt. Concurrency ist die Eigenschaft eines Programms verschiedene Code-Pfade ungeordnet auszuführen ohne das Endergebnis zu beeinträchtigen. In JavaScript konkurrieren asynchrone Tasks um die Zugriffe auf die geteilten Variablen. Diese konkurrierenden Zugriffe so zu ordnen, dass das finale Ergebnis deterministisch wird, nennt man Synchronisierung. MVar stellt dafür einen primitiven Datentypen bereit.

Deine MVars scheinen aber mit Workern nichts am Hut zu haben, insbesondere kannst Du eine MVar nicht dazu bringen, auf Messages von echten Workern zu lauschen (bzw. dafür müsste man einen eigenen Adapter zimmern).

Die Beobachtung ist richtig. Man kann MVars nutzen, um so einen Adapter zu bauen, aber das wäre eine höher geschichtete Aufgabe, und MVar ist gewollt eine low-level Bibliothek.

Eine andere Form von Nebenläufigkeit sind Generatorfunktionen. Hier ist es aber so, dass der Generator immer erst dann weiterläuft, wenn der Konsument das nächste Element anfordert. Ein Generator, der das nächste Element schonmal in einem Worker ermittelt, während der Konsument das vorige Element noch verarbeitet, das wär was cooles. Kann man das mit MVars realisieren?

Das kommt drauf an, wo siehst du hier konkurrierende Tasks?

Dein Einsatzbeispiel zeigt, wie man mehrere Eventquellen in einen gemeinsamen Event-Strom steckt (bzw. Actions in deinem Fall), um sie nachher per if wieder auseinanderzuklamüsern. Ja, ich verstehe, das ist eine einfache Demonstration, wie man MVars benutzen kann. Aber ein Beispiel, wo die Motivation klarer ist, wäre schon gut.

Danke, ich überlege mir was.

Dein API und deine Implementierung werfen bei mir ebenfalls Fragen auf. Nimm das bitte nicht als "Oh Mann ist das ein Scheiß", sondern einfach als Liste von Unklarkeiten, die ich beim Lesen des Codes empfunden habe.

Danke, und keine Sorge, ich weiß deinen Code-Review wirklich zu schätzen.

  • Warum MVar.newEmpty() und MVar.new(x)? Das ist doch ein Konstruktor. Warum machst Du den vorhandenen Konstruktor private und klemmst statische Methoden davor? Das Verpacken in ein Array und das Bereitstellen der leeren TaskQueue kann der Konstruktor auch erledigen.

Das war eine bewusste Design-Entscheidung, die ich getroffen habe, um die Schnittstelle möglichst ähnlich zu Haskell zu halten. Dort gibt es die beiden Funktionen newMVar und newEmptyMVar. Der Konstruktor ist in Haskell ebenfalls privat, dort ist es eine technische Notwendigkeit.

  • Mir ist unklar, was MVar tut, wenn ich zwei put() mache ohne dass ein take() gelaufen ist. Kann MVar das?

Die beiden Werte würden in die Queue aufgenommen und sonst würde nichts weiter passieren. Das ist als stellst du dich im Supermakrt vor eine unbesetzte Kasse und irgendwann stellt sich jemand hinter dich.

  • deine Actionqueue ist merkwürdig. Warum verwendest Du keine Polymorphie? Statt dessen fragst Du ab, ob das Actionelement read, take oder swap enthält. Deine Actions sind private, also dürfen sie auch private Dinge tun. Siehe meinen Codevorschlag weiter unten.

Polymorphie wäre hier mit Kanonen auf Spatzen geschossen. Es gibt genau drei Operationen, die zu beareiten sind. TypeScript kann sicherstellen, dass eine Verzweigung auf einem Wert von Typen Task, alle Fälle erschöpfend abdeckt. Polymoprhie hingegen erlaubt eine unbegrenzte Anzahl an Fällen. Auf deinen Code komme ich später nochmal zurück.

  • swap() tut nicht was sein Name verspricht. Es tauscht nicht. Es stellt den Austauschwert ans Ende der putQueue, statt den gelesenen Wert zu ersetzen. Der Name passt nicht.

Das ist auch wieder aus Kompatibilität zu Haskell. Die Semantik in Haskell ist, dass Operationen immer nach dem FIFO-Prinzip ausgeführt werden. Eine Swap soll sich verhalten, wie take gefolgt von einem put.

  • Die Promise-Implementierung und die Do-It-Now Implementierung von Swap unterscheiden sich in der Reihenfolge der Teilaktionen. Nach reiflicher Überlegung bin ich zwar drauf gekommen, dass das egal sein sollte, aber besserer Stil wäre, die Reihenfolge gleich zu halten (lese queue, schreibe queue, resolve Promise)

Stimmt.

  • Warum wirft tryTake eine Exception? Try-Funktionen haben doch gerade den Sinn, Exceptions zu vermeiden.

Auch wieder aus Haskell-Kompatibilität: Die drei Funktionen können fehlschlagen, in Haskell wird der Erfolg bzw. Misserfolg durch einen Rückgabewert vom Typ Mabye bzw. Bool modelliert. In einem ersten Entwurf hatte ich das auch so implementiert, und bin dann auf Exceptions umgestiegen, weil Maybes in JavaScript eher unbekannt sind. Besonders unschön finde ich, dass tryPut nun einen boolschen Rückgabe-Wert hat und keine Exception schmeißt. Ich habe verschiedene Designs ausprobiert, und bin dann mit diesem verblieben, weil es mir am wenigsten Bauchschmerzen bereitete, nicht weil es besonders elegant wäre.

Und da JavaScript singlethreaded konzipiert ist: Warum gibt's tryTake() überhaupt? Eine Abfolge von isEmpty() und take() ist nicht unterbrechbar, weil Threads nur per Message kommunizieren und es keinen thread-übergreifenden Speicher gibt. Warum also eine künstliche und scheinbar atomare tryTake Methode?

Ich sehe für die try-Methoden und isEmpty auch keinen großen Nutzen; das sind Escape-Hatches, und deshalb habe ich sie übernommen. Sie könnten nützlich sein, wenn jemand einen einen Scheduler basierend auf requestAnimiationFrame oder setInterval bauen möchte. GHCJS macht das bswp. so.

  • Warum heißen die beiden überhaupt take und read? Unter read versteht man normalerweise eine konsumierende Funktion. Eine nichtkonsumierende Lesefunktion sollte peek() heißen.

Dem Wortsinn nach finde ich take und read auch passend. Wenn ich ein Buch aus einem Regal nehme, befindet es sich nicht mehr im Regal. Wenn ich ein Buch lese, dann ist es danach nicht weg. Aber ja, die Kovention mit peek ist mir auch bekannt, ich weiß nicht, warum man sich damals dafür entschieden hat, die Funktion read zu nennen, ob den Autoren die Konvention nicht bekannt war, oder ob sie absichtlich davon abgewichen sind. Für mich spielt das keine Rolle, weil eins meiner Design-Ziele ist möglichst nah bei der Haskell-Nomenklatur zu bleiben.

Und weil eine MVar nicht viel anderes als eine Queue ist, wären enqueue() und dequeue() wohl die besseren Namen für put() und take(), denn das sind die bekannten Operationsnamen auf dem ADT Queue.

dequeue verhält sich anders als take, wenn die Queue (respektive die MVar) leer ist. Außerdem ähnelt eine MVar auch anderen Datentypen, bspw. einem Channel, wobei take einem receive und put einem send entspricht. Oder einem Semaphor mit take als wait und put als signal.

Hier nun der Vorschlag für eine Implementierung mit Polymorphie in der put-Methode und weitgehender Abstraktion des Queue-Handlings. Ich habe deinen Stil, Temp-Variablen zu bilden, beibehalten. Man könnte drauf verzichten und etliche Methoden zu Einzeilern machen (und sich dabei Debug-Hürden einfangen). Dafür habe ich es so gemacht, dass die drei Handler-Funktionen sowohl die Promise- als auch die Direktimplementierung bilden. Ich weiß nur nicht, wie man dafür die Typisierung in Typescript angeben muss. Die performAction Methode fiel mir zum Schluss ein, als ich bemerkte, dass read, take und swap nur noch aus Boilerplate-Code bestanden, die den Action-Handler entweder direkt oder via Promise aufriefen.

Danke, ich sehe woher der Wind weht. Deine Implementierung folgt auf jedem Fall dem DRY-Prinzip, das finde ich gut. Ob ich das tatsächlich verständlicher finde, weiß ich noch nicht. Ich werde das mal so implementieren und dann nochmal Feedback geben.

Das Handling von Swap finde ich übrigens bemerkenswert komplex. Ich kann auf einer leeren MVar zweimal Swap aufrufen. Beide gehen erstmal in die Taskqueue. Nun schiebe ich einen Wert in die MVar und der Pingpong geht los. Der erste Swap liest den Wert und schreibt einen neuen. Der triggert den anderen Swap, der wiederum den ersten Swap triggert. Da JS je nach Implementierung keine Tail-Rekursion macht, und in deiner Put-Methode wegen der while-Schleife auch keine Tail-Rekursion greifen wird, läuft das Ratzfatz auf einen Stackoverflow.

Hmm. Würde es dir was ausmachen, dafür mal einen Testfall bei Stackblitz oder JSFiddle zu programmieren? Ich habe das auf Anhieb nicht nachstellen können, aber das könnte ein Bug in meiner Implementierung sein.