Quando si lavora con i thread in JavaScript, i Transferable Objects risolvono il problema della copia dei dati, ma impongono un vincolo piuttosto rigido: solo un thread alla volta può accedere al dato trasferito. Esistono però scenari in cui serve qualcosa di diverso, ovvero più thread che leggono e scrivono sulla stessa area di memoria nello stesso momento. Ed è esattamente qui che entra in gioco SharedArrayBuffer, un buffer di memoria che può essere condiviso tra il main thread e uno o più worker senza effettuare alcuna copia. Tutti i thread vedono lo stesso segmento fisico di memoria, e le modifiche apportate da uno sono immediatamente visibili agli altri. Si tratta del vero shared memory, lo stesso concetto che chi ha lavorato con i thread POSIX o con Java conosce bene.
In pratica, il main thread crea un SharedArrayBuffer, lo inizializza con dei valori e lo invia al worker tramite postMessage. Il worker riceve non una copia, ma un riferimento allo stesso blocco di memoria. Può leggere i valori scritti dal main thread, elaborarli e scrivere il risultato in una posizione concordata del buffer. Dopo un certo intervallo, il main thread può leggere quel risultato senza che ci sia stato alcun trasferimento intermedio di dati.
Race condition e il ruolo fondamentale di Atomics
La memoria condivisa porta con sé il problema classico della programmazione concorrente: le race condition. Due thread che leggono lo stesso valore, lo incrementano ciascuno per conto proprio e poi lo riscrivono rischiano di sovrascriversi a vicenda, producendo un risultato sbagliato. Nel mondo JavaScript single threaded questo problema non esiste per definizione, ma con SharedArrayBuffer diventa assolutamente reale.
La soluzione si chiama Atomics, un oggetto che fornisce operazioni atomiche sulla memoria condivisa. Un’operazione atomica è indivisibile: garantisce che nessun altro thread possa interferire mentre è in corso. Le primitive principali sono Atomics.add, Atomics.store, Atomics.load e Atomics.compareExchange. Senza Atomics, un semplice incremento come arrayCondiviso[0] = arrayCondiviso[0] + 1 non è sicuro in presenza di più worker. Con Atomics.add, invece, lettura, addizione e scrittura avvengono in un’unica operazione protetta.
Ma Atomics non si limita a questo. Offre anche meccanismi di sincronizzazione più sofisticati: Atomics.wait mette un worker in attesa finché un valore in una certa posizione di memoria non cambia, mentre Atomics.notify sveglia i worker in attesa. Questo permette di costruire primitive come mutex e semafori direttamente in JavaScript, cosa che fino a qualche anno fa sarebbe sembrata fantascienza nel contesto del browser.
Requisiti di sicurezza e cross origin isolation
SharedArrayBuffer ha una storia travagliata dal punto di vista della sicurezza. Nel 2018 è stato temporaneamente disabilitato da tutti i principali browser a causa delle vulnerabilità Spectre e Meltdown. L’accesso preciso a timer ad alta risoluzione, combinato con la memoria condivisa, poteva essere sfruttato per inferire dati sensibili dalla cache della CPU attraverso attacchi side channel. Da Chrome 92 e Firefox 79 è stato reintrodotto, ma con un requisito preciso: la pagina deve trovarsi in un contesto cross origin isolated.
Questo significa configurare due header HTTP specifici sul server. Il primo è Cross-Origin-Opener-Policy: same-origin, che isola la finestra del browser impedendo ad altre origini di accedervi. Il secondo è Cross-Origin-Embedder-Policy: require-corp, che blocca il caricamento di risorse cross origin non esplicitamente autorizzate. Solo quando entrambi gli header sono presenti, la proprietà crossOriginIsolated della pagina risulta true e SharedArrayBuffer diventa disponibile. Su Nginx, ad esempio, basta aggiungere i due header nella configurazione del server.
Vale la pena ricordare che i Web Workers non hanno accesso al DOM, e questa è una scelta progettuale precisa. Il DOM non è thread safe, e permettere a più thread di modificarlo creerebbe problemi di sincronizzazione enormi. I worker sono quindi adatti al lavoro computazionale puro: calcoli numerici, trasformazioni di dati, parsing, crittografia, compressione. Per aggiornare l’interfaccia, il worker invia un messaggio al main thread che si occupa della parte visuale.
Un’altra cosa da tenere a mente è che i worker hanno un costo di avvio non trascurabile. Il pattern consigliato è mantenere un pool di worker riutilizzabili, ammortizzando il costo di inizializzazione su molte operazioni. Librerie come Comlink di Google semplificano notevolmente la gestione della comunicazione tra thread, nascondendo il sistema a messaggi dietro un’interfaccia basata su Proxy e Promise. Per quanto riguarda SharedArrayBuffer, il consiglio resta quello di utilizzarlo solo quando la comunicazione tramite messaggi risulta davvero insufficiente per ragioni di performance, dato che la complessità aggiuntiva legata alla gestione delle race condition, alla necessità di Atomics e ai requisiti di cross origin isolation va sempre giustificata da un beneficio misurabile.
