Wirklich keine Möglichkeit für separaten DOM thread?
Michael_K
- javascript
Hallo,
ich bin auf der Suche nach einer Möglichkeit, einen separaten DOM Thread client-seitig im Browser zu nutzen, damit nicht der main Thread blockiert bzw. einfriert.
Im Web Worker steht kein DOM zur Verfügung. Die einzige Möglichkeit aus meiner Sicht wäre, per JS einen neuen Tab zu öffnen und dann via channelcast zu kommunizieren. Diese Möglichkeit möchte ich aber nicht verwenden (weil dann im Browser für den Nutzer ersichtlich ein neuer Tab geöffnet und geschlossen wird).
Gibt es wirklich keine andere Möglichkeit, einen separaten DOM Thread zu generieren, der "nebenbei läuft?
Gruss Michael
PS: in meinem Fall ist es ein Bild-Rendering auf DOM-Strukturbasis in einem iframe, dass sehr intensiv ausfallen kann und dann alles blockiert.
Hallo Michael_K,
Bild-Rendering auf DOM-Strukturbasis
Wenn das zu lange dauert, dann musst du anders rendern. Das DOM ist single-threaded, das ist in allen UI-Systemen, die ich kenne, so.
Magst du erzählen, was du da genau tust? Ggf hätte ich ein paar Ideen
Rolf
Hallo Rolf,
ein Beispiel: ich nutze die JS lib html2canvas um von einem bestimmten node (in einem geladenen iframe mit same origin source) ein Bild zu generieren. Und je nach Komplexität des Nodes ist das sehr rechenintensiv und blockiert dann den Main-Thread sehr lange (Anmerkung: Ich kann den Node bzw. das HTML Dokument nicht beieinflussen - auch wenn es same origin ist - und es ist auch notwendig, dass Bild zu erstellen). Ich kann das Ganze nun in einen anderen Tab auslagern und das Ergebnis dann via Channelcast zurücksenden. Das funktioniert auch. Nur möchte ich es eben vermeiden, dass neue Tabs im Browser geöffnet und automatisch geschlossen werden (auch via channelcast).
Ich verstehe einfach nicht, warum es keinen "Dom Worker" gibt, der für so etwas genutzt werden könnte. Nach meinem Wissen hat doch Firefox und Chromium nun die Grundlagen geschaffen, dass jeder Tab in einem eigenen Thread läuft. Deshalb sollte es doch möglich sein, so eine Art "Shadow tab" bereitzustellen.
Gruss Michael
Hallo Michael,
okay, wenn Du eine Libary mit im Vordergrund laufen lassen willst, dann sind meine Ideen komplett hinfällig. Das ist fertiger Code und der muss laufen, wie er ist.
Ich verstehe einfach nicht, warum es keinen "Dom Worker" gibt, der für so etwas genutzt werden könnte.
Tja, was soll ich dazu jetzt sagen?
Der UI-Thread ist für die Interaktion mit dem Benutzer da. PUNKT. Window-Systeme schicken an ihre Anwendungen Messages, teils mit hoher Frequenz, und sie erwarten, dass diese Messages kurzfristig verarbeitet werden. Die Anwendungen betreiben dafür eine Message Loop:
Ganz grob so:
while (true) {
msg = getmessage();
if (msg.abort) {
break;
}
translateMessage(msg);
dispatchMessage(msg);
}
Wenn zwischen zwei getMessage-Aufrufen zu viel Zeit vergeht (100ms), läuft die Nachrichtenwarteschlange über und es gehen Nachrichten verloren. JavaScript betreibt eine sogenannte Event Loop, die ähnlich abläuft. Während ein Event verarbeitet wird, ist garantiert, dass niemand anderes auf das DOM zugreift. Deshalb und nur deshalb ist es problemlös möglich, in einer Schleife über das Ergebnis von querySelectorAll() oder ähnlichem zu iterieren. Würde ein Hintergrundthread am DOM Änderungen vornehmen, während man eine NodeList oder HTMLCollection durchläuft, würde sie ungültig werden oder sogar zerstört.
Ein Workerthread, der parallel zum Benutzer und den von ihm getriggerten Events auf dem UI herumturnt, ist ein Unfall, der darauf wartet, zu passieren. Einen solchen Thread zu betreiben würde verlangen, dass sowohl UI wie auch Worker jeden DOM Zugriff durch Zugriffsperren serialisieren. Kann man machen, ist aber (a) fehlerträchtig und (b) langsam, wenn man es für jeden einzelnen Zugriff macht. Insbesondere könnte dadurch die Layoutphase des Browsers gruselig blockiert werden.
Ein iframe könnte einen eigenen Thread und eine eigene Message Loop bekommen, um sich vom UI-Thread des einbettenden DOM zu entkoppeln. Aber das ist auch nicht so einfach, weil das DOM eines iframe, der vom gleichen Origin kommt, vom Host-DOM aus erreichbar ist.
Rolf
Hallo Rolf,
ich bin mit den Grundpfeilern von JS und dem wesentlichen Aufbau der JS-Engines vertraut. Bzgl. event loop und blocking UI finde ich immer noch am besten den Beitrag von Jake Archibald. Dessen Präsentation kann ich nur empfehlen, sind 35 sinnvoll investierte Minuten.
Meine Ausgangsfrage zielte darauf ab, ob ich nicht etwas "verpasst" habe und entsprechende Features/Workarounds inzwischen Einzug gehalten haben (denn die modernen Browser könnten es ja praktische schon). Ich würde es für sinnvoll, wenn man so einen headless tab/dom window verfügbar hätte. Aber wenn ich es richtig verstehe, dann gibt es dieses Features noch nicht bzw. müsste ich dann auf electron umschwenken, dort könnte man es sich relativ einfach implementieren. Ich würde aber gerne bei den Standardbrowsern bleiben.
Gruss Michael
Hallo Michael,
danke für den Link. Ich habe bisher nur 10 Minuten gesehen, und im Wesentlichen war mir das auch bekannt. Zum Weitergucken ist es mir jetzt zu spät, aber er nennt da alle meine Argumente, weshalb das UI single-threaded ist.
Es mag sich natürlich etwas getan haben, aber ich habe davon nichts gehört und ich kann mir auch nicht vorstellen, dass es passiert. Das Konzept der Event Loop ist so tief in den Specs und der Browserarchitektur verankert, dass eine Änderung daran die französische Revolution wie einen Klacks erscheinen lässt.
Electron ist eine wilde Ehe von Chromium und Node.js. Dort könntest Du vielleicht einen kopflosen Browser im Hintergrund laufen lassen. Entweder weil Du unter node.js mehr Einfluss darauf hast, ob ein Browsing Context einem Fenster oder einer headless-Simulation zugeordnet ist, oder weil Du unter node.js die Chance hast, einen zweiten Browser im headless im Hintergrund laufen zu lassen. Keine Ahnung, ich habe Electron noch nicht verwendet.
Ich kann das Ganze nun in einen anderen Tab auslagern und das Ergebnis dann via Channelcast zurücksenden
Was zum Geier ist ein Channelcast? Ich habe diesen Begriff nur im Zusammenhang mit einem Podcast-Kanal gefunden. Wenn Du aus Tab A ein Tab B öffnest, dann hast Du eine Messaging-Verbindung zwischen den beiden. D.h. Tab B kann Dir via postMessage() sein Ergebnis zurückschicken. Das wäre das, was mir bekannt ist.
Fazit: Du weißt viel mehr als ich. Ich kann Dir nichts Neues erzählen.
Rolf
Hallo Michael,
ich habe früher das Einfrieren des Browsers verhindert, indem ich Schleifen durch eine Kette von requestAnimationFrame ersetzt habe. Bei einer Schleife mit z.B. 1000 Durchläufen habe ich nach jeweils 100 Durchläufen die Schleife beendet und per requestAnimationFrame die nächsten 100 gestartet.
Dazu habe ich die Schleife in eine Funktion gelegt, die sich am Ende per requestAnimationFrame wieder selbst aufgerufen hat.
Du kannst aber aus dem Worker heraus auch eine Funktion aufrufen, die dann das DOM manipuliert. Bei meiner Seite zur Logistischen Abbildung habe ich das so gemacht.
Gruß
Jürgen
Hallo Jürgen und Michael,
es gibt diverse Ansätze - aber welcher passt, dafür muss man den Anwendungsfall kennen. Darum habe ich danach gefragt.
requestAnimationFrame hängt an der Framerate und am Layout-Zyklus. Ob das bei einer zeitintensiven Hintergrundberechnung ideal ist, weiß ich nicht. Ich würde deshalb lieber setTimeout(func, 0)
nehmen. Ich habe auch mal queueMicrotask probiert, aber wenn eine Funktion sich selbst als Microtask queued, blockiert das UI.
Grundsätzlich ist ein Hintergrundprozess, der mit dem DOM rummacht, gefährlich. Das DOM kann sich durch Benutzerinteraktionen verändern, und der Benutzer kann durch DOM-Änderungen, die der Hintergrundprozess macht, behindert werden. Das DOM ist aus gutem Grund single-threaded. Der Use-Case muss daher sehr speziell sein und der Rest der Seite darauf abgestimmt sein, dass da ein Worker tobt. In Jürgens Beispiel ist das so - der Worker ermittelt, was zu zeichnen ist und seine Effekte sind auf den Canvas begrenzt.
Wenn ein Worker - weshalb auch immer - nicht praktikabel ist, muss man sich den UI-Thread mit dem Benutzer teilen. Und man muss gut überlegen, ob man nicht den Stand des DOM, mit dem man anfängt, als Schnappschuss speichern sollte.
In Windows 2 gab es die yield-Funktion, mit der ein Prozess die CPU freiwillig abgeben konnte. Das gibt's heute nicht mehr, aber es gibt ein yield-Statement in JavaScript. Das versteckt sich in Generatorfunktionen.
Die dienen zwar eigentlich zur Erzeugung einer Wertefolge, aber konzeptionell bieten sie mit dem yield-Befehl zwei wichtige Möglichkeiten:
Hier als Beispiel eine sehr simple Verarbeitungsschleife, die als Generator verpackt ist. Sie durchläuft ein Array und tut pro Element irgendwas. Die Variablen foo und bar speichern übergreifende Daten für die Varbeitung.
Da der Rückgabewert eines Generators ein Iterator ist, kann man keine eigenen Werte zurückgeben. Ein result-Parameter, in dem Rückgabewerte platziert werden können, umgeht das.
Entscheidender Punkt ist die yield-Anweisung, an der der Generator unterbricht und dorthin zurückkehrt, wo next() aufgerufen wurde. Im Generator selbst muss lediglich "oft genug" yield aufgerufen werden. Die Zeitsteuerung wird vom Iterierer übernommen.
function* runComplexThing(data, result) {
let foo = 0, bar = 0;
result.value = undefined;
for (let i=0; i<data.length; i++) {
// data[i] verarbeiten;
if (yield i) break;;
}
}
Ein Generator muss mit einer Iteration durchlaufen werden. Dazu kann man for...of verwenden, aber in einer for...of Schleife lässt sich der JavaScript-Ablauf nicht unterbrechen. Die Alternative zu for...of ist das manuelle Durchlaufen mit der next()-Methode:
const result = {};
const dataWringer = runComplexThing(data, result);
runWringStep(dataWringer, (aborted) => handleResult(result, aborted));
function runWringStep(generator, onComplete) {
// Maximale Step-Zeit: 100ms (beispielsweise)
const stepStart = Date.now();
while (Date.now() - stepStart < 100) {
// Optional: Abbruchbedingung, z.B. Timeout oder Cancel-Button
const abort = /* true um abzubrechen, false zum weitermachen */
// Wert von abort ist Ergebnis von yield
const genValue = generator.next(abort);
// genValue enthält den Iteratorstatus
if (genValue.done) {
onComplete(abort);
return;
}
}
setTimeout(runWringStep, 0, generator, onComplete);
}
Dieses Snippet durchläuft den Generator, bis die Generatorfunktion endet (done
ist dann false). Wenn nach einer Anzahl von next()-Aufrufen zu viel Zeit vergangen ist, wird setTimeout verwendet, um zunächst das UI zu bedienen und den Generator im nächsten Durchlauf der JavaScript-Eventloop weiter zu verarbeiten. Die Zeit von 100ms muss man an den Anwendungsfall anpassen, 100 ist aber schon die Obergrenze für halbwegs flüssiges UI.
Wenn man einen Abbruchmöglichkeit vorsehen will, kann man das auf unterschiedliche Art tun. Im Beispiel übergebe ich ein bool an next()
, was zum Rückgabewert von yield
wird. Statt dessen kann man auch die return()
- oder throw()
-Methode des Generators verwenden.
Ich denke, daraus könnte man einen Wiki-Artikel machen: Vordergrund-Worker mit Generatoren. Dort würde ich das Ergebnis noch in ein Promise verpacken und einen AbortController unterstützen…
Rolf
Hallo Rolf,
ich habe einige Zeit benötigt, bis ich so in etwa verstanden habe, was da passiert. Und bei etwas so Kompliziertem frage ich mich immer: was bringt das an Mehrwert? Was ist der Vorteil von yield etc. gegenüber einer einfachen Variante mit setTimeout. Letztendlich braucht die yield-Variante ja auch setTimeout.
Ich bin ein Freund der Worker-Variante, da man auch mehrere Worker gleichzeitig laufen lassen kann und so mehr als einen CPU-Kern nutzt. Und wenn die Rechnungen im Worker „lange genug“ laufen, blockieren die DOM-Aktualisierungen zwischen den Workerläufen auch nicht den Browser.
Ein Tutorial über CPU-lastige Aufgaben mit yield oder Worker (oder Rechnen auf der Grafikkarte) wäre bestimmt interessant, aber mMn an unserer Zielgruppe vorbei.
Gruß
Jürgen
Hallo JürgenB,
was bringt das an Mehrwert
Bei dieser einfachen Hintergrundaufgabe: nichts.
Aber jetzt stell dir da einen schwierigen Algorithmus vor, bei dem es überhaupt nicht einfach ist, mittendrin abzusetzen und wieder einzusteigen. Einfache repetitive Dinge wie Mandelbrötchen dauern einfach, sind aber nicht kompliziert.
Was ich da zeige, ist letztlich ein Generator als Implementierung einer Coroutine. Aber, ja, unsere Zielgruppe interessiert das nicht 😟
Rolf
Hallo Rolf,
ich kann also eine Schleife unterbrechen und nach einiger Zeit fortsetzen, ohne die Daten zwischen zu speichern?
Gruß
Jürgen
Ich wollte euch beiden noch einmal danken. Das hat mir wirklich geholfen, in kurzer Zeit eine akzeptable Lösung zu finden (+ Extrawissen).
Im Ergebnis habe ich einen Mix aus beiden Lösungen gewählt. Die setTimeout(()=>{},0) ist zwar einfach zu implementieren, hat aber den Nachteil, dass diese die Abarbeitung im Main-Thread erheblich verlangsamen kann (je nachdem, wie oft das setTimeout aufgerufen wird. Im Ergebnis war es bei mir weit über 500% mehr Berechnungszeit.
Der Ansatz von Rolf mit einem Generator ist schick, hat aber den Nachteil, dass man ggfs. an sehr vielen Stellen diesen Mechanismus einbauen muss bzw. die Abläufe so umschreiben muss, dass zunächst alle Aufgaben in einem Stack gesammelt werden und dann die Abarbeitung erfolgt. Es kann das Problem auftreten, dass die Abarbeitung selbst zu lange dauert und man diese dann ggfs. in Untertask aufteien muss. Dies in bestehende Softwarestrukturen einzuarbeiten kann viel Aufwand bedeuten, den Code neu zu schreiben.
Im Ergebnis habe ich mich dazu entschlossen, die angefallene Zeit pro Task in einer lokalen Variable aufzusummieren, am Ende eines Task wird dann überprüft, ob die aufsummierte Zeit einen selbst gesetzten Grenzwert überschreitet. Der Grenzwert von 100ms von Rolf ist da wirklich eine gute Richtgröße. Kurzum:
Sofern die aufsummiert Zeit der bereits durchgeführten Tasks diesen Grenzwert überschreitet, wird der aktuelle Task dann mit setTimeout(()=> { resolve()},0) beendet und die aufsummierte Zeit auf 0 zurückgesetzt, wenn die aufsummierte Zeit noch unter dem Grenzwert liegt, wird der Task mit einem einfache Promise.resolve() abgeschlossen. Das funktioniert erstaunlich gut und hat zudem den Vorteil, dass man sehr flexibel das an verschiednen Stellen einbauen kann. Im Ergebnis läuft es jetzt absolut flüssig und die Berechnung benötigt ca. 20% länger als mit eingefrorenen Main-Thread.
Ich bleibe aber dabei, dass es schön wäre, wenn man zusätzlich zumindest einen "freezed" Dom erstellen könnte (ausserhalb vom main-thread), damit man so etwas noch performanter lösen kann. In meiner aktuellen Lösung "langweilen" sich die CPU cores, die nicht den main-thread abarbeiten.
Gruss Michael
Hallo Rolf,
ich musste jetzt auch erst einmal dein Beispiel durchdringen. Das ist schon abgefahren! Vielen Dank, das ist wirklich hilfreich. Mit Generatoren hatte ich mich noch nicht wirklich beschäftigt.
Bevor ich mir das jetzt zu Nutze mache, zwei kleine Rückfragen ... wenn ich darf:
Der Main-Thread würde trotzdem einfrieren, wenn die Abarbeitung pro Iterations-Schritt zu lange dauert?
Kann man einen Generator ( in deinem Beispiel runComplexThing() ) auch so aufbauen, dass er sicht selbst mit Paramterübergabe aufruft? Oder anders gefragt, wenn man rekursive eine große "Baumstruktur" durchläuft, kann dafür auch ein Generator genutzt werden, oder müsste man in solchen Fällen die Arbeitsschritte vorher "sammeln", um dann mit for(...) oder while(...) abzuarbeiten?
Gruß Michael
Hallo Michael_K,
ad 1: Ja
ad 2: Ein Generator kann einen weiteren Generator erzeugen und diesen iterieren, das geht. Die Aufgabe ist dann nur, die inneren Generatoren am Arbeiten zu halten, d.h. der innere Generator muss irgendeine Wertefolge erzeugen, die der äußere Generator mit einer Iterationsschleife abholt.
Tatsächlich ist das eine so häufige Aufgabe, dass JavaScript dafür den Syntaxzucker yield* anbietet.
const xyz = createSubGenerator();
// Entweder so:
yield* xyz;
// Oder so, wenn meine Abort-Idee beibehalten werden soll:
for (const value of xyz) {
if (yield value) break;
}
Oder anders - je nachdem, wie Du die Untersequenzen verarbeiten musst. Möglicherweise muss man noch feintunen, ich habe das jetzt ziemlich aus der Hüfte geschossen.
Der Teil, der die Laufzeit überwacht und nach zu langer Verarbeitung eine Runde in der Eventloop einschiebt, sollte unverändert bleiben können. Die ganze yielderrei dient ja nicht dazu, eine echte Wertefolge zu erzeugen, sondern Pausierungspunkte in der Coroutine zu ermöglichen. In PHP würde man das mit Fibers machen.
Aber ob Dir das alles was nützt? Ist html2canvas denn so designed, dass Du mit yield etwas anfangen kannst? Der yield muss ja direkt in der Generatorfunktion stecken. Eine Generatorfunktion, die html2canvas aufruft und dann in irgendwelchen Callbacks von html2canvas yield machen will, das geht nicht.
Grund: wenn ein yield stattfindet, muss der komplette Status der Generatorfunktion festgehalten werden. Dann endet sie. Wenn auf dem Generatorobjekt next() aufgerufen wird, wird der Status wiederhergestellt und die Ausführung fortgesetzt. Das geht aber nur, wenn der JavaScript-Code dafür passend übersetzt wurde. Und es geht gar nicht, wenn zwischen Generator und yield noch irgendwas auf dem Stack rumliegt.
Programmiere mal einen Iterator von Hand. Dann weißt Du, wie sehr man sich die Finger bricht, um so etwas von Hand zu realisieren. JavaScript muss deinen Generator unter der Haube in eine solche Struktur umsetzen.
Rolf
Mal schauen, der Quellcode von html2canvas ist gut sortiert.
Die mit Abstand meiste Zeit (ca. 2/3 der aktuellen freezetime) geht bei einer tree parser function drauf, bei der vom Root Node rekursiv alle childNodes durchlaufen werden, um sich die geclonten CSS Styles vom DOM abzuholen. Wenn ich es da schaffe, da eine Coroutine einzubauen, dann wäre schon sehr viel geholfen. Es gibt danach noch eine rendering function (ca. 1/3 freezetime), die habe ich noch nicht ganz verstanden, aber soweit ich es überflogen habe, könnte man die sogar in einen web worker auslagern, der dann stetig an den main thread funkt, um das canvas zu zeichnen.
Eine andere Lösung wäre, die html2canvas lib komplett umzuschreiben, sodass html2canvas in einem web worker arbeitet und die benötigten DOM Informationen immer bei Bedarf vom main thread anfragt. Müsste so funktionieren:
Dies scheint mir die elegantere Lösung, allerdings müsste ich da viel Hand an html2canvas anlegen und die ganzen HTML types neu definieren. Ziemlich viel Holz.
Michael