Skip to content

Non bloccare l'Event Loop (o il Worker Pool)

Dovresti leggere questa guida?

Se stai scrivendo qualcosa di più complicato di un breve script da riga di comando, la lettura di questo documento dovrebbe aiutarti a scrivere applicazioni più performanti e sicure.

Questo documento è scritto con i server Node.js in mente, ma i concetti si applicano anche ad applicazioni Node.js complesse. Laddove i dettagli specifici del sistema operativo variano, questo documento è incentrato su Linux.

Sommario

Node.js esegue il codice JavaScript nell'Event Loop (inizializzazione e callback) e offre un Worker Pool per gestire attività costose come l'I/O dei file. Node.js scala bene, a volte meglio di approcci più pesanti come Apache. Il segreto della scalabilità di Node.js è che utilizza un piccolo numero di thread per gestire molti client. Se Node.js può cavarsela con meno thread, allora può dedicare più tempo e memoria del tuo sistema a lavorare sui client piuttosto che a pagare costi di spazio e tempo per i thread (memoria, cambio di contesto). Ma poiché Node.js ha solo pochi thread, devi strutturare la tua applicazione per usarli saggiamente.

Ecco una buona regola empirica per mantenere veloce il tuo server Node.js: Node.js è veloce quando il lavoro associato a ciascun client in un dato momento è "piccolo".

Questo vale per le callback sull'Event Loop e le attività sul Worker Pool.

Perché dovrei evitare di bloccare l'Event Loop e il Worker Pool?

Node.js utilizza un piccolo numero di thread per gestire molti client. In Node.js ci sono due tipi di thread: un Event Loop (alias il loop principale, thread principale, thread eventi, ecc.) e un pool di k Worker in un Worker Pool (alias il threadpool).

Se un thread sta impiegando molto tempo per eseguire una callback (Event Loop) o un'attività (Worker), lo chiamiamo "bloccato". Mentre un thread è bloccato lavorando per conto di un client, non può gestire le richieste di altri client. Questo fornisce due motivazioni per non bloccare né l'Event Loop né il Worker Pool:

  1. Performance: Se esegui regolarmente attività pesanti su uno dei due tipi di thread, la throughput (richieste/secondo) del tuo server ne risentirà.
  2. Sicurezza: Se è possibile che per un determinato input uno dei tuoi thread possa bloccarsi, un client malintenzionato potrebbe inviare questo "input dannoso", bloccare i tuoi thread e impedirgli di lavorare su altri client. Questo sarebbe un attacco Denial of Service.

Una rapida panoramica di Node

Node.js utilizza l'architettura Event-Driven: ha un Event Loop per l'orchestrazione e un Worker Pool per i task costosi.

Quale codice viene eseguito sull'Event Loop?

All'avvio, le applicazioni Node.js completano prima una fase di inizializzazione, require'ando moduli e registrando callback per gli eventi. Le applicazioni Node.js entrano quindi nell'Event Loop, rispondendo alle richieste client in arrivo eseguendo la callback appropriata. Questa callback viene eseguita sincronicamente e può registrare richieste asincrone per continuare l'elaborazione dopo il suo completamento. Le callback per queste richieste asincrone verranno eseguite anche sull'Event Loop.

L'Event Loop gestirà anche le richieste asincrone non bloccanti effettuate dalle sue callback, ad esempio, I/O di rete.

In sintesi, l'Event Loop esegue le callback JavaScript registrate per gli eventi ed è anche responsabile dell'evasione delle richieste asincrone non bloccanti come l'I/O di rete.

Quale codice viene eseguito sul Worker Pool?

Il Worker Pool di Node.js è implementato in libuv (docs), che espone una API generale di invio dei task.

Node.js utilizza il Worker Pool per gestire i task "costosi". Questo include I/O per i quali un sistema operativo non fornisce una versione non bloccante, così come task particolarmente intensivi dal punto di vista della CPU.

Queste sono le API dei moduli Node.js che utilizzano questo Worker Pool:

  1. I/O-intensivo
    1. DNS: dns.lookup(), dns.lookupService().
    2. [File System][/api/fs]: Tutte le API del file system eccetto fs.FSWatcher() e quelle esplicitamente sincroniche utilizzano il threadpool di libuv.
  2. CPU-intensivo
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: Tutte le API zlib eccetto quelle esplicitamente sincroniche utilizzano il threadpool di libuv.

In molte applicazioni Node.js, queste API sono le uniche fonti di task per il Worker Pool. Applicazioni e moduli che utilizzano un C++ add-on possono inviare altri task al Worker Pool.

Per completezza, osserviamo che quando si chiama una di queste API da una callback sull'Event Loop, l'Event Loop sostiene alcuni costi di configurazione minori mentre entra nei binding C++ di Node.js per quella API e invia un task al Worker Pool. Questi costi sono trascurabili rispetto al costo complessivo del task, motivo per cui l'Event Loop lo sta scaricando. Quando si invia uno di questi task al Worker Pool, Node.js fornisce un puntatore alla corrispondente funzione C++ nei binding C++ di Node.js.

Come decide Node.js quale codice eseguire successivamente?

Astrattamente, l'Event Loop e il Worker Pool mantengono rispettivamente code per gli eventi in sospeso e per i task in sospeso.

In realtà, l'Event Loop non mantiene effettivamente una coda. Ha invece una collezione di descrittori di file che richiede al sistema operativo di monitorare, usando un meccanismo come epoll (Linux), kqueue (OSX), porte eventi (Solaris), o IOCP (Windows). Questi descrittori di file corrispondono a socket di rete, a qualsiasi file che sta monitorando, e così via. Quando il sistema operativo segnala che uno di questi descrittori di file è pronto, l'Event Loop lo traduce nell'evento appropriato e invoca la/le callback associata/e a quell'evento. Puoi saperne di più su questo processo qui.

Al contrario, il Worker Pool usa una vera coda le cui voci sono task da elaborare. Un Worker estrae un task da questa coda e ci lavora, e quando ha finito il Worker genera un evento "Almeno un task è terminato" per l'Event Loop.

Cosa significa questo per la progettazione dell'applicazione?

In un sistema a un thread per client come Apache, a ogni client in sospeso viene assegnato il proprio thread. Se un thread che gestisce un client si blocca, il sistema operativo lo interromperà e darà la possibilità a un altro client. Il sistema operativo garantisce quindi che i client che richiedono una piccola quantità di lavoro non siano penalizzati dai client che richiedono più lavoro.

Poiché Node.js gestisce molti client con pochi thread, se un thread si blocca gestendo la richiesta di un client, le richieste dei client in sospeso potrebbero non ottenere una possibilità fino a quando il thread non termina la sua callback o il suo task. Il trattamento equo dei client è quindi responsabilità della tua applicazione. Ciò significa che non dovresti fare troppo lavoro per nessun client in nessuna singola callback o task.

Questo è parte del motivo per cui Node.js può scalare bene, ma significa anche che sei responsabile di garantire una pianificazione equa. Le sezioni successive spiegano come garantire una pianificazione equa per l'Event Loop e per il Worker Pool.

Non bloccare il Loop degli Eventi

Il Loop degli Eventi nota ogni nuova connessione client e orchestra la generazione di una risposta. Tutte le richieste in arrivo e le risposte in uscita passano attraverso il Loop degli Eventi. Ciò significa che se il Loop degli Eventi impiega troppo tempo in un qualsiasi punto, tutti i client correnti e nuovi non avranno la possibilità di essere serviti.

È necessario assicurarsi di non bloccare mai il Loop degli Eventi. In altre parole, ogni callback JavaScript dovrebbe completarsi rapidamente. Questo ovviamente si applica anche ai vostri await, ai vostri Promise.then, e così via.

Un buon modo per garantire ciò è ragionare sulla "complessità computazionale" delle vostre callback. Se la vostra callback richiede un numero costante di passaggi indipendentemente dai suoi argomenti, allora darete sempre a ogni client in attesa una possibilità equa. Se la vostra callback richiede un numero diverso di passaggi a seconda dei suoi argomenti, allora dovreste pensare a quanto potrebbero essere lunghi gli argomenti.

Esempio 1: Una callback a tempo costante.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200)
})

Esempio 2: Una callback O(n). Questa callback si eseguirà rapidamente per piccoli n e più lentamente per grandi n.

js
app.get('/countToN', (req, res) => {
  let n = req.query.n
  // n iterazioni prima di dare a qualcun altro la possibilità
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`)
  }
  res.sendStatus(200)
})

Esempio 3: Una callback O(n^2). Questa callback si eseguirà ancora rapidamente per piccoli n, ma per grandi n si eseguirà molto più lentamente rispetto al precedente esempio O(n).

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n
  // n^2 iterazioni prima di dare a qualcun altro la possibilità
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`)
    }
  }
  res.sendStatus(200)
})

Quanto si dovrebbe essere attenti?

Node.js utilizza il motore Google V8 per JavaScript, che è piuttosto veloce per molte operazioni comuni. Le eccezioni a questa regola sono le espressioni regolari e le operazioni JSON, discusse di seguito.

Tuttavia, per attività complesse si dovrebbe considerare la limitazione dell'input e il rifiuto degli input troppo lunghi. In questo modo, anche se la vostra callback ha una complessità elevata, limitando l'input si garantisce che la callback non possa richiedere più del tempo peggiore caso sull'input più lungo accettabile. È quindi possibile valutare il costo nel caso peggiore di questa callback e determinare se il suo tempo di esecuzione è accettabile nel vostro contesto.

Blocco dell'Event Loop: REDOS

Un modo comune per bloccare disastrosamente l'Event Loop è utilizzare un'espressione regolare ("vulnerabile") regular expression.

Evitare espressioni regolari vulnerabili

Un'espressione regolare (regexp) confronta una stringa di input con un modello. Di solito pensiamo a una corrispondenza regexp come richiedente un'unica passata attraverso la stringa di input --- O(n) tempo dove n è la lunghezza della stringa di input. In molti casi, una singola passata è effettivamente tutto ciò che serve. Sfortunatamente, in alcuni casi la corrispondenza regexp potrebbe richiedere un numero esponenziale di passate attraverso la stringa di input --- O(2^n) tempo. Un numero esponenziale di passate significa che se il motore richiede x passate per determinare una corrispondenza, saranno necessarie 2*x passate se aggiungiamo solo un altro carattere alla stringa di input. Poiché il numero di passate è linearmente correlato al tempo richiesto, l'effetto di questa valutazione sarà quello di bloccare l'Event Loop.

Un'espressione regolare vulnerabile è una su cui il motore delle espressioni regolari potrebbe impiegare un tempo esponenziale, esponendoti a REDOS su "input dannosi". Se il tuo modello di espressione regolare sia vulnerabile (ovvero il motore regexp potrebbe impiegare un tempo esponenziale su di esso) è in realtà una domanda difficile a cui rispondere e varia a seconda che tu stia usando Perl, Python, Ruby, Java, JavaScript, ecc., ma ecco alcune regole empiriche che si applicano a tutte queste lingue:

  1. Evitare quantificatori annidati come (a+)*. Il motore regexp di V8 può gestirne alcuni rapidamente, ma altri sono vulnerabili.
  2. Evitare le OR con clausole sovrapposte, come (a|a)*. Anche queste sono talvolta veloci.
  3. Evitare l'uso di backreference, come (a.*) \1. Nessun motore regexp può garantire la valutazione di questi in tempo lineare.
  4. Se stai eseguendo una semplice corrispondenza di stringhe, usa indexOf o l'equivalente locale. Sarà più economico e non richiederà mai più di O(n).

Se non sei sicuro che la tua espressione regolare sia vulnerabile, ricorda che Node.js generalmente non ha problemi a segnalare una corrispondenza anche per una regexp vulnerabile e una stringa di input lunga. Il comportamento esponenziale viene attivato quando si verifica una mancata corrispondenza, ma Node.js non può esserne certo fino a quando non prova molti percorsi attraverso la stringa di input.

Un esempio di REDOS

Ecco un esempio di regexp vulnerabile che espone il suo server a REDOS:

js
app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath
  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path')
  } else {
    console.log('invalid path')
  }
  res.sendStatus(200)
})

La regexp vulnerabile in questo esempio è un modo (cattivo!) per verificare un percorso valido su Linux. Fa corrispondere stringhe che sono una sequenza di nomi delimitati da "/", come "/a/b/c". È pericoloso perché viola la regola 1: ha un quantificatore doppiamente nidificato.

Se un client esegue una query con filePath ///.../\n (100 '/' seguiti da un carattere di nuova riga che il "." della regexp non corrisponderà), allora l'Event Loop impiegherà effettivamente per sempre, bloccando l'Event Loop. L'attacco REDOS di questo client fa sì che tutti gli altri client non ottengano un turno fino a quando la corrispondenza regexp non termina.

Per questo motivo, dovresti diffidare dell'utilizzo di espressioni regolari complesse per convalidare l'input dell'utente.

Risorse Anti-REDOS

Esistono alcuni strumenti per verificare la sicurezza delle tue regexp, come

Tuttavia, nessuno di questi catturerà tutte le regexp vulnerabili.

Un altro approccio è quello di utilizzare un motore regexp diverso. È possibile utilizzare il modulo node-re2, che utilizza il motore regexp RE2 di Google, velocissimo. Ma attenzione, RE2 non è compatibile al 100% con le regexp di V8, quindi controlla le regressioni se sostituisci il modulo node-re2 per gestire le tue regexp. E le regexp particolarmente complicate non sono supportate da node-re2.

Se stai cercando di far corrispondere qualcosa di "ovvio", come un URL o un percorso di file, trova un esempio in una libreria regexp o usa un modulo npm, ad esempio ip-regex.

Blocco dell'Event Loop: moduli core di Node.js

Diversi moduli core di Node.js hanno API costose sincronizzate, tra cui:

Queste API sono costose, perché comportano calcoli significativi (crittografia, compressione), richiedono I/O (I/O del file) o potenzialmente entrambi (processo figlio). Queste API sono destinate alla praticità di scripting, ma non sono destinate all'uso nel contesto del server. Se le esegui sull'Event Loop, impiegheranno molto più tempo per completarsi rispetto a una tipica istruzione JavaScript, bloccando l'Event Loop.

In un server, non dovresti utilizzare le seguenti API sincronizzate da questi moduli:

  • Crittografia:
    • crypto.randomBytes (versione sincrona)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • Dovresti anche stare attento a fornire input di grandi dimensioni alle routine di crittografia e decrittografia.
  • Compressione:
    • zlib.inflateSync
    • zlib.deflateSync
  • Sistema di file:
    • Non utilizzare le API del file system sincronizzate. Ad esempio, se il file a cui accedi si trova in un file system distribuito come NFS, i tempi di accesso possono variare ampiamente.
  • Processo figlio:
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

Questo elenco è ragionevolmente completo a partire da Node.js v9.

Blocco del Loop degli Eventi: JSON DOS

JSON.parse e JSON.stringify sono altre operazioni potenzialmente costose. Sebbene siano O(n) nella lunghezza dell'input, per n grandi possono richiedere un tempo sorprendentemente lungo.

Se il tuo server manipola oggetti JSON, in particolare quelli provenienti da un client, dovresti essere cauto riguardo alle dimensioni degli oggetti o delle stringhe con cui lavori sul Loop degli Eventi.

Esempio: blocco JSON. Creiamo un oggetto obj di dimensione 2^21 e lo JSON.stringify, eseguiamo indexOf sulla stringa, e poi lo JSON.parse. La stringa JSON.stringify'd è di 50MB. Ci vogliono 0,7 secondi per convertire l'oggetto in stringa, 0,03 secondi per indexOf sulla stringa da 50MB, e 1,3 secondi per analizzare la stringa.

js
let obj = { a: 1 }
let niter = 20
let before, str, pos, res, took
for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj } // Raddoppia le dimensioni ad ogni iterazione
}
before = process.hrtime()
str = JSON.stringify(obj)
took = process.hrtime(before)
console.log('JSON.stringify ha impiegato ' + took)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('indexOf puro ha impiegato ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse ha impiegato ' + took)

Esistono moduli npm che offrono API JSON asincrone. Vedi ad esempio:

  • JSONStream, che ha API di stream.
  • Big-Friendly JSON, che ha API di stream così come versioni asincrone delle API JSON standard usando il paradigma di partizionamento sul Loop degli Eventi descritto di seguito.

Calcoli complessi senza bloccare il Loop degli Eventi

Supponiamo di voler effettuare calcoli complessi in JavaScript senza bloccare il Loop degli Eventi. Hai due opzioni: partizionamento o offloading.

Partizionamento

Potresti partizionare i tuoi calcoli in modo che ognuno venga eseguito sul Loop degli Eventi ma ceda regolarmente (dia il turno a) altri eventi in sospeso. In JavaScript è facile salvare lo stato di un'attività in corso in una closure, come mostrato nell'esempio 2 di seguito.

Per un semplice esempio, supponiamo di voler calcolare la media dei numeri da 1 a n.

Esempio 1: media non partizionata, costa O(n)

js
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('avg: ' + avg)

Esempio 2: media partizionata, ognuno degli n passi asincroni costa O(1).

js
function asyncAvg(n, avgCB) {
  // Salva la somma in corso nella closure JS.
  let sum = 0
  function help(i, cb) {
    sum += i
    if (i == n) {
      cb(sum)
      return
    }
    // "Ricorsione asincrona".
    // Pianifica la prossima operazione in modo asincrono.
    setImmediate(help.bind(null, i + 1, cb))
  }
  // Avvia l'helper, con CB per chiamare avgCB.
  help(1, function (sum) {
    let avg = sum / n
    avgCB(avg)
  })
}
asyncAvg(n, function (avg) {
  console.log('media di 1-n: ' + avg)
})

Puoi applicare questo principio alle iterazioni di array e così via.

Offloading

Se devi eseguire operazioni più complesse, il partizionamento non è una buona opzione. Questo perché il partizionamento utilizza solo l'Event Loop e non trarrai beneficio dai numerosi core quasi certamente disponibili sulla tua macchina. Ricorda, l'Event Loop dovrebbe orchestrare le richieste del client, non soddisfarle da solo. Per un compito complicato, sposta il lavoro dall'Event Loop a un Worker Pool.

Come eseguire l'offloading

Hai due opzioni per un Worker Pool di destinazione su cui scaricare il lavoro.

  1. Puoi utilizzare il Worker Pool integrato di Node.js sviluppando un addon C++. Su versioni precedenti di Node, compila il tuo addon C++ usando NAN, e sulle versioni più recenti usa N-API. node-webworker-threads offre un modo solo JavaScript per accedere al Worker Pool di Node.js.
  2. Puoi creare e gestire il tuo Worker Pool dedicato al calcolo piuttosto che al Worker Pool di Node.js a tema I/O. I modi più semplici per farlo sono usando Child Process o Cluster.

Non dovresti semplicemente creare un Child Process per ogni client. Puoi ricevere le richieste dei client più velocemente di quanto puoi creare e gestire i processi figlio, e il tuo server potrebbe diventare una fork bomb.

Svantaggi dell'offloading Lo svantaggio dell'approccio di offloading è che comporta un sovraccarico sotto forma di costi di comunicazione. Solo l'Event Loop può vedere il "namespace" (stato JavaScript) della tua applicazione. Da un Worker, non puoi manipolare un oggetto JavaScript nel namespace dell'Event Loop. Invece, devi serializzare e deserializzare tutti gli oggetti che desideri condividere. Quindi il Worker può operare sulla propria copia di questi oggetti e restituire l'oggetto modificato (o una "patch") all'Event Loop.

Per le problematiche di serializzazione, consulta la sezione su JSON DOS.

Alcuni suggerimenti per l'offloading

Potresti voler distinguere tra attività CPU-intensive e I/O-intensive perché hanno caratteristiche marcatamente diverse.

Un'attività CPU-intensive fa progressi solo quando il suo Worker è pianificato, e il Worker deve essere pianificato su uno dei core logici della tua macchina. Se hai 4 core logici e 5 Worker, uno di questi Worker non può fare progressi. Di conseguenza, stai pagando il sovraccarico (costi di memoria e di pianificazione) per questo Worker e non ottieni nulla in cambio.

Le attività I/O-intensive comportano l'interrogazione di un fornitore di servizi esterno (DNS, file system, ecc.) e l'attesa della sua risposta. Mentre un Worker con un'attività I/O-intensive sta aspettando la sua risposta, non ha altro da fare e può essere de-pianificato dal sistema operativo, dando ad un altro Worker la possibilità di inviare la sua richiesta. Quindi, le attività I/O-intensive faranno progressi anche quando il thread associato non è in esecuzione. I fornitori di servizi esterni come database e file system sono stati altamente ottimizzati per gestire molte richieste in sospeso contemporaneamente. Ad esempio, un file system esaminerà un ampio insieme di richieste di scrittura e lettura in sospeso per unire gli aggiornamenti in conflitto e per recuperare i file in un ordine ottimale.

Se ti affidi a un solo Worker Pool, ad esempio il Worker Pool di Node.js, le diverse caratteristiche del lavoro legato alla CPU e legato all'I/O possono danneggiare le prestazioni della tua applicazione.

Per questo motivo, potresti voler mantenere un Computation Worker Pool separato.

Offloading: conclusioni

Per attività semplici, come l'iterazione sugli elementi di un array arbitrariamente lungo, la partizionamento potrebbe essere una buona opzione. Se il calcolo è più complesso, l'offloading è un approccio migliore: i costi di comunicazione, ovvero l'overhead del passaggio di oggetti serializzati tra l'Event Loop e il Worker Pool, sono compensati dal vantaggio dell'utilizzo di più core.

Tuttavia, se il server si basa fortemente su calcoli complessi, è necessario considerare se Node.js sia effettivamente una buona soluzione. Node.js eccelle per i lavori legati all'I/O, ma per calcoli costosi potrebbe non essere l'opzione migliore.

Se si sceglie l'approccio offloading, consultare la sezione sul non blocco del Worker Pool.

Non bloccare il Worker Pool

Node.js ha un Worker Pool composto da k Worker. Se si utilizza il paradigma Offloading discusso sopra, si potrebbe avere un pool di Worker computazionali separato, a cui si applicano gli stessi principi. In entrambi i casi, supponiamo che k sia molto inferiore al numero di client che si potrebbero gestire contemporaneamente. Questo è in linea con la filosofia "un thread per molti client" di Node.js, il segreto della sua scalabilità.

Come discusso sopra, ogni Worker completa la sua attività corrente prima di procedere a quella successiva nella coda del Worker Pool.

Ora, ci sarà una variazione nel costo delle attività necessarie per gestire le richieste dei client. Alcune attività possono essere completate rapidamente (ad esempio, la lettura di file brevi o memorizzati nella cache o la produzione di un piccolo numero di byte casuali), mentre altre richiederanno più tempo (ad esempio, la lettura di file più grandi o non memorizzati nella cache o la generazione di più byte casuali). L'obiettivo dovrebbe essere quello di ridurre al minimo la variazione dei tempi di attività e si dovrebbe utilizzare il partizionamento delle attività per raggiungere questo obiettivo.

Minimizzare la variazione dei tempi di attività

Se l'attività corrente di un Worker è molto più costosa di altre attività, non sarà disponibile per lavorare su altre attività in sospeso. In altre parole, ogni attività relativamente lunga riduce effettivamente le dimensioni del Worker Pool di uno fino al suo completamento. Questo è indesiderabile perché, fino a un certo punto, più Worker ci sono nel Worker Pool, maggiore è la produttività del Worker Pool (attività/secondo) e quindi maggiore è la produttività del server (richieste client/secondo). Un client con un'attività relativamente costosa ridurrà la produttività del Worker Pool, riducendo a sua volta la produttività del server.

Per evitare ciò, è necessario cercare di ridurre al minimo la variazione della durata delle attività inviate al Worker Pool. Sebbene sia opportuno trattare i sistemi esterni accessibili dalle richieste I/O (DB, FS, ecc.) come scatole nere, è necessario essere consapevoli del costo relativo di queste richieste I/O ed evitare di inviare richieste che ci si aspetta possano essere particolarmente lunghe.

Due esempi dovrebbero illustrare la possibile variazione dei tempi di attività.

Esempio di variazione: letture del file system a lungo termine

Supponiamo che il tuo server debba leggere file per gestire alcune richieste client. Dopo aver consultato le API File system di Node.js, hai optato per utilizzare fs.readFile() per semplicità. Tuttavia, fs.readFile() non è (attualmente) partizionata: invia un singolo task fs.read() che copre l'intero file. Se leggi file più corti per alcuni utenti e file più lunghi per altri, fs.readFile() può introdurre una variazione significativa nella lunghezza dei task, a detrimento della produttività del pool di worker.

Per uno scenario peggiore, supponiamo che un attaccante possa convincere il tuo server a leggere un file arbitrario (questa è una vulnerabilità di attraversamento della directory). Se il tuo server esegue Linux, l'attaccante può nominare un file estremamente lento: /dev/random. A tutti gli effetti pratici, /dev/random è infinitamente lento e ogni worker a cui viene richiesto di leggere da /dev/random non terminerà mai quel task. Un attaccante quindi invia k richieste, una per ogni worker, e nessuna altra richiesta client che utilizza il pool di worker farà progressi.

Esempio di variazione: operazioni crittografiche a lungo termine

Supponiamo che il tuo server generi byte casuali crittograficamente sicuri usando crypto.randomBytes(). crypto.randomBytes() non è partizionata: crea un singolo task randomBytes() per generare tanti byte quanti ne hai richiesti. Se crei meno byte per alcuni utenti e più byte per altri, crypto.randomBytes() è un'altra fonte di variazione nella lunghezza dei task.

Partizionamento dei task

I task con costi temporali variabili possono danneggiare la produttività del pool di worker. Per minimizzare la variazione nei tempi dei task, per quanto possibile dovresti partizionare ogni task in sotto-task a costo comparabile. Quando ogni sotto-task viene completato, dovrebbe inviare il sotto-task successivo e, quando il sotto-task finale viene completato, dovrebbe notificare il mittente.

Per continuare l'esempio fs.readFile(), dovresti invece utilizzare fs.read() (partizionamento manuale) o ReadStream (partizionato automaticamente).

Lo stesso principio si applica ai task legati alla CPU; l'esempio asyncAvg potrebbe non essere appropriato per l'Event Loop, ma è adatto al pool di worker.

Quando si partiziona un task in sotto-task, i task più brevi si espandono in un piccolo numero di sotto-task e i task più lunghi si espandono in un numero maggiore di sotto-task. Tra ogni sotto-task di un task più lungo, il worker a cui è stato assegnato può lavorare su un sotto-task da un altro task più corto, migliorando così la produttività complessiva del task del pool di worker.

Si noti che il numero di sotto-task completati non è una metrica utile per la produttività del pool di worker. Preoccupati invece del numero di task completati.

Evitare la suddivisione dei task

Ricordiamo che lo scopo della suddivisione dei task è quello di minimizzare la variazione nei tempi di esecuzione dei task. Se è possibile distinguere tra task più brevi e task più lunghi (ad esempio, sommare una matrice rispetto all'ordinamento di una matrice), è possibile creare un pool di worker per ogni classe di task. Instradare task più brevi e task più lunghi a pool di worker separati è un altro modo per minimizzare la variazione del tempo di esecuzione dei task.

A favore di questo approccio, la suddivisione dei task comporta un overhead (i costi di creazione di una rappresentazione del task del pool di worker e di manipolazione della coda del pool di worker), e l'evitare la suddivisione consente di risparmiare i costi di viaggi aggiuntivi al pool di worker. Inoltre, impedisce di commettere errori nella suddivisione dei task.

Lo svantaggio di questo approccio è che i worker in tutti questi pool di worker incorreranno in overhead di spazio e tempo e si contenderanno il tempo di CPU. Ricordiamo che ogni task legato alla CPU fa progressi solo mentre è pianificato. Di conseguenza, questo approccio dovrebbe essere considerato solo dopo un'attenta analisi.

Pool di worker: conclusioni

Sia che si utilizzi solo il pool di worker di Node.js o che si mantengano pool di worker separati, è necessario ottimizzare la produttività dei task del pool/dei pool.

Per fare ciò, minimizzare la variazione nei tempi di esecuzione dei task utilizzando la suddivisione dei task.

I rischi dei moduli npm

Mentre i moduli core di Node.js offrono blocchi di costruzione per un'ampia varietà di applicazioni, a volte è necessario qualcosa di più. Gli sviluppatori Node.js beneficiano enormemente dell'ecosistema npm, con centinaia di migliaia di moduli che offrono funzionalità per accelerare il processo di sviluppo.

Ricordiamo, tuttavia, che la maggior parte di questi moduli sono scritti da sviluppatori terzi e vengono generalmente rilasciati con solo garanzie di impegno ottimale. Uno sviluppatore che utilizza un modulo npm dovrebbe preoccuparsi di due cose, anche se quest'ultima viene frequentemente dimenticata.

  1. Rispetta le sue API?
  2. Le sue API potrebbero bloccare l'Event Loop o un worker? Molti moduli non fanno alcuno sforzo per indicare il costo delle loro API, a detrimento della community.

Per le API semplici è possibile stimare il costo delle API; il costo della manipolazione delle stringhe non è difficile da capire. Ma in molti casi non è chiaro quanto possa costare un'API.

Se si chiama un'API che potrebbe fare qualcosa di costoso, verificare il costo. Chiedere agli sviluppatori di documentarlo o esaminare il codice sorgente stesso (e inviare un PR che documenti il costo).

Ricordiamo che, anche se l'API è asincrona, non si sa quanto tempo potrebbe impiegare su un worker o sull'Event Loop in ciascuna delle sue partizioni. Ad esempio, supponiamo che nell'esempio asyncAvg dato sopra, ogni chiamata alla funzione helper sommasse metà dei numeri invece di uno di essi. Allora questa funzione sarebbe comunque asincrona, ma il costo di ogni partizione sarebbe O(n), non O(1), rendendola molto meno sicura da usare per valori arbitrari di n.

Conclusione

Node.js ha due tipi di thread: un Event Loop e k Worker. L'Event Loop è responsabile delle callback JavaScript e dell'I/O non bloccante, mentre un Worker esegue attività corrispondenti al codice C++ che completa una richiesta asincrona, incluso l'I/O bloccante e il lavoro intensivo della CPU. Entrambi i tipi di thread lavorano su non più di un'attività alla volta. Se una callback o un'attività richiede molto tempo, il thread che la esegue viene bloccato. Se la tua applicazione esegue callback o attività bloccanti, ciò può portare a una riduzione della produttività (client/secondo) nel migliore dei casi e a un completo denial of service nel peggiore.

Per scrivere un web server ad alta produttività e più resistente ai DoS, è necessario assicurarsi che, sia in input benigni che malevoli, né il tuo Event Loop né i tuoi Worker si blocchino.