O que é Paralelismo?

Paralelismo pode ser entendido como a execução simultânea de múltiplas atividades. É como se, ao invés de realizar tarefas sequencialmente — lavar a louça, depois preparar o jantar e, por fim, responder e-mails — você pudesse fazer todas ao mesmo tempo. Imagine que você tem a capacidade de se multiplicar, e cada cópia sua assume uma tarefa diferente. Uma cuida da louça, outra prepara a refeição, enquanto outra responde aos e-mails. Essa abordagem otimiza o tempo, pois várias atividades são realizadas em paralelo, em vez de uma de cada vez.

Impactos de usar Paralelismo

  • Eficiência: O paralelismo torna as coisas mais eficientes, porque você está usando melhor o tempo e os recursos disponíveis.
  • Velocidade: As tarefas são concluídas mais rapidamente, o que é ótimo quando você tem prazos a cumprir.
  • Aproveitamento de Recursos: O paralelismo permite aproveitar ao máximo os recursos do seu sistema, como o poder de processamento de um computador com várias CPUs.

Caso de Uso: Processamento Paralelo de Imagens

Suponha que você está construindo uma funcionalidade que precisa processar um grande número de imagens. Esse processamento pode incluir redimensionamento, conversão de formato, aplicação de filtros ou qualquer outra tarefa intensiva em cpu. Fazer isso de forma serial (uma imagem de cada vez) pode ser muito lento. Portanto, você deseja aproveitar o paralelismo para acelerar o processamento.

Abordagem 1: Módulo async/await

Uma das maneiras mais comuns de alcançar o paralelismo em Node.js é usando o módulo async/await. O async/await permite que você escreva código assíncrono de uma maneira mais síncrona e legível.

const fs = require('fs').promises;
const sharp = require('sharp');

async function processImages(imagePaths) {
  const processedImages = [];
  for (const imagePath of imagePaths) {
    const image = await fs.readFile(imagePath);
    const processedImage = await sharp(image).resize(800, 600).toBuffer();
    processedImages.push(processedImage);
  }
  return processedImages;
}

Neste exemplo, estamos lendo e redimensionando várias imagens de forma sequencial, mas podemos melhorar isso com o paralelismo.

Abordagem 2: Módulo Promise.all

Agora, vamos usar o Promise.all para paralelizar o processamento de imagens. Isso permite que várias operações assíncronas ocorram simultaneamente e, em seguida, espera até que todas elas sejam concluídas.

const fs = require('fs').promises;
const sharp = require('sharp');

async function processImages(imagePaths) {
  const processPromises = imagePaths.map(async (imagePath) => {
    const image = await fs.readFile(imagePath);
    return sharp(image).resize(800, 600).toBuffer();
  });
  const processedImages = await Promise.all(processPromises);
  return processedImages;
}

Aqui, estamos criando um array de Promises, cada uma correspondendo ao processamento de uma imagem. Em seguida, usamos Promise.all para esperar que todas as Promises sejam resolvidas em paralelo.

Explicação

  • A abordagem com async/await é mais simples de entender, mas a execução é sequencial, o que pode ser lento para muitas imagens.
  • A abordagem com Promise.all paraleliza as operações de leitura e redimensionamento de imagens, aproveitando melhor os recursos da CPU e acelerando o processo.
  • É importante observar que o Node.js possui um limite para o número de operações simultâneas (por padrão, 4). Se você estiver processando um grande número de imagens, pode ser necessário ajustar esse limite usando Promise.allLimit ou outra biblioteca para evitar sobrecarregar o sistema.

Caso de Uso: Processamento Paralelo de Dados

Exemplo: Imagine que você tem um conjunto de dados grande que precisa ser processado, como uma lista de URLs para fazer requisições HTTP e extrair informações. Nesse caso, você pode usar paralelismo para processar várias URLs simultaneamente, melhorando o desempenho.

const axios = require('axios');

async function processarUrls(urls) {
  const resultados = await Promise.all(urls.map(async (url) => {
    const resposta = await axios.get(url);
    return resposta.data;
  }));
  return resultados;
}

const listaDeUrls = ['<https://example.com>', '<https://example.org>', '<https://example.net>'];
processarUrls(listaDeUrls).then((dados) => {
  console.log(dados);
});

Agora, casos de usos de quando paralelismo não é indicado:

Caso de Uso 1: Tarefas Sequenciais Simples

function tarefaSimples1() {
  console.log("Tarefa 1 iniciada");
  console.log("Tarefa 1 concluída");
}

function tarefaSimples2() {
  console.log("Tarefa 2 iniciada");
  console.log("Tarefa 2 concluída");
}

tarefaSimples1();
tarefaSimples2();

Problema: Usar paralelismo aqui não faz sentido, pois as tarefas são simples e independentes. Introduzir paralelismo apenas adicionaria complexidade ao código sem benefícios reais.

Caso de Uso 2: Recursos Limitados

const { Worker, isMainThread, parentPort } = require('worker_threads');

function executarWorkers() {
  if (isMainThread) {
    const numWorkers = 1000;
    for (let i = 0; i < numWorkers; i++) {
      const worker = new Worker(__filename);
    }
  }
}

executarWorkers();

Problema: Executar um grande número de workers em um sistema com recursos limitados pode sobrecarregar o sistema, resultando em travamentos ou degradação do desempenho.

Caso de Uso 3: Dependência de Resultados Anteriores

function tarefaA() {
  console.log("Tarefa A iniciada");
  setTimeout(() => {
    console.log("Tarefa A concluída");
  }, 1000);
}

function tarefaB() {
  console.log("Tarefa B iniciada");
  setTimeout(() => {
    console.log("Tarefa B concluída");
  }, 500);
}

tarefaA();
tarefaB();

Problema: Se você tentar chamar as tarefas A e B em paralelo, pode ocorrer que a Tarefa B termine antes da Tarefa A, resultando em resultados incorretos ou inesperados devido à dependência.

Caso de Uso 4: Má Gestão do Paralelismo

const { Worker, isMainThread, parentPort } = require('worker_threads');

function executarWorkers() {
  if (isMainThread) {
    const numTrabalhadores = 4;
    for (let i = 0; i < numTrabalhadores; i++) {
      const worker = new Worker(__filename);
      worker.on('message', (message) => {
        console.log(`worker${i} enviou a mensagem: ${message}`);
      });
      worker.postMessage(`Olá, worker${i}!`);
    }
  } else {
    parentPort.on('message', (message) => {
      console.log(`Recebi a mensagem: ${message}`);
    });
    parentPort.postMessage('Olá do worker!');
  }
}

executarWorkers();

Problema: Neste exemplo, há uma má gestão do paralelismo, com workers e messages. Sem sincronização adequada, pode haver problemas de concorrência, como condições de corrida ou resultados imprevisíveis.

Resumo

É importante considerar cuidadosamente se o paralelismo é apropriado para um determinado cenário. Introduzir paralelismo em situações inadequadas pode levar a problemas de desempenho, complexidade adicional e resultados incorretos.

O paralelismo é uma técnica valiosa para melhorar o desempenho de aplicativos que lidam com tarefas assíncronas intensivas. Sempre tenham em mente de ajustar o paralelismo de acordo com a capacidade do sistema e o tipo de tarefa que está sendo realizada.