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, passando da documenti statici con alcuni stili e immagini ad applicazioni complesse e dinamiche. Tuttavia, una cosa è rimasta sostanzialmente 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 sovraccarico. Man mano che le app web diventano più complesse, il thread principale diventa un collo di bottiglia significativo per le prestazioni. A peggiorare le cose, il tempo necessario per eseguire il codice sul thread principale per un determinato utente è quasi completamente imprevedibile perché le funzionalità del dispositivo hanno un impatto enorme sulle prestazioni. Questa imprevedibilità non farà che aumentare man mano che gli utenti accedono al web da un insieme sempre più diversificato di dispositivi, dai feature phone con risorse estremamente limitate ai dispositivi di punta ad alte prestazioni e con frequenza di aggiornamento elevata.
Se vogliamo che le app web sofisticate soddisfino in modo affidabile le linee guida sul rendimento, come i Core Web Vitals, che si basano su dati empirici sulla percezione e sulla psicologia umana, abbiamo bisogno di modi per eseguire il nostro codice off the main thread (OMT).
Perché i web worker?
JavaScript è, per impostazione predefinita, un linguaggio single-thread 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 l'accesso diretto al DOM, possono essere estremamente utili se è necessario svolgere un lavoro considerevole che altrimenti sovraccaricherebbe il thread principale.
Per quanto riguarda i Core Web Vitals, l'esecuzione di operazioni al di fuori del thread principale può essere utile. In particolare, l'offload del lavoro dal thread principale ai web worker 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.
Un minor lavoro del thread principale, soprattutto durante l'avvio, comporta anche un potenziale vantaggio per il Largest Contentful Paint (LCP) riducendo le attività lunghe. Il rendering di un elemento LCP richiede tempo del thread principale, sia per il rendering di testo che di immagini, che sono elementi LCP frequenti e comuni. Riducendo il lavoro complessivo del thread principale, puoi assicurarti che l'elemento LCP della pagina abbia meno probabilità di essere bloccato da un lavoro oneroso che potrebbe essere gestito da un web worker.
Threading con i web worker
In genere, le altre piattaforme supportano il lavoro in 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 race condition.
In JavaScript, possiamo ottenere una funzionalità approssimativamente simile dai web worker, che esistono dal 2007 e sono supportati da tutti i principali browser dal 2012. I web worker vengono eseguiti in parallelo con il thread principale, ma, a differenza del threading del sistema operativo, non possono condividere 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 tramite l'API postMessage. Passa il valore del messaggio come parametro nella chiamata postMessage e poi aggiungi un listener di eventi di messaggio 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 listener 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);
});
Certo, questo approccio è un po' limitato. Storicamente, i web worker sono stati utilizzati principalmente per spostare un singolo lavoro pesante dal thread principale. Cercare di gestire più operazioni con un singolo web worker diventa rapidamente complicato: devi codificare non solo i parametri, ma anche l'operazione nel messaggio e devi tenere traccia delle risposte per abbinarle alle richieste. Questa complessità è probabilmente il motivo per cui i web worker non sono 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 perfetto per molti casi d'uso. Per fortuna, esiste una libreria che fa proprio questo.
Comlink: semplificare il lavoro con i 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 il threading.
Per configurare Comlink, importalo in un web worker e definisci un insieme di funzioni da esporre al thread principale. Poi importi Comlink nel thread principale, esegui il wrapping del 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, con la differenza che ogni funzione restituisce una promise per un valore anziché il valore stesso.
Quale codice dovresti spostare in un web worker?
I web worker non hanno accesso al DOM e a molte API come WebUSB, WebRTC o Web Audio, quindi 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 liberare spazio nel thread principale per le operazioni che devono essere eseguite lì, 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. Questo sembrerebbe rendere difficile la migrazione a un'architettura OMT.
Tuttavia, se passiamo a un modello in cui i problemi relativi alla UI 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 di OMT
Il team di Google Chrome ha sviluppato PROXX come clone di Prato fiorito che soddisfa i requisiti delle app web progressive, tra cui il funzionamento offline e un'esperienza utente coinvolgente. Purtroppo, le prime versioni del gioco avevano prestazioni scarse su dispositivi con risorse limitate come i feature phone, il che ha portato il team a rendersi conto 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 web worker gestisce la logica del gioco, che è puramente computazionale.
L'OMT ha avuto effetti interessanti sulle prestazioni dei feature phone di PROXX. Nella versione non OMT, l'interfaccia utente si blocca per sei secondi dopo che l'utente interagisce con l'app. Non viene fornito alcun feedback e l'utente deve attendere tutti i sei secondi prima di poter fare qualcos'altro.
Nella versione OMT, invece, il gioco impiega dodici secondi per completare un aggiornamento della UI. Anche se questo sembra un calo delle prestazioni, in realtà porta a un aumento del feedback per l'utente. Il rallentamento si verifica perché l'app invia più frame rispetto alla versione non OMT, che non ne invia affatto. In questo modo, l'utente sa che sta succedendo qualcosa e può continuare a giocare mentre l'UI si aggiorna, migliorando notevolmente l'esperienza di gioco.
Si tratta di un compromesso consapevole: offriamo agli utenti di dispositivi con limitazioni un'esperienza che sembra migliore senza penalizzare gli utenti di dispositivi di fascia alta.
Implicazioni di un'architettura OMT
Come mostra l'esempio di PROXX, OMT consente alla tua app di funzionare in modo affidabile su una gamma più ampia di dispositivi, ma non la rende più veloce:
- In questo modo, il lavoro non viene ridotto, ma solo spostato dal thread principale.
- L'overhead di comunicazione aggiuntivo tra il web worker e il thread principale a volte può rallentare leggermente le operazioni.
Valuta i compromessi
Poiché il thread principale è libero di elaborare le interazioni degli utenti, come lo scorrimento, mentre JavaScript è in esecuzione, i frame persi sono meno, anche se il tempo di attesa totale potrebbe essere leggermente più lungo. Far aspettare un po' l'utente è preferibile rispetto a un frame saltato, perché il margine di errore è inferiore per i frame saltati: un frame saltato si verifica in millisecondi, mentre hai centinaia di millisecondi prima che un utente percepisca il tempo di attesa.
A causa dell'imprevedibilità delle prestazioni sui vari dispositivi, l'obiettivo dell'architettura OMT è in realtà ridurre il rischio, rendendo l'app più solida di fronte a condizioni di runtime altamente variabili, non i vantaggi in termini di prestazioni della parallelizzazione. L'aumento della resilienza e i miglioramenti alla UX valgono ampiamente qualsiasi piccolo compromesso in termini di velocità.
Una nota sugli strumenti
I web worker non sono ancora molto diffusi, quindi la maggior parte degli strumenti per i moduli, come webpack e Rollup, non li supporta in modo nativo. (Parcel sì, invece!) Per fortuna, 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, che sono il modo in cui la maggior parte degli utenti accede al web a livello globale. OMT offre un modo promettente per aumentare le prestazioni su questi dispositivi senza influire negativamente sugli utenti di dispositivi di fascia alta.
Inoltre, l'OMT offre vantaggi secondari:
- Sposta i costi di esecuzione di JavaScript in un thread separato.
- Sposta i costi di analisi, il che significa che la UI potrebbe avviarsi più velocemente. Ciò potrebbe ridurre il First Contentful Paint o persino il Time to Interactive, il che a sua volta può aumentare il punteggio Lighthouse.
I web worker non devono essere spaventosi. Strumenti come Comlink semplificano il lavoro dei worker e li rendono una scelta valida per un'ampia gamma di applicazioni web.