A linguagem JavaScript é uma das maravilhas do mundo do software. Ela é incrivelmente poderosa, flexível e versátil. Uma limitação de seu design fundamental, no entanto, é sua natureza single-threaded. O JavaScript tradicional parece lidar com tarefas paralelas, mas isso é um truque de sintaxe. Para atingir o verdadeiro paralelismo, você precisa usar abordagens modernas de multithreading, como web workers e worker threads.
Paralelismo vs. simultaneidade
A maneira mais básica de entender a diferença entre paralelismo e concorrência é que concorrência é semântica, enquanto paralelismo é implementação. O que quero dizer é que concorrência permite que você diga ao sistema (semântica) fazer mais de uma coisa ao mesmo tempo. O paralelismo simplesmente executa múltiplas tarefas simultaneamente (implementação). Todo processamento paralelo é concorrente, mas nem toda programação concorrente é paralela.
No JavaScript puro, você pode dizer à plataforma para fazer algumas coisas:
function fetchPerson(id) {
return new Promise((resolve, reject) => {
fetch(`https://swapi.dev/api/people/${id}`)
.then(response => response.json())
.then(data => resolve(data))
.catch(error => reject(error));
});
}
const lukeId = 1;
const leiaId = 5;
console.log("Fetching Star Wars characters...");
// Fetch character data concurrently (non-blocking)
Promise.all((fetchPerson(lukeId), fetchPerson(leiaId)))
.then(data => {
console.log("Characters received:");
console.log(data(0)); // Data for Luke Skywalker (ID: 1)
console.log(data(1)); // Data for Leia Organa (ID: 5)
})
.catch(error => console.error("Error fetching characters:", error));
console.log("Moving on to other things...");
// Fetching Star Wars characters...
// Moving on to other things...
Characters received:
{name: 'Luke Skywalker', height: '172', mass: '77', …}
{name: 'Leia Organa', height: '150', mass: '49', …}
Isso parece buscar dados sobre Luke e Leia ao mesmo tempo, usando Promise.all
para executar dois fetch
chamadas juntas. Na verdade, porém, o JavaScript agendará cada tarefa para ser manipulada por um thread de aplicativo.
Isso ocorre porque o JavaScript usa um loop de eventos. O loop pega coisas de uma fila tão rápido que muitas vezes parece acontecer simultaneamente — mas não é um processo verdadeiramente simultâneo.
Para realmente fazer duas coisas ao mesmo tempo, precisamos de múltiplos threads. Threads são uma abstração dos processos do sistema operacional subjacente e seu acesso ao hardware, incluindo processadores multi-core.
Multithreading com web workers
Web workers oferecem uma maneira de gerar threads em um navegador da web. Você pode simplesmente carregar um script worker separado do script principal, e ele manipulará mensagens assíncronas. Cada manipulador de mensagens é executado em seu próprio thread, dando a você um verdadeiro paralelismo.
Para nosso exemplo simples de API Star Wars, queremos gerar threads que manipularão ou buscarão requisições. Usar web workers para isso é um exagero, obviamente, mas mantém as coisas simples. Queremos criar um web worker que aceitará uma mensagem do thread principal e emitirá as requisições.
Aqui está o nosso script principal (main.js
) parece agora:
function fetchPersonWithWorker(id) {
return new Promise((resolve, reject) => {
const worker = new Worker('worker.js');
worker.onmessage = function(event) {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
worker.terminate(); // Clean up the worker after receiving the data
}
worker.postMessage({ url: `https://swapi.dev/api/people/${id}` });
});
}
const lukeId = 1; const leiaId = 5;
console.log("Fetching Star Wars characters with web worker...");
// Fetch character data concurrently (truly parallel)
Promise.all((fetchPersonWithWorker(lukeId), fetchPersonWithWorker(leiaId)))
.then(data => {
console.log("Characters received:");
console.log(data(0)); // Data for Luke Skywalker (ID: 1)
console.log(data(1)); // Data for Leia Organa (ID: 5)
})
.catch(error => console.error("Error fetching characters:", error));
console.log("Moving on to other things...");
Isto é semelhante ao primeiro exemplo, mas em vez de usar uma função que funciona localmente em Promise.all
passamos no fetchPersonWithWorker
função. Esta última função cria uma Worker
objeto chamado worker
que é configurado com o worker.js
arquivo.
Depois que o objeto do trabalhador é criado, fornecemos um onmessage
evento nele. Usaremos isso para lidar com as mensagens que retornam do trabalhador. No nosso caso, resolvemos ou rejeitamos a promessa que estamos retornando (consumida por Promise.all
no script principal), então encerramos o trabalhador.
Depois disso, chamamos worker.postMessage()
e passe um objeto JSON simples com um campo URL definido para a URL que queremos chamar.
O trabalhador da web
Aqui está o outro lado da equação, em worker.js
:
// worker.js
onmessage = function(event) {
console.log(“onmessage: “ + event.data); // {"url":"https://swapi.dev/api/people/1"}
const { url } = event.data;
fetch(url)
.then(response => response.json())
.then(data => postMessage(data))
.catch(error => postMessage({ error }));
}
Nosso simples onmessage
O manipulador aceita o evento e usa o campo URL para emitir as mesmas chamadas de busca de antes, mas desta vez usamos postMessage()
para comunicar os resultados de volta para main.js
.
Então, você pode ver que nos comunicamos entre os dois mundos com mensagens usando postMessage
e onmessage
. Lembre o onmessage
manipuladores no worker ocorrem de forma assíncrona em seus próprios threads. (Não use variáveis locais para armazenar dados — eles provavelmente serão apagados).
Threading do lado do servidor com threads de trabalho
Agora vamos dar uma olhada no lado do servidor, usando Node.js. Neste caso, em vez de web workers, usamos o conceito de um fio de trabalho. Um thread de trabalho é semelhante a um web worker, pois passamos mensagens do thread principal para o worker.
Por exemplo, digamos que temos dois arquivos, main.js
e worker.js
. Nós vamos correr main.js
(usando o comando: node main.js
) e irá gerar um thread ao carregar worker.js
como um thread de trabalho. Aqui está o nosso main.js
arquivo:
const { Worker } = require('worker_threads');
function fetchPersonWithWorker(id) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: id });
worker.on('message', (data) => {
if (data.error) {
reject(data.error);
} else {
resolve(data);
}
worker.terminate();
});
worker.on('error', (error) => reject(error));
let url = `https://swapi.dev/api/people/${id}`;
worker.postMessage({ url });
});
}
const lukeId = 1;
const leiaId = 5;
console.log("Fetching Star Wars characters with worker threads...");
Promise.all((fetchPersonWithWorker(lukeId), fetchPersonWithWorker(leiaId)))
.then(data => {
console.log("Characters received: "+ JSON.stringify(data) );
console.log(data(0)); // Data for Luke Skywalker (ID: 1)
console.log(data(1)); // Data for Leia Organa (ID: 5)
})
.catch(error => console.error("Error fetching characters:", error));
console.log("Moving on to other things...");
Nós importamos Worker
de worker_threads
módulo, mas observe que ele é integrado ao Node, então não precisamos do NPM para isso. Para iniciar o worker, criamos um novo Worker
objeto e dar-lhe o worker.js
arquivo como um parâmetro. Uma vez feito isso, adicionamos um ouvinte de mensagem que resolve ou rejeita nossa promessa — isso é exatamente como fizemos para o web worker. Também encerramos o worker quando terminamos, para limpar os recursos.
Por fim, enviamos ao trabalhador uma nova mensagem contendo a URL que queremos recuperar.
O fio do trabalhador
Dê uma olhada no worker.js:
const { parentPort } = require('worker_threads');
parentPort.on('message', (msg) => {
console.log("message(worker): " + msg.url);
fetch(msg.url)
.then(response => response.json())
.then(data => parentPort.postMessage(data))
.catch(error => parentPort.postMessage({ error }));
});
Novamente importamos de worker_threads
desta vez o parentPort
objeto. Este é um objeto que nos permite comunicar com o thread principal. No nosso caso, escutamos o evento message e, quando ele é recebido, descompactamos o campo url dele e o usamos para emitir uma solicitação.
Desta forma, conseguimos solicitações verdadeiramente simultâneas para as URLs. Se você executar o exemplo com node main.js
você verá os dados de ambas as URLs enviados para o console.
Conclusão
Você viu os mecanismos fundamentais para atingir threads verdadeiramente paralelos em JavaScript, tanto no navegador quanto no servidor. Como eles são executados depende do sistema operacional e do perfil de hardware do ambiente host real, mas, em geral, eles dão a você acesso a processos multithread.
Embora o JavaScript não suporte o alcance e a profundidade da programação simultânea encontrados em uma linguagem como Java, os web workers e os worker threads oferecem o mecanismo básico para paralelismo quando você precisa.
Você pode encontrar os exemplos executáveis para este artigo no GitHub aqui. Para executar o exemplo do web worker, digite
$ node server.js
do diretório raiz.
Para executar o exemplo de thread de trabalho, digite:
~/worker-thread $ node main.js