Podstawy procesów internetowych

Problem: równoczesność JavaScriptu

Istnieje wiele wąskich gardeł, które uniemożliwiają przenoszenie interesujących aplikacji (np. z implementacji przeciążonych przez serwer) do kodu JavaScript po stronie klienta. Są to między innymi zgodność przeglądarki, pisanie statyczne, ułatwienia dostępu i wydajność. Na szczęście ta sytuacja szybko mija, ponieważ dostawcy przeglądarek szybko przyspieszają działanie swoich silników JavaScript.

Przeszkodą w przypadku JavaScriptu jest w rzeczywistości sam język. JavaScript jest środowiskiem jednowątkowym, co oznacza, że nie można jednocześnie uruchomić wielu skryptów. Weźmy na przykład witrynę, która musi obsługiwać zdarzenia UI, wysyłać zapytania i przetwarzać duże ilości danych interfejsu API oraz manipulować DOM. To dość powszechne, prawda? Niestety nie wszystkie te działania nie mogą jednocześnie działać ze względu na ograniczenia w środowisku wykonawczym JavaScriptu przeglądarki. Wykonanie skryptu odbywa się w ramach jednego wątku.

Deweloperzy naśladują równoczesność, korzystając z technik takich jak setTimeout(), setInterval() i XMLHttpRequest oraz modułów obsługi zdarzeń. Tak. Wszystkie te funkcje działają asynchronicznie, ale nieblokowanie niekoniecznie oznacza równoczesność. Zdarzenia asynchroniczne są przetwarzane po wygenerowaniu bieżącego skryptu wykonującego. Dobra wiadomość jest taka, że HTML5 daje nam coś lepszego niż te techniki.

Przedstawiamy procesy Web Workers: przenoszenie wątków do JavaScriptu

Specyfikacja Web Workers definiuje interfejs API do generowania skryptów w tle w aplikacji internetowej. Procesy internetowe umożliwiają np. uruchamianie długotrwałych skryptów do obsługi zadań wymagających dużej mocy obliczeniowej bez blokowania interfejsu użytkownika ani innych skryptów do obsługi interakcji użytkowników. Pomogą Ci dopracować ten brzydki, nieelastyczny dialog, który wszyscy pokochaliśmy:

Okno skryptu nie odpowiada
Typowe okno skryptu, który nie odpowiada.

Instancje robocze wykorzystują przekazywanie wiadomości w wątku, aby uzyskać równoległość. Dzięki nim interfejs użytkownika jest odświeżany, wydajny i nie wymaga reagowania.

Typy procesów internetowych

Warto zauważyć, że specyfikacja obejmuje 2 rodzaje instancji roboczych: dedykowane procesy robocze i współdzielone instancje robocze. Ten artykuł dotyczy wyłącznie wyznaczonych pracowników. Przez cały czas będę ich nazywać „pracownikami sieciowymi” lub „pracownikami”.

Wprowadzenie

Instancje robocze Web Worker są uruchamiane w izolowanym wątku. W związku z tym wykonywany przez nią kod musi znajdować się w osobnym pliku. Najpierw jednak utwórz na stronie głównej nowy obiekt Worker. Konstruktor przyjmuje nazwę skryptu instancji roboczej:

var worker = new Worker('task.js');

Jeśli określony plik istnieje, przeglądarka uruchomi nowy wątek instancji roboczej, który zostanie pobrany asynchronicznie. Instancja robocza rozpocznie się dopiero po całkowitym pobraniu i wykonaniu pliku. Jeśli ścieżka do instancji roboczej zwróci błąd 404, spowoduje to błąd.

Po utworzeniu instancji roboczej uruchom ją, wywołując metodę postMessage():

worker.postMessage(); // Start the worker.

Komunikacja z pracownikiem przez przekazywanie wiadomości

Komunikacja między utworem a jej stroną nadrzędną odbywa się za pomocą modelu zdarzenia i metody postMessage(). W zależności od przeglądarki i wersji postMessage() może akceptować ciąg znaków lub obiekt JSON jako pojedynczy argument. Najnowsze wersje nowoczesnych przeglądarek obsługują przekazywanie obiektu JSON.

Poniżej znajdziesz przykład użycia ciągu znaków do przekazania tekstu „Hello World” do instancji roboczej w doWork.js. Instancja ta zwraca po prostu przekazaną do niej wiadomość.

Główny skrypt:

var worker = new Worker('doWork.js');

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (instancja robocza):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

Gdy nastąpi wywołanie postMessage() ze strony głównej, nasza instancja robocza obsługuje tę wiadomość, definiując moduł obsługi onmessage dla zdarzenia message. Ładunek wiadomości (w tym przypadku „Hello World”) jest dostępny w usłudze Event.data. Ten przykład nie jest zbyt atrakcyjny, ale pokazuje, że postMessage() to także sposób na przekazywanie danych z powrotem do wątku głównego. Wygodnie!

Wiadomości przekazywane między stroną główną a instancjami roboczymi są kopiowane, a nie udostępniane. W następnym przykładzie właściwość „msg” wiadomości JSON jest dostępna w obu lokalizacjach. Wygląda na to, że obiekt jest przekazywany bezpośrednio do instancji roboczej, mimo że działa w osobnej, dedykowanej przestrzeni. W rzeczywistości obiekt jest zserializowany, czyli przekazywany do pracownika, a następnie deserializacji po drugiej stronie. Strona i instancja robocza nie korzystają z tej samej instancji, więc w efekcie na każdym karcie tworzony jest duplikat. Większość przeglądarek wdraża tę funkcję przez automatyczne kodowanie/dekodowanie wartości w formacie JSON po obu stronach.

Poniżej znajduje się bardziej skomplikowany przykład przekazywania wiadomości za pomocą obiektów JSON.

Główny skrypt:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

var worker = new Worker('doWork2.js');

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

Obiekty możliwe do przeniesienia

Większość przeglądarek korzysta z algorytmu klonowania ustrukturyzowanego, który umożliwia przekazywanie bardziej złożonych typów instancji roboczych, takich jak File, Blob, ArrayBuffer i obiekty JSON. Jednak przy przekazywania tego typu danych za pomocą funkcji postMessage() i tak tworzona jest ich kopia. Dlatego też, jeśli przekazujesz duży plik o wielkości 50 MB, przesyłanie pliku między instancją roboczą a wątkiem głównym wiąże się z znacznym nakładem pracy.

Uporządkowane klonowanie jest świetne, ale kopiowanie może zająć setki milisekund. Aby walczyć z uderzeniami, możesz używać obiektów z możliwością przenoszenia.

Dzięki obiektom do przenoszenia dane są przenoszone z jednego kontekstu do drugiego. To zero-kopia, która znacznie poprawia wydajność wysyłania danych do instancji roboczej. Jeśli pochodzisz ze świata C/C++, możesz myśleć o tym jako o przekazywanym w ten sposób pliku referencyjnym. Jednak w przeciwieństwie do przekazywania dalej, „wersja” z kontekstu wywołania nie jest już dostępna po przeniesieniu do nowego kontekstu. Na przykład podczas przenoszenia obiektu ArrayBuffer z aplikacji głównej do instancji roboczej pierwotny plik ArrayBuffer zostaje wyczyszczony i nie można go już używać. Jego zawartość jest przenoszona (dosłownie) do kontekstu instancji roboczej.

Aby użyć obiektów możliwych do przeniesienia, użyj nieco innego podpisu postMessage():

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

W przypadku instancji roboczej pierwszy argument to dane, a drugi to lista elementów do przeniesienia. Przy okazji pierwszy argument nie musi być ArrayBuffer. Może to być np. obiekt JSON:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

Ważne jest to, że drugi argument musi być tablicą typu ArrayBuffer. To jest Twoja lista elementów, które można przenieść.

Więcej informacji na temat urządzeń przenoszonych znajdziesz w naszym poście na developers.chrome.com.

Środowisko instancji roboczych

Zakres instancji roboczych

W kontekście instancji roboczej zarówno self, jak i this odnoszą się do jej globalnego zakresu. Poprzedni przykład mógłby więc również wyglądać następująco:

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

Możesz też bezpośrednio ustawić moduł obsługi zdarzeń onmessage (chociaż addEventListener zawsze zalecają ninja z JavaScriptu).

onmessage = function(e) {
var data = e.data;
...
};

Funkcje dostępne dla pracowników

Ze względu na wielowątkowość instancje robocze mają dostęp tylko do podzbioru funkcji JavaScriptu:

  • Obiekt navigator
  • Obiekt location (tylko do odczytu)
  • XMLHttpRequest
  • setTimeout()/clearTimeout()setInterval()/clearInterval()
  • Pamięć podręczna aplikacji
  • Importowanie zewnętrznych skryptów przy użyciu metody importScripts()
  • Tworzenie innych instancji roboczych

Instancje robocze NIE mają dostępu do:

  • DOM (nie jest bezpieczny w wątku)
  • Obiekt window
  • Obiekt document
  • Obiekt parent

Wczytuję skrypty zewnętrzne

Za pomocą funkcji importScripts() możesz wczytać do instancji roboczej zewnętrzne pliki lub biblioteki skryptów. Przyjmuje ona zero lub więcej ciągów reprezentujących nazwy plików do zaimportowania.

Ten przykład wczytuje elementy script1.js i script2.js do instancji roboczej:

worker.js:

importScripts('script1.js');
importScripts('script2.js');

Co można też zapisać jako pojedynczą instrukcję importu:

importScripts('script1.js', 'script2.js');

Podrzędne instancje robocze

Pracownicy mogą tworzyć dzieci. Świetnie nadaje się do dalszego dzielenia dużych zadań w czasie działania. Partnerzy podrzędni mają jednak pewne ograniczenia:

  • Podrzędne instancje robocze muszą być hostowane w tym samym źródle co strona nadrzędna.
  • Identyfikatory URI w instancjach podrzędnych są rozpoznawane względem lokalizacji nadrzędnej instancji roboczej (a nie strony głównej).

Pamiętaj, że większość przeglądarek uruchamia oddzielny proces w przypadku każdego instancji roboczej. Zanim utworzysz farmę pracowników, zachowaj ostrożność, aby nie wykorzystać zbyt wielu zasobów systemowych użytkownika. Jednym z powodów jest to, że wiadomości przekazywane między stronami głównymi i instancjami roboczymi są kopiowane, a nie udostępniane. Zobacz Komunikacja z pracownikami przez przekazywanie wiadomości.

Przykład tworzenia podinstancji znajdziesz w przykładzie w specyfikacji.

Wbudowane instancje robocze

A jeśli chcesz utworzyć skrypt na bieżąco lub samodzielną stronę bez konieczności tworzenia osobnych plików instancji roboczych? Dzięki Blob() możesz „wbudować” instancję roboczą w ten sam plik HTML co główna logika, tworząc uchwyt adresu URL dla kodu instancji roboczej pod postacią ciągu znaków:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

Adresy URL obiektów blob

Magia jest dostępna w połączeniu z numerem window.URL.createObjectURL(). Ta metoda tworzy prosty ciąg adresu URL, którego można używać do odwoływania się do danych przechowywanych w obiekcie DOM File lub Blob. Na przykład:

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

Adresy URL blobów są unikalne i są ważne przez cały okres działania aplikacji (np. do momentu usunięcia z urządzenia document). Jeśli tworzysz wiele adresów URL blobów, warto zwolnić odniesienia, które nie są już potrzebne. Możesz wyraźnie udostępnić adresy URL obiektów blob, przekazując je do window.URL.revokeObjectURL():

window.URL.revokeObjectURL(blobURL);

W Chrome można zobaczyć wszystkie utworzone adresy URL blobów: chrome://blob-internals/.

Pełny przykład

Co więcej, możemy lepiej zrozumieć, jak kod JavaScript pracownika jest wbudowany w naszą stronę. Ta metoda używa tagu <script> do definiowania instancji roboczej:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

Uważam, że nowe podejście jest trochę bardziej przejrzyste i czytelne. Definiuje tag skryptu z elementami id="worker1" i type='javascript/worker' (dzięki czemu przeglądarka nie analizuje kodu JS). Kod ten jest wyodrębniany jako ciąg znaków za pomocą funkcji document.querySelector('#worker1').textContent i przekazywany do usługi Blob() w celu utworzenia pliku.

Wczytuję skrypty zewnętrzne

Gdy używasz tych technik do wbudowywania kodu instancji roboczej, importScripts() będzie działać tylko wtedy, gdy podasz bezwzględny identyfikator URI. Próba przekazania względnego identyfikatora URI spowoduje, że przeglądarka zgłosi błąd zabezpieczeń. Powód: instancja robocza (teraz utworzona z adresu URL obiektu blob) zostanie rozpoznana za pomocą prefiksu blob:, a aplikacja będzie uruchamiana z innego schematu (prawdopodobnie http://). W związku z tym błąd wynika z ograniczeń z innych domen.

Jednym ze sposobów wykorzystania funkcji importScripts() we wbudowanej instancji roboczej jest „wstrzyknięcie” bieżącego adresu URL głównego skryptu. Aby to zrobić, przekaż go do tej instancji i ręcznie utwórz bezwzględny URL. Dzięki temu będziesz mieć pewność, że zewnętrzny skrypt zostanie zaimportowany z tego samego źródła. Zakładamy, że aplikacja główna jest uruchomiona w http://example.com/index.html:

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

obsługa błędów

Tak jak w przypadku każdej logiki JavaScriptu, musisz radzić sobie ze wszystkimi błędami zgłaszanymi przez roboty sieciowe. Jeśli podczas wykonywania instancji roboczej wystąpi błąd, uruchamiany jest element ErrorEvent. Interfejs zawiera 3 przydatne właściwości umożliwiające ustalenie, co poszło nie tak: filename – nazwa skryptu instancji roboczej, która spowodowała błąd, lineno – numer wiersza, w którym wystąpił błąd, oraz message – zrozumiały opis błędu. Oto przykład konfigurowania modułu obsługi zdarzeń onerror w celu wyświetlania właściwości błędu:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

Przykład: robot workerWithError.js próbuje wykonać 1/x, gdzie x jest niezdefiniowany.

// DO ZROBIENIA: DevSite – usunięto przykładowy kod, ponieważ używał on wbudowanych modułów obsługi zdarzeń

workerWithError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

Kilka słów o bezpieczeństwie

Ograniczenia związane z dostępem lokalnym

Ze względu na ograniczenia bezpieczeństwa Google Chrome instancje robocze nie będą działać lokalnie (np. z file://) w najnowszych wersjach przeglądarki. ale po cichu radzą sobie z tymi problemami. Aby uruchomić aplikację ze schematu file://, uruchom Chrome z ustawioną flagą --allow-file-access-from-files.

Inne przeglądarki nie nakładają takich samych ograniczeń.

Uwagi na temat tej samej domeny

Skrypty instancji roboczych muszą być plikami zewnętrznymi o tym samym schemacie co ich strona wywołująca. Dlatego nie można ładować skryptu z adresu URL data: lub javascript:, a strona https: nie może uruchamiać skryptów instancji roboczych rozpoczynających się od adresów URL http:.

Przykłady zastosowań

Która aplikacja korzysta z pracowników internetowych? Oto kilka kolejnych pomysłów, które mogą Cię rozbudzić:

  • Pobieranie z wyprzedzeniem lub zapisywanie danych w pamięci podręcznej do późniejszego użycia.
  • Podświetlanie składni kodu lub inne formatowanie tekstu w czasie rzeczywistym.
  • sprawdzanie pisowni;
  • Analizuję dane wideo lub audio
  • Wejście-wyjście w tle lub odpytywanie usług internetowych.
  • Przetwarzanie dużych tablic lub trudnych odpowiedzi JSON.
  • Filtrowanie obrazów w: <canvas>.
  • Aktualizowanie wielu wierszy lokalnej bazy danych internetowej.

Więcej informacji o przypadkach użycia związanych z interfejsem Web Workers API znajdziesz w artykule Omówienie instancji roboczych.

Przykłady

Źródła