Desbloqueie o poder do processamento paralelo em JavaScript. Aprenda a gerenciar Promises concorrentes com Promise.all, allSettled, race e any para aplicações mais rápidas e robustas.
Dominando a Concorrência em JavaScript: Um Mergulho Profundo no Processamento Paralelo de Promises
No cenário do desenvolvimento web moderno, a performance não é um recurso; é um requisito fundamental. Utilizadores em todo o mundo esperam que as aplicações sejam rápidas, responsivas e fluidas. No cerne deste desafio de performance, especialmente em JavaScript, reside o conceito de lidar eficientemente com operações assíncronas. Desde obter dados de uma API até ler um ficheiro ou consultar uma base de dados, muitas tarefas não são concluídas instantaneamente. A forma como gerimos estes períodos de espera pode fazer a diferença entre uma aplicação lenta e uma experiência de utilizador deliciosamente fluida.
O JavaScript, por sua natureza, é uma linguagem de thread único (single-threaded). Isto significa que só pode executar um pedaço de código de cada vez. Pode parecer uma limitação, mas o event loop do JavaScript e o seu modelo de I/O não bloqueante permitem-lhe lidar com tarefas assíncronas com uma eficiência incrível. A pedra angular moderna deste modelo é a Promise — um objeto que representa a eventual conclusão (ou falha) de uma operação assíncrona.
No entanto, o simples uso de Promises ou da sua elegante sintaxe `async/await` não garante automaticamente uma performance ótima. Uma armadilha comum para os programadores é lidar com múltiplas tarefas assíncronas independentes de forma sequencial, criando estrangulamentos desnecessários. É aqui que entra o processamento concorrente de promises. Ao lançar múltiplas operações assíncronas em paralelo e aguardar por elas coletivamente, podemos reduzir drasticamente o tempo total de execução e construir aplicações muito mais eficientes.
Este guia abrangente levar-te-á a um mergulho profundo no mundo da concorrência em JavaScript. Exploraremos as ferramentas incorporadas na linguagem — `Promise.all()`, `Promise.allSettled()`, `Promise.race()` e `Promise.any()` — para te ajudar a orquestrar tarefas paralelas como um profissional. Quer sejas um programador júnior a familiarizar-se com a assincronicidade ou um engenheiro experiente à procura de refinar os seus padrões, este artigo irá equipar-te com o conhecimento para escrever código JavaScript mais rápido, mais resiliente e mais sofisticado.
Primeiro, um Esclarecimento Rápido: Concorrência vs. Paralelismo
Antes de prosseguirmos, é importante esclarecer dois termos que são frequentemente usados de forma intercambiável, mas que têm significados distintos em ciência da computação: concorrência e paralelismo.
- Concorrência é o conceito de gerir múltiplas tarefas ao longo de um período de tempo. Trata-se de lidar com muitas coisas ao mesmo tempo. Um sistema é concorrente se conseguir iniciar, executar e concluir mais do que uma tarefa sem esperar que a anterior termine. No ambiente de thread único do JavaScript, a concorrência é alcançada através do event loop, que permite ao motor alternar entre tarefas. Enquanto uma tarefa de longa duração (como um pedido de rede) está à espera, o motor pode trabalhar noutras coisas.
- Paralelismo é o conceito de executar múltiplas tarefas simultaneamente. Trata-se de fazer muitas coisas ao mesmo tempo. O verdadeiro paralelismo requer um processador multi-core, onde diferentes threads podem ser executadas em diferentes núcleos exatamente ao mesmo tempo. Embora os web workers permitam o verdadeiro paralelismo no JavaScript do navegador, o modelo de concorrência principal que estamos a discutir aqui pertence à thread principal única.
Para operações ligadas a I/O (como pedidos de rede), o modelo concorrente do JavaScript proporciona o *efeito* de paralelismo. Podemos iniciar múltiplos pedidos de uma só vez. Enquanto o motor do JavaScript espera pelas respostas, está livre para fazer outro trabalho. As operações estão a acontecer 'em paralelo' da perspetiva dos recursos externos (servidores, sistemas de ficheiros). Este é o modelo poderoso que vamos aproveitar.
A Armadilha Sequencial: Um Anti-Padrão Comum
Vamos começar por identificar um erro comum. Quando os programadores aprendem `async/await` pela primeira vez, a sintaxe é tão limpa que é fácil escrever código que parece síncrono, mas é inadvertidamente sequencial e ineficiente. Imagine que precisa de obter o perfil de um utilizador, as suas publicações recentes e as suas notificações para construir um painel de controlo.
Uma abordagem ingénua poderia ser assim:
Exemplo: A Busca Sequencial Ineficiente
async function fetchDashboardDataSequentially(userId) {
console.time('sequentialFetch');
console.log('A obter perfil do utilizador...');
const userProfile = await fetchUserProfile(userId); // Espera aqui
console.log('A obter publicações do utilizador...');
const userPosts = await fetchUserPosts(userId); // Espera aqui
console.log('A obter notificações do utilizador...');
const userNotifications = await fetchUserNotifications(userId); // Espera aqui
console.timeEnd('sequentialFetch');
return { userProfile, userPosts, userNotifications };
}
// Imagine que estas funções levam tempo para resolver
// fetchUserProfile -> 500ms
// fetchUserPosts -> 800ms
// fetchUserNotifications -> 1000ms
O que há de errado com este cenário? Cada palavra-chave `await` pausa a execução da função `fetchDashboardDataSequentially` até que a promise seja resolvida. O pedido para `userPosts` nem sequer começa até que o pedido `userProfile` esteja totalmente concluído. O pedido para `userNotifications` não começa até que `userPosts` tenha retornado. Estes três pedidos de rede são independentes uns dos outros; não há razão para esperar! O tempo total gasto será a soma de todos os tempos individuais:
Tempo Total ≈ 500ms + 800ms + 1000ms = 2300ms
Isto é um enorme estrangulamento de performance. Podemos fazer muito, muito melhor.
Desbloquear a Performance: O Poder da Execução Concorrente
A solução é iniciar todas as operações assíncronas de uma só vez, sem as aguardar imediatamente. Isto permite que sejam executadas concorrentemente. Podemos armazenar os objetos Promise pendentes em variáveis e depois usar um combinador de Promises para esperar que todas elas se completem.
Exemplo: A Busca Concorrente Eficiente
async function fetchDashboardDataConcurrently(userId) {
console.time('concurrentFetch');
console.log('A iniciar todas as buscas de uma vez...');
const profilePromise = fetchUserProfile(userId);
const postsPromise = fetchUserPosts(userId);
const notificationsPromise = fetchUserNotifications(userId);
// Agora esperamos que todas se completem
const [userProfile, userPosts, userNotifications] = await Promise.all([
profilePromise,
postsPromise,
notificationsPromise
]);
console.timeEnd('concurrentFetch');
return { userProfile, userPosts, userNotifications };
}
Nesta versão, chamamos as três funções de busca sem `await`. Isto inicia imediatamente os três pedidos de rede. O motor do JavaScript entrega-os ao ambiente subjacente (o navegador ou Node.js) e recebe de volta três Promises pendentes. Depois, `Promise.all()` é usado para esperar que todas estas três promises se resolvam. O tempo total gasto é agora determinado pela operação de maior duração, não pela soma.
Tempo Total ≈ max(500ms, 800ms, 1000ms) = 1000ms
Acabámos de reduzir o nosso tempo de obtenção de dados em mais de metade! Este é o princípio fundamental do processamento paralelo de promises. Agora, vamos explorar as poderosas ferramentas que o JavaScript oferece para orquestrar estas tarefas concorrentes.
O Kit de Ferramentas dos Combinadores de Promises: `all`, `allSettled`, `race` e `any`
O JavaScript fornece quatro métodos estáticos no objeto `Promise`, conhecidos como combinadores de promises. Cada um deles recebe um iterável (como um array) de promises e retorna uma nova e única promise. O comportamento desta nova promise depende do combinador que utilizar.
1. `Promise.all()`: A Abordagem Tudo ou Nada
`Promise.all()` é a ferramenta perfeita para quando tem um grupo de tarefas que são todas críticas para o passo seguinte. Representa a condição lógica "E": A Tarefa 1 E a Tarefa 2 E a Tarefa 3 devem todas ser bem-sucedidas.
- Entrada: Um iterável de promises.
- Comportamento: Retorna uma única promise que é cumprida (fulfills) quando todas as promises de entrada foram cumpridas. O valor de cumprimento é um array com os resultados das promises de entrada, na mesma ordem.
- Modo de Falha: Rejeita imediatamente assim que uma das promises de entrada rejeita. A razão da rejeição é a razão da primeira promise que rejeitou. Isto é frequentemente chamado de comportamento "fail-fast".
Caso de Uso: Agregação de Dados Críticos
O nosso exemplo do painel de controlo é um caso de uso perfeito. Se não conseguir carregar o perfil do utilizador, exibir as suas publicações e notificações pode não fazer sentido. O componente inteiro depende da disponibilidade dos três pontos de dados.
// Função auxiliar para simular chamadas de API
const mockApiCall = (value, delay, shouldFail = false) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error(`Chamada de API falhou para: ${value}`));
} else {
console.log(`Resolvido: ${value}`);
resolve({ data: value });
}
}, delay);
});
};
async function loadCriticalData() {
console.log('A usar Promise.all para dados críticos...');
try {
const [profile, settings, permissions] = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700),
mockApiCall('userPermissions', 500)
]);
console.log('Todos os dados críticos foram carregados com sucesso!');
// Agora renderiza a UI com o perfil, configurações e permissões
} catch (error) {
console.error('Falha ao carregar dados críticos:', error.message);
// Mostra uma mensagem de erro ao utilizador
}
}
// O que acontece se uma falhar?
async function loadCriticalDataWithFailure() {
console.log('\nDemonstrando falha do Promise.all...');
try {
const results = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700, true), // Esta vai falhar
mockApiCall('userPermissions', 500)
]);
} catch (error) {
console.error('Promise.all rejeitado:', error.message);
// Nota: As chamadas 'userProfile' e 'userPermissions' podem ter sido concluídas,
// mas os seus resultados são perdidos porque toda a operação falhou.
}
}
loadCriticalData();
// Após um atraso, chama o exemplo de falha
setTimeout(loadCriticalDataWithFailure, 2000);
Armadilha do `Promise.all()`
A principal armadilha é a sua natureza fail-fast. Se estiver a obter dados para dez widgets diferentes e independentes numa página, e uma API falhar, o `Promise.all()` será rejeitado, e perderá os resultados das outras nove chamadas bem-sucedidas. É aqui que o nosso próximo combinador brilha.
2. `Promise.allSettled()`: O Agregador Resiliente
Introduzido no ES2020, o `Promise.allSettled()` foi uma mudança de paradigma para a resiliência. Foi concebido para quando quer saber o resultado de cada promise, quer tenha sido bem-sucedida ou tenha falhado. Nunca rejeita.
- Entrada: Um iterável de promises.
- Comportamento: Retorna uma única promise que sempre é cumprida. É cumprida assim que todas as promises de entrada estiverem estabelecidas (settled) (seja cumpridas ou rejeitadas). O valor de cumprimento é um array de objetos, cada um descrevendo o resultado de uma promise.
- Formato do Resultado: Cada objeto de resultado tem uma propriedade `status`.
- Se cumprida: `{ status: 'fulfilled', value: oResultado }`
- Se rejeitada: `{ status: 'rejected', reason: oErro }`
Caso de Uso: Operações Independentes e Não Críticas
Imagine uma página que exibe vários componentes independentes: um widget de meteorologia, um feed de notícias e um ticker de ações. Se a API do feed de notícias falhar, ainda quer mostrar a meteorologia e as informações de ações. `Promise.allSettled()` é perfeito para isto.
async function loadDashboardWidgets() {
console.log('\nUsando Promise.allSettled para widgets independentes...');
const results = await Promise.allSettled([
mockApiCall('Dados Meteorológicos', 600),
mockApiCall('Feed de Notícias', 1200, true), // Esta API está em baixo
mockApiCall('Ticker de Ações', 800)
]);
console.log('Todas as promises foram estabelecidas. A processar resultados...');
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Widget ${index} carregado com sucesso com dados:`, result.value.data);
// Renderiza este widget na UI
} else {
console.error(`Widget ${index} falhou ao carregar:`, result.reason.message);
// Mostra um estado de erro específico para este widget
}
});
}
loadDashboardWidgets();
Com `Promise.allSettled()`, a sua aplicação torna-se muito mais robusta. Um único ponto de falha não causa uma cascata que derruba toda a interface do utilizador. Pode lidar com cada resultado de forma graciosa.
3. `Promise.race()`: O Primeiro a Chegar à Meta
`Promise.race()` faz exatamente o que o nome indica. Coloca um grupo de promises a competir umas contra as outras e declara um vencedor assim que a primeira cruza a linha da meta, independentemente de ter sido um sucesso ou uma falha.
- Entrada: Um iterável de promises.
- Comportamento: Retorna uma única promise que se estabelece (cumpre ou rejeita) assim que a primeira das promises de entrada se estabelece. O valor de cumprimento ou a razão de rejeição da promise retornada será o da promise "vencedora".
- Nota Importante: As outras promises não são canceladas. Elas continuarão a ser executadas em segundo plano, e os seus resultados serão simplesmente ignorados pelo contexto do `Promise.race()`.
Caso de Uso: Implementar um Timeout
O caso de uso mais comum e prático para `Promise.race()` é impor um tempo limite (timeout) a uma operação assíncrona. Pode "correr" a sua operação principal contra uma promise de `setTimeout`. Se a sua operação demorar muito, a promise de timeout estabelecer-se-á primeiro, e poderá tratar isso como um erro.
function createTimeout(delay) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Operação excedeu o tempo limite após ${delay}ms`));
}, delay);
});
}
async function fetchDataWithTimeout() {
console.log('\nUsando Promise.race para um timeout...');
try {
const result = await Promise.race([
mockApiCall('alguns dados críticos', 2000), // Isto vai demorar demasiado tempo
createTimeout(1500) // Este vai ganhar a corrida
]);
console.log('Dados obtidos com sucesso:', result.data);
} catch (error) {
console.error(error.message);
}
}
fetchDataWithTimeout();
Outro Caso de Uso: Endpoints Redundantes
Poderia também usar `Promise.race()` para consultar vários servidores redundantes pelo mesmo recurso e aceitar a resposta do servidor mais rápido. No entanto, isto é arriscado porque se o servidor mais rápido retornar um erro (ex: um status code 500), `Promise.race()` rejeitará imediatamente, mesmo que um servidor ligeiramente mais lento tivesse retornado uma resposta bem-sucedida. Isto leva-nos ao nosso último e mais adequado combinador para este cenário.
4. `Promise.any()`: O Primeiro a Ter Sucesso
Introduzido no ES2021, `Promise.any()` é como uma versão mais otimista do `Promise.race()`. Também espera pela primeira promise a estabelecer-se, mas procura especificamente pela primeira a ser cumprida (fulfill).
- Entrada: Um iterável de promises.
- Comportamento: Retorna uma única promise que é cumprida assim que qualquer uma das promises de entrada é cumprida. O valor de cumprimento é o valor da primeira promise que foi cumprida.
- Modo de Falha: Só rejeita se todas as promises de entrada rejeitarem. A razão da rejeição é um objeto especial `AggregateError`, que contém uma propriedade `errors` — um array com todas as razões de rejeição individuais.
Caso de Uso: Obter de Fontes Redundantes
Esta é a ferramenta perfeita para obter um recurso de múltiplas fontes, como servidores primários e de backup ou múltiplas Redes de Distribuição de Conteúdo (CDNs). Apenas se importa em obter uma resposta bem-sucedida o mais rápido possível.
async function fetchResourceFromMirrors() {
console.log('\nUsando Promise.any para encontrar a fonte bem-sucedida mais rápida...');
try {
const resource = await Promise.any([
mockApiCall('CDN Primária', 800, true), // Falha rapidamente
mockApiCall('Mirror Europeu', 1200), // Mais lento, mas terá sucesso
mockApiCall('Mirror Asiático', 1100) // Também tem sucesso, mas é mais lento que o europeu
]);
console.log('Recurso obtido com sucesso de um mirror:', resource.data);
} catch (error) {
if (error instanceof AggregateError) {
console.error('Todos os mirrors falharam ao fornecer o recurso.');
// Pode inspecionar os erros individuais:
error.errors.forEach(err => console.log('- ' + err.message));
}
}
}
fetchResourceFromMirrors();
Neste exemplo, `Promise.any()` irá ignorar a falha rápida da CDN Primária e esperará que o Mirror Europeu seja cumprido, momento em que resolverá com esses dados e efetivamente ignorará o resultado do Mirror Asiático.
Escolher a Ferramenta Certa para o Trabalho: Um Guia Rápido
Com quatro opções poderosas, como decide qual usar? Aqui está um enquadramento simples para a tomada de decisão:
- Preciso dos resultados de TODAS as promises, e é um desastre se QUALQUER uma delas falhar?
UsePromise.all(). Isto é para cenários fortemente acoplados, de tudo ou nada. - Preciso de saber o resultado de TODAS as promises, independentemente de terem sucesso ou falharem?
UsePromise.allSettled(). Isto é para lidar com múltiplas tarefas independentes onde quer processar cada resultado e manter a resiliência da aplicação. - Só me importa a primeira promise a terminar, seja um sucesso ou uma falha?
UsePromise.race(). Isto é principalmente para implementar timeouts ou outras condições de corrida onde o primeiro resultado (de qualquer tipo) é o único que importa. - Só me importa a primeira promise a ter SUCESSO, e posso ignorar as que falharem?
UsePromise.any(). Isto é para cenários que envolvem redundância, como tentar múltiplos endpoints para o mesmo recurso.
Padrões Avançados e Considerações do Mundo Real
Embora os combinadores de promises sejam incrivelmente poderosos, o desenvolvimento profissional muitas vezes requer um pouco mais de nuance.
Limitação de Concorrência e Throttling
O que acontece se tiver um array de 1.000 IDs e quiser obter dados para cada um? Se passar ingenuamente todas as 1.000 chamadas geradoras de promises para o `Promise.all()`, irá disparar instantaneamente 1.000 pedidos de rede. Isto pode ter várias consequências negativas:
- Sobrecarga do Servidor: Poderia sobrecarregar o servidor do qual está a pedir, levando a erros ou performance degradada para todos os utilizadores.
- Limitação de Taxa (Rate Limiting): A maioria das APIs públicas tem limites de taxa. Provavelmente atingirá o seu limite e receberá erros `429 Too Many Requests`.
- Recursos do Cliente: O cliente (navegador ou servidor) pode ter dificuldade em gerir tantas ligações de rede abertas ao mesmo tempo.
A solução é limitar a concorrência processando as promises em lotes (batches). Embora possa escrever a sua própria lógica para isto, bibliotecas maduras como `p-limit` ou `async-pool` lidam com isto de forma graciosa. Aqui está um exemplo conceptual de como poderia abordá-lo manualmente:
async function processInBatches(items, batchSize, processingFn) {
let results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
console.log(`A processar lote a começar no índice ${i}...`);
const batchPromises = batch.map(processingFn);
const batchResults = await Promise.allSettled(batchPromises);
results = results.concat(batchResults);
}
return results;
}
// Exemplo de uso:
const userIds = Array.from({ length: 20 }, (_, i) => i + 1);
// Vamos processar 20 utilizadores em lotes de 5
processInBatches(userIds, 5, id => mockApiCall(`user_${id}`, Math.random() * 1000))
.then(allResults => {
console.log('\nProcessamento em lote completo.');
const successful = allResults.filter(r => r.status === 'fulfilled').length;
const failed = allResults.filter(r => r.status === 'rejected').length;
console.log(`Total de Resultados: ${allResults.length}, Com Sucesso: ${successful}, Falhados: ${failed}`);
});
Uma Nota sobre Cancelamento
Um desafio de longa data com as Promises nativas é que elas não são canceláveis. Uma vez que cria uma promise, ela será executada até à sua conclusão. Embora `Promise.race` possa ajudá-lo a ignorar um resultado lento, a operação subjacente continua a consumir recursos. Para pedidos de rede, a solução moderna é a API `AbortController`, que lhe permite sinalizar a um pedido `fetch` que deve ser abortado. Integrar o `AbortController` com combinadores de promises pode fornecer uma forma robusta de gerir e limpar tarefas concorrentes de longa duração.
Conclusão: Do Pensamento Sequencial ao Concorrente
Dominar o JavaScript assíncrono é uma jornada. Começa com a compreensão do event loop de thread único, progride para o uso de Promises e `async/await` para clareza, e culmina no pensamento concorrente para maximizar a performance. Mudar de uma mentalidade sequencial de `await` para uma abordagem que prioriza o paralelismo é uma das mudanças mais impactantes que um programador pode fazer para melhorar a responsividade da aplicação.
Ao aproveitar os combinadores de promises incorporados, está equipado para lidar com uma vasta variedade de cenários do mundo real com elegância e precisão:
- Use `Promise.all()` para dependências de dados críticas, de tudo ou nada.
- Confie em `Promise.allSettled()` para construir UIs resilientes com componentes independentes.
- Empregue `Promise.race()` para impor restrições de tempo e evitar esperas indefinidas.
- Escolha `Promise.any()` para criar sistemas rápidos e tolerantes a falhas com fontes de dados redundantes.
Da próxima vez que se encontrar a escrever múltiplas declarações `await` seguidas, pare e pergunte: "Estas operações são verdadeiramente dependentes umas das outras?" Se a resposta for não, tem uma oportunidade de ouro para refatorar o seu código para concorrência. Comece a iniciar as suas promises em conjunto, escolha o combinador certo para a sua lógica e veja a performance da sua aplicação disparar.