Kapitel 4. Gemeinsamer Speicher

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Bisher hast du die Web Worker API für Browser ( Kapitel 2) und das Worker Threads Modul für Node.js (Kapitel "Das worker_threads Modul") kennengelernt. Dies sind zwei mächtige Werkzeuge für die Arbeit mit Parallelität in JavaScript, die es Entwicklern ermöglichen, Code auf eine Art und Weise parallel laufen zu lassen, die JavaScript bisher nicht zur Verfügung stand.

Allerdings war die Interaktion, die du bisher mit ihnen hattest, ziemlich oberflächlich. Es stimmt zwar, dass sie es dir ermöglichen, Code parallel laufen zu lassen, aber du hast dies nur mit Hilfe von Message-Passing-APIs getan und warst letztendlich auf die bekannte Ereignisschleife angewiesen, um den Empfang einer Nachricht zu verarbeiten. Das ist ein viel weniger leistungsfähiges System als der Threading-Code, mit dem du in "Threads in C" gearbeitet hast : Get Rich with Happycoin" gearbeitet hast, bei dem diese verschiedenen Threads auf denselben gemeinsamen Speicher zugreifen können.

In diesem Kapitel werden zwei leistungsstarke Werkzeuge vorgestellt, die deinen JavaScript-Anwendungen zur Verfügung stehen: das Atomics Objekt und die SharedArrayBuffer Klasse. Sie ermöglichen es dir, Speicher zwischen zwei Threads zu teilen, ohne auf die Weitergabe von Nachrichten angewiesen zu sein. Doch bevor wir uns in eine vollständige technische Erklärung dieser Objekte stürzen, ist ein kurzes Einführungsbeispiel angebracht.

In den falschen Händen können die hier vorgestellten Tools gefährlich sein und logikfeindliche Fehler in deine Anwendung einbringen, die während der Entwicklung im Verborgenen schlummern und erst in der Produktion zum Vorschein kommen. Aber wenn sie richtig eingesetzt werden, können diese Werkzeuge deine Anwendung zu neuen Höhenflügen verhelfen und nie dagewesene Leistungswerte aus deiner Hardware herausholen.

Einführung in den geteilten Speicher

In diesem Beispiel wirst du eine sehr einfache Anwendung erstellen, die zwischen zwei Web Workern kommunizieren kann. Das erfordert zwar anfangs ein wenig Boilerplate mit postMessage() und onmessage, aber spätere Aktualisierungen werden diese Funktionen nicht mehr benötigen.

Dieses Shared-Memory-Beispiel funktioniert sowohl im Browser als auch in Node.js, auch wenn der Aufwand für die Einrichtung in beiden Fällen ein wenig anders ist. Im Moment wirst du ein Beispiel erstellen, das im Browser funktioniert, und es wird viel beschrieben. Später, wenn du dich ein bisschen besser auskennst, wirst du ein Beispiel mit Node.js erstellen.

Gemeinsamer Speicher im Browser

Um loszulegen, erstelle ein weiteres Verzeichnis mit dem Namen ch4-web-workers/, in dem du dieses Projekt unterbringst. Dann erstellst du eine HTML-Datei mit dem Namen index.html und fügst den Inhalt aus Beispiel 4-1 hinzu.

Beispiel 4-1. ch4-web-workers/index.html
<html>
  <head>
    <title>Shared Memory Hello World</title>
    <script src="main.js"></script>
  </head>
</html>

Sobald du mit dieser Datei fertig bist, kannst du dich dem komplizierteren Teil der Anwendung zuwenden. Erstelle eine Datei mit dem Namen main.js, die den Inhalt aus Beispiel 4-2 enthält.

Beispiel 4-2. ch4-web-workers/main.js
if (!crossOriginIsolated) { 1
  throw new Error('Cannot use SharedArrayBuffer');
}

const worker = new Worker('worker.js');

const buffer = new SharedArrayBuffer(1024); 2
const view = new Uint8Array(buffer); 3

console.log('now', view[0]);

worker.postMessage(buffer);

setTimeout(() => {
  console.log('later', view[0]);
  console.log('prop', buffer.foo); 4
}, 500);
1

Wenn crossOriginIsolated wahr ist, dann kann SharedArrayBuffer verwendet werden.

2

Richtet einen 1 KB großen Puffer ein.

3

Ein Blick in den Puffer wird erstellt.

4

Eine geänderte Eigenschaft wird gelesen.

Diese Datei ähnelt der Datei, die du zuvor erstellt hast. Sie nutzt immer noch einen eigenen Arbeiter. Aber es wurden ein paar komplexe Funktionen hinzugefügt. Die erste Neuerung ist die Überprüfung von auf den Wert crossOriginIsolated, eine globale Variable, die in modernen Browsern verfügbar ist. Dieser Wert sagt dir, ob der JavaScript-Code, der gerade ausgeführt wird, unter anderem in der Lage ist, eine SharedArrayBuffer Instanz zu instanziieren.

Aus Sicherheitsgründen (im Zusammenhang mit dem Spectre-CPU-Angriff) ist das Objekt SharedArrayBuffer nicht immer für die Instanziierung verfügbar . Vor ein paar Jahren haben die Browser diese Funktion sogar komplett deaktiviert. Jetzt unterstützen sowohl Chrome als auch Firefox das Objekt und verlangen, dass beim Ausliefern des Dokuments zusätzliche HTTP-Header gesetzt werden, bevor ein SharedArrayBuffer instanziiert werden kann. Node.js hat nicht die gleichen Einschränkungen. Hier sind die erforderlichen Header:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Der Testserver, den du laufen lässt, setzt diese Header automatisch. Jedes Mal, wenn du eine produktionsreife Anwendung erstellst, die SharedArrayBuffer Instanzen verwendet, musst du dich daran erinnern, diese Header zu setzen.

Nachdem ein dedizierter Worker instanziiert wurde, wird auch eine Instanz von SharedArrayBuffer instanziiert. Das folgende Argument, in diesem Fall 1.024, ist die Anzahl der Bytes, die dem Puffer zugewiesen werden. Im Gegensatz zu anderen Arrays oder Pufferobjekten, die du vielleicht kennst, können diese Puffer nicht verkleinert oder vergrößert werden, nachdem sie erstellt worden sind.1

Es wurde auch eine Ansicht für den Puffer mit dem Namen view erstellt. Solche Ansichten werden ausführlich in "SharedArrayBuffer und TypedArrays" behandelt, aber im Moment kannst du sie als eine Möglichkeit betrachten, von einem Puffer zu lesen und in ihn zu schreiben.

Dieser Einblick in den Puffer ermöglicht es uns, mit der Array-Index-Syntax aus dem Puffer zu lesen. In diesem Fall können wir das 0. Byte im Puffer untersuchen, indem wir einen Aufruf an view[0] protokollieren. Danach wird die Instanz des Puffers mit der Methode worker.postMessage() an den Worker übergeben. In diesem Fall ist der Puffer das Einzige, was übergeben wird. Es hätte aber auch ein komplexeres Objekt übergeben werden können, bei dem der Puffer eine der Eigenschaften ist. Während der im Anhang beschriebene Algorithmus meist komplexe Objekte verschluckt, sind Instanzen von SharedArrayBuffer eine bewusste Ausnahme.

Sobald das Skript mit den Einrichtungsarbeiten fertig ist, plant es eine Funktion, die in 500 ms ausgeführt wird. Dieses Skript druckt erneut das 0. Byte des Puffers aus und versucht außerdem, eine an den Puffer angehängte Eigenschaft namens .foo zu drucken. Beachte, dass diese Datei ansonsten keinen worker.onmessage Handler definiert hat.

Jetzt, wo du mit der Haupt-JavaScript-Datei fertig bist, kannst du den Worker erstellen. Erstelle eine Datei namens worker.js und füge den Inhalt aus Beispiel 4-3 hinzu.

Beispiel 4-3. ch4-web-workers/worker.js
self.onmessage = ({data: buffer}) => {
  buffer.foo = 42; 1
  const view = new Uint8Array(buffer);
  view[0] = 2; 2
  console.log('updated in worker');
};
1

Eine Eigenschaft des Pufferobjekts wird geschrieben.

2

Der 0. Index wird auf die Zahl 2 gesetzt.

Diese Datei fügt einen Handler für das Ereignis onmessage hinzu, der ausgeführt wird, nachdem die Methode .postMessage() in main.js ausgelöst wurde. Sobald er aufgerufen wird, wird das Pufferargument abgegriffen. Daserste, was im Handler passiert, ist, dass eine .foo Eigenschaft an dieSharedArrayBuffer Instanz. Danach wird eine weitere Ansicht für den Puffer erstellt. Danach wird der Puffer über die Ansicht aktualisiert. Danach wird eine Meldung ausgegeben, damit du sehen kannst, was passiert ist.

Jetzt, da deine Dateien vollständig sind, kannst du deine neue Anwendung starten. Öffne ein Terminalfenster und führe den folgenden Befehl aus. Er unterscheidet sich ein wenig von den serve Befehlen, die du zuvor ausgeführt hast, weil er die Sicherheits-Header bereitstellen muss:

$ npx MultithreadedJSBook/serve .

Wie zuvor öffnest du den Link, der in deinem Terminal angezeigt wird. Öffne dann den Webinspektor und rufe die Registerkarte Konsole auf. Es kann sein, dass du keine Ausgabe siehst. Wenn das der Fall ist, aktualisiere die Seite und führe den Code erneut aus. Du solltest Logs sehen, die von der Anwendung gedruckt werden. Ein Beispiel für die Ausgabe ist in Tabelle 4-1 wiedergegeben.

Tabelle 4-1. Beispiel Konsolenausgabe
Logge Standort

jetzt 0

main.js:10:9

aktualisiert in Arbeiter

worker.js:5:11

später 2

main.js:15:11

prop undefined

main.js:16:11

Die erste gedruckte Zeile ist der Anfangswert des Puffers, wie er in main.js zu sehen ist. In diesem Fall ist der Wert 0. Als Nächstes wird der Code in worker.js ausgeführt, wobei der Zeitpunkt dafür meist unbestimmt ist. Ungefähr eine halbe Sekunde später wird der Wert, der in main.js zu sehen ist, erneut ausgegeben, und der Wert ist jetzt 2. Auch hier ist zu beachten, dass abgesehen von den anfänglichen Vorbereitungsarbeiten keine Nachrichten zwischen dem Thread, der die Datei main.js ausführt, und dem Thread, der die Datei worker.js ausführt, ausgetauscht werden.

Hinweis

Dies ist ein sehr einfaches Beispiel, das zwar funktioniert, aber nicht so, wie du normalerweise Multithreading-Code schreiben würdest. Es gibt keine Garantie dafür, dass der in worker.js aktualisierte Wert auch in main.js sichtbar ist. Eine clevere JavaScript-Engine könnte den Wert zum Beispiel als Konstante behandeln, aber du wirst kaum einen Browser finden, in dem das nicht passiert.

Nachdem der Wert des Puffers gedruckt wurde, wird auch die Eigenschaft .foo gedruckt und ein Wert vonundefined wird angezeigt. Woran kann das liegen? Es stimmt zwar, dass die beiden JavaScript-Umgebungen einen Verweis auf den Speicherplatz, in dem die Binärdaten des Puffers gespeichert sind, gemeinsam genutzt haben, aber das eigentliche Objekt wurde nicht gemeinsam genutzt. Wäre das der Fall gewesen, hätte dies gegen die Einschränkung des strukturierten Klon-Algorithmus verstoßen, wonach Objektreferenzen nicht zwischen Threads geteilt werden dürfen.

Gemeinsamer Speicher in Node.js

Das Node.js-Pendant dieser -Anwendung ist größtenteils ähnlich; allerdings ist die von Browsern zur Verfügung gestellte globale Worker nicht verfügbar und der Worker-Thread macht keinen Gebrauch von self.onmessage. Stattdessen muss das Worker-Thread-Modul benötigt werden, um Zugriff auf diese Funktionalität zu erhalten. Da Node.js kein Browser ist, ist die Datei index.html nicht anwendbar.

Um ein Node.js-Pendant zu erstellen, brauchst du nur zwei Dateien, die du in denselben Ordner wie ch4-web-workers/ verschieben kannst, den du schon benutzt hast. Erstelle zunächst ein main-node.js-Skript und füge den Inhalt aus Beispiel 4-4 hinzu.

Beispiel 4-4. ch4-web-workers/main-node.js
#!/usr/bin/env node

const { Worker } = require('worker_threads');
const worker = new Worker(__dirname + '/worker-node.js');

const buffer = new SharedArrayBuffer(1024);
const view = new Uint8Array(buffer);

console.log('now', view[0]);

worker.postMessage(buffer);

setTimeout(() => {
  console.log('later', view[0]);
  console.log('prop', buffer.foo);
  worker.unref();
}, 500);

Der Code ist ein wenig anders, aber er sollte dir weitgehend vertraut sein. Da die globale Variable Worker nicht verfügbar ist, wird stattdessen auf sie zugegriffen, indem die Eigenschaft .Worker aus dem erforderlichen Modul worker_threads gezogen wird. Bei der Instanziierung des Workers muss ein expliziterer Pfad zum Worker angegeben werden, als er von den Browsern akzeptiert wird. In diesem Fall war der Pfad./worker-node.js erforderlich, auch wenn die Browser mit der Datei worker.js auskommen. Abgesehen davon ist die Haupt-JavaScript-Datei für dieses Node.js-Beispiel im Vergleich zum Browser-Äquivalent weitgehend unverändert. Der letzte Aufruf von worker.unref() wurde hinzugefügt, um zu verhindern, dass der Worker den Prozess ewig laufen lässt.

Als Nächstes erstellst du eine Datei namens worker-node.js, die das Node.js-Pendant des Browser-Workers enthält. Füge den Inhalt aus Beispiel 4-5 in diese Datei ein.

Beispiel 4-5. ch4-web-workers/worker-node.js
const { parentPort } = require('worker_threads');

parentPort.on('message', (buffer) => {
  buffer.foo = 42;
  const view = new Uint8Array(buffer);
  view[0] = 2;
  console.log('updated in worker');
});

In diesem Fall ist der Wert self.onmessage für den Arbeiter nicht verfügbar. Stattdessen wird wieder das Modul worker_threads benötigt, und die Eigenschaft .parentPort des Moduls wird verwendet. Diese wird verwendet, um eine Verbindung zum Port aus der aufrufenden JavaScript-Umgebung darzustellen.

Der .onmessage Handler kann dem parentPort Objekt zugewiesen werden, und die Methode.on('message', cb) kann aufgerufen werden. Wenn du beide verwendest, werden sie in derReihenfolge aufgerufen, in der sie verwendet wurden. Die Callback-Funktion für das Ereignis message erhält das übergebene Objekt (in diesem Fallbuffer ) direkt als Argument, während deronmessage Handler eine MessageEvent Instanz mit einer .data Eigenschaft bereitstellt, die buffer enthält. Welchen Ansatz du verwendest, hängt hauptsächlich von deinen persönlichen Vorlieben ab.

Abgesehen davon ist der Code in Node.js und im Browser genau derselbe, die gleichen anwendbaren Globals wie SharedArrayBuffer sind immer noch verfügbar und sie funktionieren für dieses Beispiel auch genauso.

Jetzt, wo diese Dateien vollständig sind, kannst du sie mit diesem Befehl ausführen:

$ node main-node.js

Die Ausgabe dieses Befehls sollte mit der Ausgabe in Tabelle 4-1 übereinstimmen, wie sie im Browser angezeigt wird. Auch hier können mit dem gleichen strukturierten Klon-Algorithmus Instanzen von SharedArrayBuffer weitergegeben werden, allerdings nur die zugrundeliegenden binären Pufferdaten, nicht ein direkter Verweis auf das Objekt selbst.

SharedArrayBuffer und TypedArrays

Traditionell unterstützte die Sprache JavaScript nicht wirklich die Interaktion mit binären Daten. Sicher, es gab Strings, aber diese abstrahierten den zugrunde liegenden Mechanismus der Speicherung von Daten. Es gab auch Arrays, aber die können Werte beliebigen Typs enthalten und sind nicht geeignet, um binäre Puffer darzustellen. Viele Jahre lang war das "gut genug", vor allem bevor Node.js aufkam und JavaScript auch außerhalb von Webseiten eingesetzt wurde.

Die Node.js-Laufzeitumgebung ist unter anderem in der Lage, das Dateisystem zu lesen und zu beschreiben, Daten ins und aus dem Netzwerk zu streamen und so weiter. Solche Interaktionen beschränken sich nicht nur auf ASCII-basierte Textdateien, sondern können auch die Weiterleitung von Binärdaten umfassen. Da es keine geeignete Datenstruktur für Puffer gab, haben die Autoren ihre eigene entwickelt. Das war die Geburtsstunde von Node.js Buffer.

In dem Maße, wie die Grenzen der JavaScript-Sprache selbst erweitert wurden, wuchsen auch die APIs und die Möglichkeiten der Sprache, mit der Welt außerhalb des Browserfensters zu interagieren. Schließlich wurden das ArrayBuffer Objekt und später das SharedArrayBuffer Objekt geschaffen, die heute ein Kernbestandteil der Sprache sind. Wenn Node.js heute entwickelt würde, hätte es wahrscheinlich keine eigene Buffer Implementierung geschaffen.

Die Instanzen von ArrayBuffer und SharedArrayBuffer stellen einen Puffer mit Binärdaten dar, der eine feste Länge hat und dessen Größe nicht verändert werden kann. Die beiden sind sich zwar sehr ähnlich, aber in diesem Abschnitt geht es um die letztere Variante, weil sie es Anwendungen ermöglicht, Speicher über Threads hinweg gemeinsam zu nutzen. Obwohl binäre Daten in vielen traditionellen Programmiersprachen wie C allgegenwärtig und ein erstklassiges Konzept sind, können sie leicht missverstanden werden, besonders von Entwicklern, die Hochsprachen wie JavaScript verwenden.

Nur für den Fall, dass du noch keine Erfahrung damit hast: Das Binärsystem ist ein auf 2 basierendes Zählsystem, das auf der untersten Ebene durch 1en und 0en dargestellt wird. Jede dieser Zahlen wird als ein Bit bezeichnet. Das Dezimalsystem, das die meisten Menschen zum Zählen verwenden, basiert auf 10 und wird mit Ziffern von 0 bis 9 dargestellt. Eine Kombination aus 8 Bits wird als Byte bezeichnet und ist oft der kleinste adressierbare Wert im Speicher, da er in der Regel einfacher zu handhaben ist als einzelne Bits. Grundsätzlich bedeutet das, dass CPUs (und Programmierer) mit Bytes statt mit einzelnen Bits arbeiten.

Diese Bytes werden auf oft als zwei hexadezimale Zeichen dargestellt. Dabei handelt es sich um ein 16-basiertes Zählsystem, das die Ziffern 0-9 und die Buchstaben A-F verwendet. Wenn du eine Instanz von ArrayBuffer mit Node.js protokollierst, zeigt die Ausgabe den Wert des Puffers in hexadezimaler Form an.

Bei einer beliebigen Menge von Bytes, die auf der Festplatte oder sogar im Speicher eines Computers gespeichert ist, ist es etwas unklar, was die Daten bedeuten. Was könnte zum Beispiel der hexadezimale Wert 0x54 (das Präfix 0x in JavaScript bedeutet, dass der Wert in hexadezimaler Form vorliegt) bedeuten? Wenn er Teil einer Zeichenkette ist, könnte er für den Großbuchstaben T stehen. Wenn er jedoch eine ganze Zahl darstellt, könnte er für die Dezimalzahl 84 stehen. Es kann sich aber auch auf einen Speicherplatz, einen Teil eines Pixels in einem JPEG-Bild oder auf etwas anderes beziehen. Der Kontext ist hier sehr wichtig. Dieselbe Zahl sieht in binärer Darstellung aus wie 0b01010100 (das Präfix 0b steht für binär).

Um diese Zweideutigkeit im Hinterkopf zu behalten, ist es auch wichtig zu erwähnen, dass der Inhalt eines ArrayBuffer (und SharedArrayBuffer) nicht direkt geändert werden kann. Stattdessen muss zuerst ein "Blick" auf den Puffer erstellt werden. Im Gegensatz zu anderen Sprachen, die den Zugriff auf den verlassenen Speicher ermöglichen, wird der Inhalt des Puffers bei der Instanziierung von ArrayBuffer in JavaScript auf 0 initialisiert. Wenn man bedenkt, dass diese Pufferobjekte nur numerische Daten speichern, sind sie wirklich ein sehr einfaches Werkzeug für die Speicherung von Daten, auf dem kompliziertere Systeme oft aufbauen.

Sowohl ArrayBuffer als auch SharedArrayBuffer erben von Object und verfügen über die entsprechenden Methoden. Darüber hinaus verfügen sie über zwei Eigenschaften. Die erste ist der schreibgeschützte Wert .byteLength, der die Bytelänge des Puffers angibt, und die zweite ist die Methode .slice(begin, end), die je nach angegebenem Bereich eine Kopie des Puffers zurückgibt.

Der begin Wert von .slice() ist inklusiv, während der end Wert exklusiv ist und sich damit deutlich von z.B. String#substr(begin, length) unterscheidet, wo der zweite Parameter eine Länge ist. Wenn der Wert begin weggelassen wird, wird das erste Element als Standardwert verwendet, und wenn der Wert end weggelassen wird, wird das letzte Element als Standardwert verwendet. Negative Zahlen stehen für Werte ab dem Ende des Puffers.

Hier ist ein Beispiel für eine grundlegende Interaktion mit einer ArrayBuffer:

const ab = new ArrayBuffer(8);
const view = new Uint8Array(ab)
for (i = 0; i < 8; i++) view[i] = i;
console.log(view);
// Uint8Array(8) [
//   0, 1, 2, 3,
//   4, 5, 6, 7
// ]
ab.byteLength; // 8
ab.slice(); // 0, 1, 2, 3, 4, 5, 6, 7
ab.slice(4, 6); // 4, 5
ab.slice(-3, -2); // 5

Verschiedene JavaScript-Umgebungen zeigen den Inhalt einer ArrayBuffer Instanz unterschiedlich an. Node.js zeigt eine Liste von Hexadezimalpaaren an, als ob die Daten als Uint8Array angezeigt werden würden. Chrome v88 zeigt ein erweiterbares Objekt mit mehreren verschiedenen Ansichten an. Firefox hingegen zeigt die Daten nicht an, sondern muss sie erst durch eine Ansicht leiten.

Der Begriff " View" wurde auf schon an einigen Stellen erwähnt, und jetzt ist ein guter Zeitpunkt, um ihn zu definieren. Da nicht klar ist, was binäre Daten bedeuten können, müssen wir eine View verwenden, um den zugrunde liegenden Puffer zu lesen und zu beschreiben. Es gibt mehrere solcher Ansichten in JavaScript. Jede dieser Ansichten wird von einer Basisklasse namens TypedArray abgeleitet. Diese Klasse kann nicht direkt instanziiert werden und ist auch nicht als Global verfügbar, aber man kann auf sie zugreifen, indem man die Eigenschaft .prototype einer instanziierten Unterklasse nutzt.

Tabelle 4-2 enthält eine Liste der View-Klassen, die von TypedArray erweitern.

Tabelle 4-2. Klassen, die erweitern TypedArray
Klasse Bytes Mindestwert Maximaler Wert

Int8Array

1

-128

127

Uint8Array

1

0

255

Uint8ClampedArray

1

0

255

Int16Array

2

-32,768

32,767

Uint16Array

2

0

65,535

Int32Array

4

-2,147,483,648

2,147,483,647

Uint32Array

4

0

4294967295

Float32Array

4

1.4012984643e-45

3.4028235e38

Float64Array

8

5e-324

1.7976931348623157e308

BigInt64Array

8

–9,223,372,036,854,775,808

9,223,372,036,854,775,807

BigUint64Array

8

0

18,446,744,073,709,551,615

Die Spalte Klasse ist der Name der Klasse, die für die Instanziierung verfügbar ist. Diese Klassen sind global und in jeder modernen JavaScript-Engine zugänglich. Die Spalte Bytes gibt die Anzahl der Bytes an, die zur Darstellung jedes einzelnen Elements in der Ansicht verwendet werden. Die Spalten Mindestwert und Höchstwert zeigen die gültigen Zahlenbereiche an, die zur Darstellung eines Elements im Puffer verwendet werden können.

Wenn du eine dieser Ansichten erstellst, wird die Instanz ArrayBuffer an denKonstruktor der Ansicht übergeben. Die Bytelänge des Puffers muss ein Vielfaches derBytelänge des Elements sein, das von der jeweiligen Ansicht verwendet wird, an die es übergeben wird. Wenn zum Beispiel einArrayBuffer aus 6 Bytes erstellt wurde, ist es akzeptabel, diesen in eine Int16Array (Bytelänge von 2) zu übergeben, da dies drei Int16 Elemente darstellt. Derselbe 6-Byte-Puffer kann jedoch nicht an Int32Array weitergegeben werden, da er eineinhalb Elemente darstellen würde, was nicht zulässig ist.

Die Namen dieser Ansichten sind dir vielleicht bekannt, wenn du mit niedrigeren Sprachen wie C oder Rust programmiert hast.

Das Präfix U vor der Hälfte dieser Klassen verweist auf unsigned, was bedeutet, dass nur positive Zahlen dargestellt werden können. Klassen ohne das Präfix U sind vorzeichenbehaftet, sodass negative und positive Zahlen dargestellt werden können, allerdings nur mit dem halben Maximalwert. Das liegt daran, dass bei einer Zahl mit Vorzeichen das erste Bit für das "Vorzeichen" verwendet wird, das angibt, ob die Zahl positiv oder negativ ist.

Die Begrenzung des Zahlenbereichs ergibt sich aus der Menge der Daten, die in einem einzigen Byte gespeichert werden können, um eine Zahl eindeutig zu identifizieren. Ähnlich wie bei der Dezimalzahl werden die Zahlen von Null an bis zur Basis gezählt und rollen dann zu einer Zahl auf der linken Seite über. Für eine Uint8 Zahl oder eine "ganze Zahl ohne Vorzeichen, die durch 8 Bits dargestellt wird", ist der maximale Wert (0b11111111) gleich 255.

JavaScript hat keinen Integer-Datentyp, sondern nur den Number Typ, der eine Implementierung der IEEE 754 Gleitkommazahl ist. Er ist gleichwertig mit dem Datentyp Float64. Andernfalls muss jedes Mal, wenn eine JavaScript Number in eine dieser Ansichten geschrieben wird, eine Art Umwandlung stattfinden.

Wenn ein Wert auf Float64Array geschrieben wird, kann er meist gleich gelassen werden. Der minimal zulässige Wert ist derselbe wie Number.MIN_VALUE, während der maximal zulässige WertNumber.MAX_VALUE. Wenn ein Wert auf Float32Array geschrieben wird, wird nicht nur der Bereich der Mindest- und Höchstwerte reduziert, sondern auch die Dezimalgenauigkeit abgeschnitten.

Ein Beispiel dafür ist der folgende Code:

const buffer = new ArrayBuffer(16);

const view64 = new Float64Array(buffer);
view64[0] = 1.1234567890123456789; // bytes 0 - 7
console.log(view64[0]); // 1.1234567890123457

const view32 = new Float32Array(buffer);
view32[2] = 1.1234567890123456789; // bytes 8 - 11
console.log(view32[2]); // 1.1234568357467651

In diesem Fall ist die Dezimalgenauigkeit für die Zahl float64 auf die 15.Dezimalstelle genau, während die Genauigkeit für die Zahl float32 nur auf die 6.

Dieser Code verdeutlicht eine weitere interessante Sache. In diesem Fall gibt es eine einzige Instanz von ArrayBuffer mit dem Namen buffer, aber zwei verschiedene Instanzen von TypedArray, die auf diese Pufferdaten verweisen. Kannst du dir vorstellen, was daran seltsam ist? Abbildung 4-1 gibt dir vielleicht einen Hinweis.

Two TypedArray views pointing at a single ArrayBuffer
Abbildung 4-1. Einzelne ArrayBuffer und mehrere TypeArray Ansichten

Was denkst du, was zurückgegeben wird, wenn du entweder view64[1], view32[0] oder view32[1] liest? In diesem Fall wird eine verkürzte Version des Speichers, der zum Speichern von Daten eines Typs verwendet wird, kombiniert oder aufgeteilt, um Daten eines anderen Typs darzustellen. Die zurückgegebenen Werte werden falsch interpretiert und sind unsinnig, obwohl sie deterministisch und konsistent sein sollten.

Wenn numerische Werte geschrieben werden, die außerhalb des Bereichs der unterstützten TypedArray für Nicht-Floats liegen, müssen sie eine Art Umwandlungsprozess durchlaufen, um in den Zieldatentyp zu passen. Zunächst muss die Zahl in eine Ganzzahl umgewandelt werden, als ob sie in Math.trunc() übergeben würde. Wenn der Wert außerhalb des zulässigen Bereichs liegt, wird er umgedreht und bei 0 zurückgesetzt, als ob der Modulus-Operator (%) verwendet würde. Hier sind einige Beispiele dafür, wie mit einem Uint8Array (das ein TypedArray mit einem maximalen Elementwert von 255 ist) funktioniert:

const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view[0] = 255;    view[1] = 256;
view[2] = 257;    view[3] = -1;
view[4] = 1.1;    view[5] = 1.999;
view[6] = -1.1;   view[7] = -1.9;
console.log(view);

Tabelle 4-3 enthält eine Liste der Werte, die in der zweiten Zeile ausgegeben werden, mit den dazugehörigen Werten in der ersten Zeile.

Tabelle 4-3. TypedArray Umrechnungen

Eingabe

255

256

257

-1

1.1

1.999

-1.1

-1.9

Ausgabe

255

0

1

255

1

1

255

255

Dieses Verhalten ist ein wenig anders als bei Uint8ClampedArray. Wenn ein negativer Wert geschrieben wird, wird er in 0 umgewandelt. Wenn ein Wert größer als 255 geschrieben wird, wird er in 255 umgewandelt. Wenn ein nicht-ganzzahliger Wert angegeben wird, wird er stattdessen an Math.round() übergeben. Je nach deinem Anwendungsfall kann es sinnvoller sein, diese Ansicht zu verwenden.

Schließlich verdienen auch die Einträge BigInt64Array und BigUint64Array eine besondere Aufmerksamkeit. Im Gegensatz zu den anderen TypedArray Ansichten, die mit dem Number Typ arbeiten, arbeiten diese beiden Varianten mit dem BigInt Typ (1 ist ein Number während 1n ein BigInt ist ). Das liegt daran, dass die numerischen Werte, die mit 64 Bytes dargestellt werden können, aus dem Bereich der Zahlen herausfallen, die mit dem JavaScript-Typ Number dargestellt werden können. Aus diesem Grund muss das Setzen eines Wertes mit diesen Ansichten mit einem BigInt erfolgen, und die abgerufenen Werte sind ebenfalls vom Typ BigInt.

Generell ist es gefährlich, mehrere TypedArray Ansichten zu verwenden, vor allem wenn sie unterschiedlich groß sind, um in dieselbe Pufferinstanz zu schauen, und sollte nach Möglichkeit vermieden werden. Du könntest feststellen, dass du versehentlich einige Daten verwechselst, wenn du verschiedene Operationen durchführst. Es ist möglich, mehr als einen SharedArrayBuffer zwischen Threads zu übergeben. Wenn du also Typen mischen musst, kann es von Vorteil sein, wenn du mehr als einen Puffer hast.

Jetzt, wo du mit den Grundlagen von ArrayBuffer und SharedArrayBuffer vertraut bist, kannst du mit über eine komplexere API mit ihnen interagieren.

Atomare Methoden zur Datenmanipulation

Atomarität ist ein Begriff, den du vielleicht schon einmal gehört hast, vor allem, wenn es um Datenbanken geht, wo es das erste Wort des Akronyms ACID (Atomarität, Konsistenz, Isolation, Dauerhaftigkeit) ist. Wenn eine Operation atomar ist, bedeutet das im Wesentlichen, dass der Gesamtvorgang zwar aus mehreren kleineren Schritten besteht, aber dass der Gesamtvorgang garantiert entweder vollständig gelingt oder fehlschlägt. Eine einzelne Abfrage an eine Datenbank ist zum Beispiel atomar, aber drei separate Abfragen sind nicht atomar.

Wenn diese drei Abfragen jedoch in eine Datenbanktransaktion eingeschlossen sind, wird das Ganze atomar: Entweder werden alle drei Abfragen erfolgreich ausgeführt oder keine. Es ist auch wichtig, dass die Operationen in einer bestimmten Reihenfolge ausgeführt werden, vorausgesetzt, sie manipulieren denselben Zustand oder haben andere Nebeneffekte, die sich gegenseitig beeinflussen können. Die Isolierung bedeutet, dass keine anderen Operationendazwischen ausgeführt werden können; zum Beispiel kann nicht gelesen werden, wenn nur einige der Operationen ausgeführt wurden.

Atomare Operationen sind in der Informatik sehr wichtig, besonders wenn es um verteiltes Rechnen geht. Datenbanken, die viele Client-Verbindungen haben können, müssen atomare Operationen unterstützen. Verteilte Systeme, bei denen viele Knotenpunkte in einem Netzwerk miteinander kommunizieren, müssen ebenfalls atomare Operationen unterstützen. Etwas weiter gedacht: Auch innerhalb eines einzelnen Computers, wo der Datenzugriff über mehrere Threads erfolgt, ist Atomarität wichtig.

JavaScript stellt ein globales Objekt mit dem Namen Atomics bereit, für das mehrere statische Methoden verfügbar sind. Dieses globale Objekt folgt demselben Muster wie das bekannte globale Objekt Math. In beiden Fällen kannst du den Operator new nicht verwenden, um eine neue Instanz zu erstellen, und die verfügbaren Methoden sind zustandslos und wirken sich nicht auf das Global selbst aus. Stattdessen werden sie bei Atomics durch die Übergabe eines Verweises auf die Daten, die geändert werden sollen, verwendet.

Der Rest dieses Abschnitts listet bis auf drei Ausnahmen alle Methoden auf, die für dasAtomics Objekt zur Verfügung stehen. Die übrigen Methoden werden in "Atomare Methoden für die Koordination" behandelt . Mit Ausnahme von Atomics.isLockFree() akzeptieren alle Methoden von eine Instanz von TypedArray als erstes Argument und den Index, auf den reagiert werden soll, als zweites Argument.

Atomics.add()

old = Atomics.add(typedArray, index, value)

Diese Methode addiert den angegebenen value zum bestehenden Wert in einem typedArray, der sich auf index befindet. Der alte Wert wird zurückgegeben. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
typedArray[index] = old + value;
return old;

Atomics.and()

old = Atomics.and(typedArray, index, value)

Diese Methode führt eine bitweise and unter Verwendung von value mit dem bestehenden Wert in typedArray auf index durch. Der alte Wert wird zurückgegeben. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
typedArray[index] = old & value;
return old;

Atomics.compareExchange()

old = Atomics.compareExchange(typedArray, index, oldExpectedValue, value)

Diese Methode prüft typedArray bis , um zu sehen, ob sich der Wert oldExpectedValue auf index befindet. Wenn ja, wird der Wert durch value ersetzt. Wenn nicht, passiert nichts. Der alte Wert wird immer zurückgegeben, sodass du feststellen kannst, ob der Austausch erfolgreich war, wenn oldExpectedValue === old. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
if (old === oldExpectedValue) {
  typedArray[index] = value;
}
return old;

Atomics.exchange()

old = Atomics.exchange(typedArray, index, value)

Diese Methode setzt den Wert in typedArray, der sich auf index befindet, auf value. Der alte Wert wird zurückgegeben. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
typedArray[index] = value;
return old;

Atomics.isLockFree()

free = Atomics.isLockFree(size)

Diese Methode gibt ein true zurück, wenn size ein Wert ist, der als BYTES_PER_ELEMENT für eine der TypedArray Unterklassen (normalerweise 1, 2, 4, 8) erscheint, und ein false, wenn nicht.2 Wenn true, dann wird die Verwendung der Atomics Methoden mit der aktuellen Systemhardware recht schnell sein. Wenn false, dann sollte die Anwendung vielleicht einen manuellen Sperrmechanismus verwenden, wie er in "Mutex: A Basic Lock" behandelt wird, vor allem, wenn die Leistung das Hauptanliegen ist.

Atomics.load()

value = Atomics.load(typedArray, index)

Diese Methode gibt den Wert in typedArray zurück, der sich auf index befindet. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
return old;

Atomics.or()

old = Atomics.or(typedArray, index, value)

Diese Methode führt eine bitweise or unter Verwendung von value mit dem bestehenden Wert in typedArray auf index durch. Der alte Wert wird zurückgegeben. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
typedArray[index] = old | value;
return old;

Atomics.store()

value = Atomics.store(typedArray, index, value)

Diese Methode speichert die übergebene value in typedArray unter index. Die value, die übergeben wurde, wird dann zurückgegeben. So könnte die nicht-atomare Version aussehen:

typedArray[index] = value;
return value;

Atomics.sub()

old = Atomics.sub(typedArray, index, value)

Diese Methode subtrahiert den von bereitgestellten Wert value von dem vorhandenen Wert in typedArray, der sich auf index befindet. Der alte Wert wird zurückgegeben. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
typedArray[index] = old - value;
return old;

Atomics.xor()

old = Atomics.xor(typedArray, index, value)

Diese Methode führt eine bitweise xor unter Verwendung von value mit dem bestehenden Wert in typedArray auf index durch. Der alte Wert wird zurückgegeben. So könnte die nicht-atomare Version aussehen:

const old = typedArray[index];
typedArray[index] = old ^ value;
return old;

Fragen der Atomarität

Die Methoden, die in "Atomare Methoden zur Datenmanipulation" behandelt werden, werden garantiert atomar ausgeführt. Betrachte zum Beispiel die Methode Atomics.compareExchange(). Diese Methode nimmt einen oldExpectedValue und einen neuen value entgegen und ersetzt den bestehenden Wert nur dann durch den neuen value, wenn er gleich oldExpectedValue ist. Dieser Vorgang würde zwar mehrere einzelne Anweisungen erfordern, um ihn mit JavaScript darzustellen, aber es ist garantiert, dass der gesamte Vorgang immer vollständig ausgeführt wird.

Um das zu veranschaulichen, stell dir vor, du hast eine Uint8Array mit dem Namen typedArray und das 0.Element ist auf 7 gesetzt. Dann stell dir vor, dass mehrere Threads Zugriff auf diese Datei haben.typedArrayund jeder von ihnen führt eine Variante der folgenden Codezeile aus:

let old1 = Atomics.compareExchange(typedArray, 0, 7, 1); // Thread #1
let old2 = Atomics.compareExchange(typedArray, 0, 7, 2); // Thread #2

Es ist völlig unbestimmt, in welcher Reihenfolge diese drei Methoden aufgerufen werden, und auch der Zeitpunkt ihres Aufrufs ist nicht festgelegt. Sie könnten sogar gleichzeitig aufgerufen werden!Durch die Atomaritätsgarantie des Atomics Objekts ist jedoch gewährleistet, dass genau einer der Threads den ursprünglichen Wert von 7 zurückerhält, während der andere Thread denaktualisierten Wert von 1 oder 2 zurückerhält. Eine Zeitleiste, wie diese Vorgänge funktionieren, ist in Abbildung 4-2 zu sehen, wobei CEX(oldExpectedValue, value) eine Abkürzung fürAtomics.compareExchange().

Multiple calls to Atomics.compareExchange() are atomic.
Abbildung 4-2. Atomare Form von Atomics.compareExchange()

Wenn du hingegen das nicht-atomare Äquivalent von compareExchange() verwendest, wie z.B. direktes Lesen und Schreiben auf typedArray[0], ist es durchaus möglich, dass das Programm versehentlich einen Wert überschneidet. In diesem Fall lesen beide Threads den vorhandenen Wert etwa zur gleichen Zeit, dann sehen sie beide, dass der ursprüngliche Wert vorhanden ist, und dann schreiben sie beide etwa zur gleichen Zeit. Hier ist noch einmal eine kommentierte Version desnicht-atomaren compareExchange() Codes:

const old = typedArray[0]; // GET()
if (old === oldExpectedValue) {
  typedArray[0] = value;   // SET(value)
}

Dieser Code führt mehrere Interaktionen mit gemeinsam genutzten Daten durch, insbesondere die Zeile, in der die Daten abgerufen (als GET() gekennzeichnet) und später gesetzt werden (als SET(value) gekennzeichnet). Damit dieser Code richtig funktioniert, muss sichergestellt werden, dassandere Threads den Wert nicht lesen oder schreiben können, während der Code ausgeführt wird. DieseGarantie, dass nur ein Thread exklusiven Zugriff auf gemeinsame Ressourcen erhält, wird als kritischer Abschnitt bezeichnet.

Abbildung 4-3 zeigt eine Zeitleiste, wie dieser Code ohne die exklusiven Zugriffsgarantien laufen könnte.

Nonatomic calls result in data loss.
Abbildung 4-3. Nichtatomare Form von Atomics.compareExchange()

In diesem Fall denken beide Threads, dass sie den Wert erfolgreich gesetzt haben, aber das gewünschte Ergebnis bleibt nur für den zweiten Thread bestehen. Diese Art von Fehler wird als Race Condition bezeichnet, bei der zwei oder mehr Threads gegeneinander antreten, um eine Aktion durchzuführen.3 Das Schlimmste an diesen Fehlern ist, dass sie nicht durchgängig auftreten, dass sie schwer zu reproduzieren sind und dass sie nur in einer bestimmten Umgebung auftreten können, z. B. auf einem Produktionsserver, und nicht in einer anderen Umgebung, z. B. auf deinem Entwicklungslaptop.

Um von den atomaren Eigenschaften des Atomics Objekts zu profitieren, wenn du mit einem Array-Puffer interagierst, musst du vorsichtig sein, wenn du Atomics Aufrufe mit direktem Array-Puffer-Zugriff kombinierst. Wenn ein Thread deiner Anwendung die Methode compareExchange() verwendet und ein anderer Thread direkt auf denselben Puffer zugreift, werden die Sicherheitsmechanismen ausgehebelt und deine Anwendung verhält sich nicht deterministisch. Wenn du Atomics aufrufst, gibt es eine implizite Sperre, um die Interaktion zu erleichtern.

Leider können nicht alle Operationen, die du mit gemeinsamem Speicher durchführen musst, mit den Methoden von Atomics dargestellt werden. In diesem Fall musst du dir einen manuellen Sperrmechanismus ausdenken, der es dir ermöglicht, frei zu lesen und zu schreiben und andere Threads daran zu hindern, dies zu tun. Dieses Konzept wird später in "Mutex: Eine grundlegende Sperre".

Daten-Serialisierung

Puffer sind extrem leistungsstarke Werkzeuge. Allerdings kann die Arbeit mit Puffern aus rein numerischer Sicht ein wenig schwierig werden ( ). Manchmal musst du Dinge in einem Puffer speichern, die nicht numerische Daten darstellen. In diesem Fall musst du die Daten auf irgendeine Weise serialisieren, bevor du sie in den Puffer schreibst, und du musst sie später deserialisieren, wenn du aus dem Puffer liest.

Je nach Art der Daten, die du darstellen möchtest, gibt es verschiedene Tools, die du für die Serialisierung verwenden kannst. Einige Tools eignen sich für verschiedene Situationen, aberjedes bringt unterschiedliche Kompromisse in Bezug auf die Größe der Speicherung und dieLeistung der Serialisierung mit sich.

Booleans

Boolesche Werte lassen sich leicht darstellen , weil sie nur ein einziges Bit benötigen, um die Daten zu speichern, und ein Bit ist weniger als ein Byte. Du kannst also eine der kleinsten Ansichten erstellen, z. B. eine Uint8Array, dann auf eine ArrayBuffer mit einer Bytelänge von 1 zeigen und fertig. Das Interessante daran ist, dass du bis zu acht dieser Booleans in einem einzigen Byte speichern kannst. Wenn du es mit vielen booleschen Werten zu tun hast, kannst du die JavaScript-Engine sogar übertreffen, indem du eine große Anzahl von ihnen in einem Puffer speicherst, da für jede boolesche Instanz zusätzliche Metadaten anfallen. Abbildung 4-4 zeigt eine Liste boolescher Werte, die als Byte dargestellt wird.

Bits are ordered from right to left
Abbildung 4-4. Boolesche Werte in einem Byte gespeichert

Wenn du Daten in einzelnen Bits speicherst, fängst du am besten mit dem niedrigstwertigen Bit an, z. B. dem Bit, das am weitesten rechts steht und mit 0 gekennzeichnet ist, und gehst dann zu den höherwertigen Bits über, wenn du dem Byte, in dem du sie speicherst, weitere Boolesche Werte hinzufügst. Der Grund dafür ist einfach: Wenn die Anzahl der zu speichernden Binsenweisheiten wächst, wächst auch die Größe des Puffers und die bestehenden Bitpositionen sollten korrekt bleiben. Der Puffer selbst kann zwar nicht dynamisch wachsen, aber neuere Versionen deiner Anwendung müssen möglicherweise große Puffer instanziieren.

Wenn der Puffer, in dem die Booleschen Werte speichert, heute 1 Byte und morgen 2 Byte groß ist, wird die Dezimaldarstellung der Daten entweder eine 0 oder eine 1 sein, wenn die niedrigstwertige Stelle zuerst verwendet wird. Wenn jedoch die höchstwertige Stelle verwendet wird, könnte der Wert heute 0 und 128 und morgen 32.768 und 0 sein.

Das folgende Beispiel zeigt, wie man diese booleschen Werte speichert und abruft, so dass sie in einer ArrayBuffer gesichert werden:

const buffer = new ArrayBuffer(1);
const view = new Uint8Array(buffer);
function setBool(slot, value) {
  view[0] = (view[0] & ~(1 << slot)) | ((value|0) << slot);
}
function getBool(slot) {
  return !((view[0] & (1 << slot)) === 0);
}

Dieser Code erstellt einen Ein-Byte-Puffer (0b00000000 in binärer Form) und erstellt dann eine Ansicht in diesem Puffer. Um den Wert der niedrigstwertigen Stelle in ArrayBuffer auf true zu setzen, verwendest du den Aufruf setBool(0, true). Um die zweitniedrigstwertige Stelle auf false zu setzen, rufst du setBool(1, false) auf. Um die Werte an der drittniedrigstwertigen Stelle abzurufen, würdest du getBool(2) aufrufen.

Die Funktion setBool() funktioniert indem sie den booleschen Wert value in eine ganze Zahl umwandelt (value|0 wandelt false in 0 und true in 1 um). Dann "verschiebt" sie den Wert nach links, indem sie Nullen auf der rechten Seite hinzufügt, je nachdem, in welchem slot der Wert gespeichert werden soll (0b1<<0 bleibt 0b1, 0b1<<1 wird 0b10, usw.). Er nimmt auch die Zahl 1 und verschiebt sie auf der Grundlage von slot (also 0b1000, wenn slot eine 3 ist), dann invertiert er die Bits (mit ~) und erhält einen neuen Wert durch UND-Verknüpfung (&) des bestehenden Wertes mit diesem neuen Wert (view[0] & ~(1 << slot)). Schließlich werden der geänderte alte Wert und die neuen verschobenen Werte miteinander ODER-verknüpft (|) und view[0] zugewiesen. Im Grunde werden die vorhandenen Bits gelesen, das entsprechende Bit ersetzt und die Bits zurückgeschrieben.

Die Funktion getBool() nimmt die Zahl 1, verschiebt sie auf der Basis des Slots und vergleicht sie dann mit & mit dem bestehenden Wert. Der verschobene Wert (auf der rechten Seite von &) enthält nur eine einzige 1 und sieben 0en. Die UND-Verknüpfung zwischen diesem geänderten Wert und dem bestehenden Wert liefert entweder eine Zahl, die den Wert des verschobenen Slots darstellt, vorausgesetzt, der Wert an der Position slot auf view[0] war wahr; andernfalls wird 0 zurückgegeben. Dieser Wert wird dann überprüft, ob er genau gleich 0 ist (===0), und das Ergebnis wird negiert (!). Im Grunde genommen wird der Wert des Bits an slot zurückgegeben.

Dieser Code hat einige Unzulänglichkeiten und sollte nicht unbedingt in der Produktion verwendet werden. Er ist zum Beispiel nicht für die Arbeit mit Puffern geeignet, die größer als ein einzelnes Byte sind, und beim Lesen oder Schreiben von Einträgen, die größer als 7 sind, kommt es zu einem undefinierten Verhalten. Eine produktionstaugliche Version würde die Größe der Speicherung berücksichtigen und eine Begrenzungsprüfung durchführen, aber das ist eine Übung für den Leser.

Strings

Strings sind nicht so einfach zu kodieren wie es auf den ersten Blick scheint. Es ist leicht anzunehmen, dass jedes Zeichen in einem String durch ein einzelnes Byte dargestellt werden kann und dass die .length Eigenschaft eines Strings ausreicht, um die Größe des Puffers zu bestimmen, in dem er gespeichert werden soll. Das mag zwar manchmal funktionieren, vor allem bei einfachen Zeichenketten, aber wenn du mit komplexeren Daten arbeitest, wirst du schnell auf Fehler stoßen.

Der Grund dafür, dass dies bei einfachen Zeichenketten funktioniert, ist, dass Daten, die mit ASCII dargestellt werden, ein einzelnes Zeichen in einem einzigen Byte unterbringen können. In der Programmiersprache C wird die Art der Speicherung von Daten, die ein einzelnes Byte darstellen, als char bezeichnet.

Es gibt viele Möglichkeiten, einzelne Zeichen mit Zeichenketten zu kodieren. Mit ASCII kann die gesamte Bandbreite an Zeichen mit einem Byte dargestellt werden, aber in einer Welt mit vielen Kulturen, Sprachen und Emojis ist es absolut unmöglich, all diese Zeichen auf diese Weise darzustellen. Stattdessen verwenden wir Kodierungssysteme, bei denen eine variable Anzahl von Bytes verwendet werden kann, um ein einzelnes Zeichen darzustellen. Intern verwenden JavaScript-Engines je nach Situation verschiedene Kodierungsformate, um Zeichenketten darzustellen, und die Komplexität dieser Formate bleibt unseren Anwendungen verborgen. Ein mögliches internes Format ist UTF-16, das 2 oder 4 Bytes zur Darstellung eines Zeichens verwendet, oder sogar bis zu 14 Bytes zur Darstellung bestimmter Emojis. Ein universellerer Standard ist UTF-8, der 1 bis 4 Byte Speicherung pro Zeichen verwendet und mit ASCII rückwärtskompatibel ist.

Das folgende Beispiel zeigt, was passiert, wenn eine Zeichenkette mit der Eigenschaft .length iteriert wird und die resultierenden Werte auf eine Uint8Array Instanz abgebildet werden:

// Warning: Antipattern!
function stringToArrayBuffer(str) {
  const buffer = new ArrayBuffer(str.length);
  const view = new Uint8Array(buffer);
  for (let i = 0; i < str.length; i++) {
    view[i] = str.charCodeAt(i);
  }
  return view;
}

stringToArrayBuffer('foo'); // Uint8Array(3) [ 102, 111, 111 ]
stringToArrayBuffer('€');   // Uint8Array(1) [ 172 ]

In diesem Fall ist die Speicherung der Grundzeichenfolge foo in Ordnung. Das Zeichen , das in Wirklichkeit durch den Wert 8.364 dargestellt wird, ist jedoch größer als der von Uint8Array unterstützte Maximalwert von 255 und wurde daher auf 172 abgeschnitten. Wenn du diese Zahl wieder in ein Zeichen umwandelst, erhältst du den falschen Wert.

Modernes JavaScript verfügt über eine API, um Strings direkt in ArrayBuffer Instanzen zu kodieren und zu dekodieren. Diese API ist und wird von den Globals TextEncoder und TextDecoder bereitgestellt, die beide Konstruktoren sind und in modernen JavaScript-Umgebungen, einschließlich Browsern und Node.js, global verfügbar sind. Diese APIs kodieren und dekodieren mit der UTF-8-Kodierung, da diese allgegenwärtig ist.

Hier ist ein Beispiel, wie du mit dieser API Strings sicher in die UTF-8-Kodierung umwandeln kannst:

const enc = new TextEncoder();
enc.encode('foo'); // Uint8Array(3) [ 102, 111, 111 ]
enc.encode('€');   // Uint8Array(3) [ 226, 130, 172 ]

Und hier ist, wie man solche Werte entschlüsselt:

const ab = new ArrayBuffer(3);
const view = new Uint8Array(ab);
view[0] = 226; view[1] = 130; view[2] = 172;
const dec = new TextDecoder();
dec.decode(view); // '€'
dec.decode(ab);   // '€'

Beachte, dass TextDecoder#decode() entweder mit der Ansicht Uint8Array oder mit der zugrunde liegenden Instanz ArrayBuffer funktioniert. Dadurch können Daten, die du von einem Netzwerkaufruf erhältst, bequem dekodiert werden, ohne dass du sie zuerst in eine Ansicht einpacken musst.

Objekte

Da Objekte bereits mit JSON als Strings dargestellt werden können ( ), hast du die Möglichkeit, ein Objekt, das du über zwei Threads hinweg nutzen möchtest, in einen JSON-String zu serialisieren und diesen String mit der gleichen API ( TextEncoder ), mit der du im vorherigen Abschnitt gearbeitet hast, in einen Array-Puffer zu schreiben. Dies kann im Wesentlichen mit folgendem Code geschehen:

const enc = new TextEncoder();
return enc.encode(JSON.stringify(obj));

JSON wandelt ein JavaScript-Objekt in eine String-Darstellung um. In diesem Fall gibt es viele Redundanzen im Ausgabeformat. Wenn du die Größe einer Nutzlast noch weiter reduzieren möchtest, könntest du ein Format wie MessagePack verwenden, das die Größe eines serialisierten Objekts noch weiter reduzieren kann, indem es Objekt-Metadaten mit binären Daten darstellt. Das macht Tools wie MessagePack nicht unbedingt zu einer guten Wahl in Situationen, in denen reiner Text angemessen ist, wie z. B. bei einer E-Mail, aber in Situationen, in denen binäre Puffer herumgereicht werden, ist es vielleicht nicht so schlimm. Das msgpack5 npm-Paket ist ein browser- und Node.js-fähiges Paket, das genau das tut.

Die Kompromisse bei der Kommunikation zwischen Threads sind in der Regel nicht auf die Größe der übertragenen Nutzdaten zurückzuführen, sondern eher auf die Kosten für die Serialisierung und Deserialisierung der Nutzdaten. Aus diesem Grund ist es in der Regel besser, einfachere Datendarstellungen zwischen Threads zu übertragen. Selbst wenn es um die Weitergabe von Objekten zwischen Threads geht, wirst du feststellen, dass der strukturierte Klon-Algorithmus in Kombination mit den Methoden .onmessage und .postMessage schneller und sicherer ist als die Serialisierung von Objekten und das Schreiben in Puffer.

Wenn du eine Anwendung entwickelst, die Objekte serialisiert und deserialisiert und sie in eine SharedArrayBuffer schreibt, solltest du die Architektur der Anwendung überdenken. Es ist fast immer besser, einen Weg zu finden, die Objekte, die du weitergibst, mit Hilfe von Typen niedrigerer Ebenen zu serialisieren und sie stattdessen weiterzugeben.

1 Diese Einschränkung kann sich in Zukunft ändern; siehe "In-Place Resizable and Growable ArrayBuffers" für einen Vorschlag.

2 Wenn JavaScript auf seltener Hardware läuft, ist es möglich, dass diese Methode für 1, 2 oder 8 ein false zurückgibt. Für 4 wird jedoch immer true zurückgegeben.

3 Bei der Art und Weise, wie der Code kompiliert, geordnet und ausgeführt wird, ist es möglich, dass ein rasantes Programm auf eine Weise fehlschlägt, die sich mit diesem Diagramm der ineinandergreifenden Schritte nicht erklären lässt. Wenn das passiert, kann es sein, dass du am Ende einen Wert erhältst, der allen Erwartungen widerspricht.

Get Multithreading-Javascript now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.