Un'architettura off-main-thread può migliorare notevolmente l'affidabilità e l'esperienza utente della tua app.
Negli ultimi 20 anni, il web si è evoluto notevolmente da documenti statici con pochi stili e immagini ad applicazioni complesse e dinamiche. Tuttavia, una cosa è rimasta in gran parte invariata: abbiamo un solo thread per scheda del browser (con alcune eccezioni) per eseguire il rendering dei nostri siti ed eseguire il nostro codice JavaScript.
Di conseguenza, il thread principale è diventato incredibilmente sovraccaricato. Inoltre, con l'aumentare della complessità delle app web, il thread principale diventa un collo di bottiglia significativo per le prestazioni. A peggiorare le cose, il tempo necessario per eseguire il codice nel thread principale per un determinato utente è quasi completamente imprevedibile perché le funzionalità del dispositivo hanno un impatto enorme sulle prestazioni. Questa imprevedibilità aumenterà man mano che gli utenti accedono al web da un insieme sempre più diversificato di dispositivi, dai feature phone con limitazioni estreme ai computer di punta ad alte prestazioni e ad alto refresh rate.
Se vogliamo che le app web sofisticate soddisfino in modo affidabile le linee guida sul rendimento come Core Web Vitals, che si basano su dati empirici sulla percezione e sulla psicologia umana, abbiamo bisogno di modi per eseguire il nostro codice al di fuori del thread principale (OMT).
Perché i web worker?
Per impostazione predefinita, JavaScript è un linguaggio a thread singolo che esegue le attività sul thread principale. Tuttavia, i web worker forniscono una sorta di via di fuga dal thread principale consentendo agli sviluppatori di creare thread separati per gestire il lavoro al di fuori del thread principale. Sebbene l'ambito dei web worker sia limitato e non offra accesso diretto al DOM, possono essere di grande aiuto se è necessario eseguire un lavoro considerevole che altrimenti sovraccaricherà il thread principale.
Per quanto riguarda i Segnali web essenziali, può essere utile eseguire il lavoro al di fuori del thread principale. In particolare, lo sgravamento del lavoro dal thread principale ai worker web può ridurre la contesa per il thread principale, il che può migliorare la metrica di reattività Interaction to Next Paint (INP) di una pagina. Quando il thread principale ha meno lavoro da elaborare, può rispondere più rapidamente alle interazioni degli utenti.
Una minore attività nel thread principale, in particolare durante l'avvio, comporta anche un potenziale vantaggio per il Largest Contentful Paint (LCP), in quanto riduce le attività lunghe. Il rendering di un elemento LCP richiede tempo del thread principale, sia per il rendering di testo o immagini, che sono elementi LCP frequenti e comuni, sia per ridurre il lavoro complessivo del thread principale, puoi assicurarti che l'elemento LCP della tua pagina abbia meno probabilità di essere bloccato da un lavoro costoso che un web worker potrebbe gestire.
Thread con i worker web
Altre piattaforme in genere supportano il lavoro parallelo consentendoti di assegnare a un thread una funzione che viene eseguita in parallelo con il resto del programma. Puoi accedere alle stesse variabili da entrambi i thread e l'accesso a queste risorse condivise può essere sincronizzato con mutex e semafori per evitare condizioni di gara.
In JavaScript, possiamo ottenere funzionalità approssimativamente simili dai web worker, che esistono dal 2007 e sono supportati su tutti i principali browser dal 2012. I worker web vengono eseguiti in parallelo con il thread principale, ma, a differenza del threading del sistema operativo, non possono condividere le variabili.
Per creare un web worker, passa un file al costruttore del worker, che avvia l'esecuzione del file in un thread separato:
const worker = new Worker("./worker.js");
Comunica con il web worker inviando messaggi utilizzando l'API postMessage
. Passa il valore del messaggio come parametro nella chiamata a postMessage
e poi aggiungi un gestore di eventi dei messaggi al worker:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
// ...
});
Per inviare un messaggio al thread principale, utilizza la stessa API postMessage
nel web worker e configura un gestore di eventi nel thread principale:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
console.log(event.data);
});
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
postMessage(a + b);
});
Bisogna ammettere che questo approccio è un po' limitato. Storicamente, i web worker sono stati utilizzati principalmente per spostare un singolo lavoro pesante dal thread principale. Il tentativo di gestire più operazioni con un singolo worker web diventa rapidamente complicato: devi codificare non solo i parametri, ma anche l'operazione nel messaggio e devi eseguire la contabilità per abbinare le risposte alle richieste. È probabile che questa complessità sia la ragione per cui i web worker non siano stati adottati più ampiamente.
Tuttavia, se potessimo rimuovere alcune delle difficoltà di comunicazione tra il thread principale e i web worker, questo modello potrebbe essere ideale per molti casi d'uso. Fortunatamente, esiste una libreria che fa proprio questo.
Comlink: semplificare il lavoro dei web worker
Comlink è una libreria il cui obiettivo è consentirti di utilizzare i web worker senza dover pensare ai dettagli di postMessage
. Comlink ti consente di condividere variabili tra i web worker e il thread principale quasi come altri linguaggi di programmazione che supportano la threading.
Configura Comlink importandolo in un worker web e definendo un insieme di funzioni da esporre al thread principale. Importa quindi Comlink nel thread principale, avvolgi il worker e accedi alle funzioni esposte:
worker.js
import {expose} from 'comlink';
const api = {
someMethod() {
// ...
}
}
expose(api);
main.js
import {wrap} from 'comlink';
const worker = new Worker('./worker.js');
const api = wrap(worker);
La variabile api
nel thread principale si comporta come quella nel web worker, tranne per il fatto che ogni funzione restituisce una promessa per un valore anziché il valore stesso.
Quale codice devi spostare in un worker web?
I worker web non hanno accesso al DOM e a molte API come WebUSB, WebRTC o Web Audio, pertanto non puoi inserire in un worker parti della tua app che si basano su questo accesso. Tuttavia, ogni piccolo frammento di codice spostato in un worker consente di avere più spazio nel thread principale per le cose che devono essere presenti, come l'aggiornamento dell'interfaccia utente.
Un problema per gli sviluppatori web è che la maggior parte delle app web si basa su un framework UI come Vue o React per orchestrare tutto nell'app; tutto è un componente del framework e quindi è intrinsecamente legato al DOM. Ciò potrebbe rendere difficile la migrazione a un'architettura OMT.
Tuttavia, se passiamo a un modello in cui i problemi relativi all'interfaccia utente sono separati da altri problemi, come la gestione dello stato, i web worker possono essere molto utili anche con le app basate su framework. Questo è esattamente l'approccio adottato con PROXX.
PROXX: un case study sull'OMT
Il team di Google Chrome ha sviluppato PROXX come clone di Campo Minato che soddisfa i requisiti delle app web progressive, tra cui il funzionamento offline e un'esperienza utente coinvolgente. Purtroppo, le prime versioni del gioco non funzionavano bene su dispositivi con limitazioni come i cellulari, il che ha portato il team a capire che il thread principale era un collo di bottiglia.
Il team ha deciso di utilizzare i web worker per separare lo stato visivo del gioco dalla sua logica:
- Il thread principale gestisce il rendering di animazioni e transizioni.
- Un worker web gestisce la logica di gioco, che è puramente computazionale.
L'OMT ha avuto effetti interessanti sulle prestazioni del feature phone di PROXX. Nella versione non OMT, l'interfaccia utente viene bloccata per sei secondi dopo l'interazione dell'utente. Non viene fornito alcun feedback e l'utente deve attendere sei secondi prima di poter fare qualcos'altro.
Nella versione OMT, invece, il gioco impiega dodici secondi per completare un aggiornamento dell'interfaccia utente. Sebbene possa sembrare una perdita di rendimento, in realtà porta a un aumento del feedback all'utente. Il rallentamento si verifica perché l'app invia più frame rispetto alla versione non OMT, che non ne invia affatto. L'utente sa quindi che sta succedendo qualcosa e può continuare a giocare mentre l'interfaccia utente si aggiorna, migliorando notevolmente l'esperienza di gioco.
Si tratta di un compromesso consapevole: offriamo agli utenti di dispositivi con limitazioni un'esperienza più piacevole senza penalizzare gli utenti di dispositivi di fascia alta.
Implicazioni di un'architettura OMT
Come mostra l'esempio di PROXX, l'OMT consente di eseguire l'app in modo affidabile su una gamma più ampia di dispositivi, ma non la rende più veloce:
- Sposti semplicemente il lavoro dal thread principale, non lo riduci.
- A volte l'overhead di comunicazione aggiuntivo tra il web worker e il thread principale può rallentare leggermente le operazioni.
Valuta i pro e i contro
Poiché il thread principale è libero di elaborare le interazioni degli utenti, come lo scorrimento, mentre JavaScript è in esecuzione, si verificano meno frame persi, anche se il tempo di attesa totale potrebbe essere leggermente più lungo. È preferibile far attendere un po' l'utente rispetto a eliminare un frame perché il margine di errore è inferiore per i frame persi: l'eliminazione di un frame avviene in millisecondi, mentre hai centinaia di millisecondi prima che un utente percepisca il tempo di attesa.
A causa dell'imprevedibilità delle prestazioni sui dispositivi, l'obiettivo dell'architettura OMT è in realtà ridurre i rischi, rendendo l'app più solida in presenza di condizioni di runtime molto variabili, non i vantaggi delle prestazioni della parallizzazione. L'aumento della resilienza e i miglioramenti all'esperienza utente valgono più di qualsiasi piccolo compromesso in termini di velocità.
Una nota sugli strumenti
I worker web non sono ancora mainstream, quindi la maggior parte degli strumenti per i moduli, come webpack e Rollup, non li supporta immediatamente. (Parcel invece sì). Fortunatamente, esistono plug-in per far funzionare i web worker con webpack e Rollup:
- worker-plugin per webpack
- rollup-plugin-off-main-thread per Rollup
Riepilogo
Per garantire che le nostre app siano il più affidabili e accessibili possibile, soprattutto in un mercato sempre più globalizzato, dobbiamo supportare i dispositivi con limitazioni, tramite i quali la maggior parte degli utenti accede al web a livello globale. L'OMT offre un modo promettente per aumentare le prestazioni su questi dispositivi senza influire negativamente sugli utenti di dispositivi di fascia alta.
L'OMT offre inoltre i seguenti vantaggi secondari:
- Sposta i costi di esecuzione di JavaScript in un thread separato.
- Sposta i costi di analisi, il che significa che l'interfaccia utente potrebbe avviarsi più velocemente. Ciò potrebbe ridurre il First Contentful Paint o anche il Time to Interactive, il che a sua volta può aumentare il punteggio di Lighthouse.
I worker web non devono per forza essere complicati. Strumenti come Comlink sgravano i lavoratori e rappresentano una scelta valida per un'ampia gamma di applicazioni web.