Nozioni di base sui web worker

Il problema: contemporaneità JavaScript

Esistono una serie di colli di bottiglia che impediscono la portabilità di applicazioni interessanti (ad esempio, da implementazioni con utilizzo intensivo dei server) a JavaScript lato client. Alcuni di questi includono compatibilità del browser, digitazione statica, accessibilità e prestazioni. Fortunatamente, quest'ultimo sta diventando rapidamente un ricordo del passato, poiché i fornitori di browser migliorano rapidamente la velocità dei loro motori JavaScript.

Un aspetto che è rimasto un ostacolo per JavaScript è in realtà il linguaggio stesso. JavaScript è un ambiente a thread singolo, il che significa che non è possibile eseguire più script contemporaneamente. Ad esempio, immagina un sito che deve gestire eventi UI, eseguire query ed elaborare grandi quantità di dati dell'API e manipolare il DOM. È una cosa piuttosto comune, vero? Sfortunatamente, tutto ciò non può essere contemporaneamente a causa delle limitazioni del runtime JavaScript dei browser. L'esecuzione dello script avviene all'interno di un singolo thread.

Gli sviluppatori imitano la "contemporaneità" utilizzando tecniche come setTimeout(), setInterval(), XMLHttpRequest e gestori di eventi. Sì, tutte queste funzionalità vengono eseguite in modo asincrono, ma il fatto di non bloccare non significa necessariamente contemporaneità. Gli eventi asincroni vengono elaborati al termine dello script attualmente in esecuzione. La buona notizia è che HTML5 ci offre qualcosa di meglio di questi trucchi.

Introduzione ai web worker: integrazione dei thread in JavaScript

La specifica dei web worker definisce un'API per la generazione di script in background nell'applicazione web. I web worker consentono di eseguire operazioni come avviare script a lunga esecuzione per gestire attività che richiedono molta calcolo, ma senza bloccare l'interfaccia utente o altri script per la gestione delle interazioni degli utenti. Ci aiuteranno a mettere e a porre fine a quella brutta finestra di dialogo "script non reattiva" che tutti abbiamo amato:

Finestra di dialogo dello script che non risponde
Finestra di dialogo di scripting comune che non risponde.

I worker utilizzano la trasmissione di messaggi in thread per raggiungere il parallelismo. Sono perfetti per mantenere l'interfaccia utente aggiornata, performante e reattiva per gli utenti.

Tipi di web worker

Vale la pena notare che la specifica riguarda due tipi di worker web: lavoratori dedicati e lavoratori condivisi. Questo articolo riguarda solo i lavoratori dedicati. Ci riferiremo a loro come "web worker" o "lavoratori".

Per iniziare

I web worker vengono eseguiti in un thread isolato. Di conseguenza, il codice che eseguono deve essere contenuto in un file separato. Prima però, la prima cosa da fare è creare un nuovo oggetto Worker nella pagina principale. Il costruttore prende il nome dello script worker:

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

Se il file specificato esiste, il browser genererà un nuovo thread di worker, che viene scaricato in modo asincrono. Il worker non inizierà finché il file non sarà stato scaricato ed eseguito completamente. Se il percorso del worker restituisce un errore 404, il worker restituirà un errore.

Dopo aver creato il worker, avvialo chiamando il metodo postMessage():

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

Comunicare con un lavoratore tramite la trasmissione di messaggi

La comunicazione tra un'attività e la relativa pagina padre viene eseguita utilizzando un modello di evento e il metodo postMessage(). A seconda del browser o della versione, postMessage() può accettare un oggetto stringa o JSON come singolo argomento. Le versioni più recenti dei browser moderni supportano il passaggio di un oggetto JSON.

Di seguito è riportato un esempio di utilizzo di una stringa per passare "Hello World" a un worker in doWork.js. Il worker restituisce semplicemente il messaggio che gli viene passato.

Script principale:

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 (il worker):

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

Quando postMessage() viene chiamato dalla pagina principale, il nostro worker gestisce il messaggio definendo un gestore onmessage per l'evento message. Il payload del messaggio (in questo caso "Hello World") è accessibile in Event.data. Sebbene questo esempio in particolare non sia molto interessante, dimostra che postMessage() è anche il tuo mezzo per ritrasmettere dati al thread principale. Comodo.

I messaggi passati tra la pagina principale e i worker vengono copiati, non condivisi. Nell'esempio successivo, la proprietà "msg" del messaggio JSON è accessibile in entrambe le posizioni. Sembra che l'oggetto venga passato direttamente al worker anche se è in esecuzione in uno spazio dedicato separato. In realtà, ciò che accade è che l'oggetto viene serializzato quando viene consegnato al worker e, successivamente, viene desserializzato dall'altro capo. La pagina e il worker non condividono la stessa istanza, quindi il risultato finale è la creazione di un duplicato in ogni passaggio. La maggior parte dei browser implementa questa funzionalità codifica/decodifica automaticamente in formato JSON il valore alle due estremità.

Di seguito è riportato un esempio più complesso di trasmissione dei messaggi utilizzando oggetti JSON.

Script principale:

<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);

Oggetti trasferibili

La maggior parte dei browser implementa l'algoritmo di clonazione strutturata, che consente di passare tipi più complessi in/out dai worker, come gli oggetti File, Blob, ArrayBuffer e JSON. Tuttavia, quando trasmetti questi tipi di dati utilizzando postMessage(), viene comunque creata una copia. Di conseguenza, se passi un file di grandi dimensioni da 50 MB (ad esempio), si verifica un notevole sovraccarico quando il file viene trasferito tra il worker e il thread principale.

La clonazione strutturata è ottima, ma una copia può richiedere centinaia di millisecondi. Per combattere l'hit di rendimento, puoi utilizzare gli Oggetti trasferibili.

Con gli oggetti trasferibili, i dati vengono trasferiti da un contesto all'altro. Si tratta di zero-copy, che migliora notevolmente le prestazioni di invio dei dati a un worker. È una sorta di "pass-by-reference" se appartieni al mondo C/C++. Tuttavia, a differenza del passaggio per riferimento, la "versione" del contesto di chiamata non è più disponibile una volta trasferita nel nuovo contesto. Ad esempio, quando trasferisci un ArrayBuffer dall'app principale a Worker, il valore ArrayBuffer originale viene cancellato e non può più essere utilizzato. I suoi contenuti vengono (letteralmente) trasferiti al contesto del worker.

Per utilizzare oggetti trasferibili, utilizza una firma leggermente diversa di postMessage():

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

Il caso dei worker, il primo argomento è costituito dai dati e il secondo è l'elenco di elementi che devono essere trasferiti. A proposito, il primo argomento non deve essere un ArrayBuffer. Ad esempio, può essere un oggetto JSON:

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

Il punto importante è che il secondo argomento deve essere un array di ArrayBuffer. Questo è il tuo elenco di elementi trasferibili.

Per ulteriori informazioni sui file trasferibili, leggi il nostro post all'indirizzo developer.chrome.com.

L'ambiente worker

Ambito worker

Nel contesto di un worker, sia self sia this fanno riferimento all'ambito globale del worker. Pertanto, l'esempio precedente potrebbe anche essere scritto come:

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

In alternativa, puoi impostare direttamente il gestore di eventi onmessage (anche se addEventListener è sempre incoraggiato dai ninja JavaScript).

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

Funzionalità disponibili per i lavoratori

A causa del loro comportamento multithread, i web worker hanno accesso solo a un sottoinsieme di funzionalità JavaScript:

  • L'oggetto navigator
  • Oggetto location (sola lettura)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() e setInterval()/clearInterval()
  • La cache dell'applicazione
  • Importazione di script esterni utilizzando il metodo importScripts()
  • Creazione di altri web worker

I worker NON hanno accesso a:

  • Il DOM (non è sicuro per i thread)
  • L'oggetto window
  • L'oggetto document
  • L'oggetto parent

Caricamento di script esterni in corso...

Puoi caricare file di script o librerie di script esterni in un worker con la funzione importScripts(). Il metodo richiede zero o più stringhe che rappresentano i nomi dei file delle risorse da importare.

Questo esempio carica script1.js e script2.js nel worker:

worker.js:

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

che può anche essere scritta come singola istruzione di importazione:

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

Lavoratori secondari

I lavoratori hanno la capacità di generare lavoratori secondari. È un'ottima soluzione per suddividere ulteriormente le attività di grandi dimensioni in fase di runtime. Tuttavia, i lavoratori secondari devono tenere conto di alcune avvertenze:

  • I lavoratori secondari devono essere ospitati all'interno della stessa origine della pagina principale.
  • Gli URI all'interno dei lavoratori secondari vengono risolti in base alla posizione del worker principale (anziché alla pagina principale).

Tieni presente che la maggior parte dei browser generano processi separati per ciascun worker. Prima di creare un'azienda agricola, fai attenzione a non perdere troppe risorse di sistema dell'utente. Uno dei motivi è che i messaggi trasmessi tra le pagine principali e i worker vengono copiati, non condivisi. Vedi Comunicazione con un lavoratore tramite trasmissione di messaggi.

Per un esempio su come generare un lavoro secondario, vedi l'esempio nella specifica.

Worker in linea

E se volessi creare il tuo script worker all'istante o creare una pagina indipendente senza dover creare file worker separati? Con Blob() puoi "incorporare" il worker nello stesso file HTML della logica principale, creando un handle di URL per il codice worker come stringa:

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.

URL BLOB

La magia risiede nella chiamata al numero window.URL.createObjectURL(). Questo metodo crea una stringa URL semplice che può essere utilizzata per fare riferimento ai dati archiviati in un oggetto File o Blob DOM. Ad esempio:

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

Gli URL BLOB sono univoci e durano per tutta la durata della tua applicazione (ad esempio fino all'unload del document). Se stai creando molti URL BLOB, è consigliabile rilasciare i riferimenti che non sono più necessari. Puoi rilasciare esplicitamente un URL BLOB passandolo a window.URL.revokeObjectURL():

window.URL.revokeObjectURL(blobURL);

In Chrome è disponibile una pagina utile per visualizzare tutti gli URL BLOB creati: chrome://blob-internals/.

Esempio completo

Facendo un ulteriore passo in avanti, possiamo imparare a usare il codice JS del worker nella pagina. Questa tecnica utilizza un tag <script> per definire il worker:

<!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>

A mio avviso questo nuovo approccio è un po' più lineare e più leggibile. Definisce un tag script con id="worker1" e type='javascript/worker' (quindi il browser non analizza il codice JS). Il codice viene estratto come stringa utilizzando document.querySelector('#worker1').textContent e trasmesso a Blob() per creare il file.

Caricamento di script esterni in corso...

Quando utilizzi queste tecniche per incorporare il codice worker, importScripts() funziona solo se fornisci un URI assoluto. Se tenti di passare un URI relativo, il browser genera un errore di sicurezza. Il motivo è che il worker (ora creato da un URL blob) verrà risolto con un prefisso blob:, mentre l'app verrà eseguita da uno schema diverso (presumibilmente http://). Di conseguenza, l'errore sarà dovuto a restrizioni tra origini.

Un modo per utilizzare importScripts() in un worker in linea è "inserire" l'URL corrente dello script principale da cui viene eseguito passandolo al worker in linea e costruendo manualmente l'URL assoluto. In questo modo, avrai la certezza che lo script esterno venga importato dalla stessa origine. Supponendo che la tua app principale sia in esecuzione dal giorno 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>

gestione degli errori

Come per qualsiasi logica JavaScript, dovrai gestire eventuali errori che vengono generati dai web worker. Se si verifica un errore durante l'esecuzione di un worker, viene attivato un ErrorEvent. L'interfaccia contiene tre proprietà utili per individuare il problema: filename (il nome dello script worker che ha causato l'errore), lineno (il numero di riga in cui si è verificato l'errore) e message (una descrizione significativa dell'errore). Ecco un esempio di configurazione di un gestore di eventi onerror per stampare le proprietà dell'errore:

<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>

Esempio: workerWithError.js cerca di eseguire 1/x, dove x non è definito.

// TODO: DevSite - Esempio di codice rimosso poiché utilizzava gestori di eventi incorporati

workerWithError.js:

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

Una parola sulla sicurezza

Limitazioni relative all'accesso locale

A causa delle limitazioni di sicurezza di Google Chrome, i worker non verranno eseguiti localmente (ad esempio da file://) nelle versioni più recenti del browser. Invece, falliscono silenzioso! Per eseguire l'app dallo schema file://, esegui Chrome con il flag --allow-file-access-from-files impostato.

Altri browser non impongono le stesse restrizioni.

Considerazioni sulla stessa origine

Gli script worker devono essere file esterni con lo stesso schema della pagina di chiamata. Pertanto, non puoi caricare uno script da un URL data: o javascript: e una pagina https: non può avviare script worker che iniziano con URL http:.

casi d'uso

Quindi, che tipo di app utilizzerebbero i web worker? Ecco qualche altra idea per far scatenare la tua mente:

  • Precaricamento e/o memorizzazione nella cache dei dati per utilizzarli in seguito.
  • Evidenziazione della sintassi del codice o altra formattazione del testo in tempo reale.
  • Controllo ortografico.
  • Analisi dei dati video o audio in corso...
  • I/O in background o polling dei servizi web.
  • Elaborazione di array di grandi dimensioni o enormi risposte JSON.
  • Filtro delle immagini in <canvas>.
  • Aggiornamento di molte righe di un database web locale.

Per ulteriori informazioni sui casi d'uso che coinvolgono l'API Web Workers, visita la panoramica sui worker.

Demo

Riferimenti