O Event Loop é o coração do modelo de concorrência do JavaScript. Ele permite que uma única thread execute operações assíncronas sem bloquear a interface do usuário, coordenando a execução de código síncrono, tarefas assíncronas e callbacks. Entender o Event Loop em profundidade é essencial para dominar o comportamento de promessas, timers, eventos e para evitar bugs sutis em aplicações complexas.

Nesta aula, vamos mergulhar nas diferenças entre macrotarefas e microtarefas, na ordem precisa de execução e em exemplos clássicos que confundem até desenvolvedores experientes. Ao final, você será capaz de prever a ordem de execução de qualquer código assíncrono em JavaScript.

Macrotarefas vs microtarefas

No JavaScript, as tarefas assíncronas são divididas em duas categorias principais: macrotarefas (também chamadas de tarefas ou tasks) e microtarefas (microtasks). Essa distinção é fundamental para entender a ordem de execução.

As macrotarefas incluem callbacks de setTimeout, setInterval, eventos de I/O (como leitura de arquivos ou requisições de rede) e eventos de interface do usuário (como cliques). Cada macrotarefa é executada em uma iteração separada do Event Loop. Após a execução de cada macrotarefa, o Event Loop verifica se há microtarefas pendentes e as executa antes de passar para a próxima macrotarefa.

As microtarefas incluem callbacks de promessas (como .then(), .catch(), .finally()) e o queueMicrotask(). Elas são executadas após a conclusão da tarefa atual e antes de qualquer nova macrotarefa. Ou seja, as microtarefas têm prioridade sobre as macrotarefas.

Para visualizar, imagine que o Event Loop é um ciclo que processa uma macrotarefa por vez, mas entre cada macrotarefa, ele drena toda a fila de microtarefas. Isso garante que as promessas sejam resolvidas o mais rápido possível, mas também pode causar atrasos em timers se houver muitas microtarefas.

Exemplo de código que demonstra a diferença:

console.log('Início');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise');
});

console.log('Fim');
// Saída:
// Início
// Fim
// Promise
// setTimeout

Observe que a promessa (microtarefa) é executada antes do setTimeout (macrotarefa), mesmo que ambos estejam agendados para o próximo ciclo. Isso ocorre porque as microtarefas são processadas imediatamente após o código síncrono atual, antes de qualquer macrotarefa.

Ordem de execução

A ordem de execução no Event Loop segue um algoritmo bem definido. Vamos detalhar passo a passo:

  1. Executa o código síncrono: o script é executado linha por linha, até o final.
  2. Executa todas as microtarefas: após o código síncrono, a fila de microtarefas é drenada. Se novas microtarefas forem adicionadas durante a execução, elas também serão executadas antes de passar para a próxima etapa.
  3. Executa a próxima macrotarefa: o Event Loop pega a primeira macrotarefa da fila e a executa.
  4. Repete o ciclo: após cada macrotarefa, ele verifica novamente se há microtarefas e as executa antes de continuar.

Esse ciclo garante que as microtarefas sempre tenham prioridade sobre as macrotarefas, mas também significa que uma macrotarefa pode ser adiada indefinidamente se houver um fluxo contínuo de microtarefas.

Vamos analisar um exemplo mais complexo:

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => console.log('3'));
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => console.log('5'), 0);
});

console.log('6');

// Saída:
// 1
// 6
// 4
// 2
// 3
// 5

Explicação:

  1. O código síncrono imprime 1 e 6.
  2. Depois, a microtarefa da promessa é executada, imprimindo 4 e agendando um setTimeout para imprimir 5.
  3. Em seguida, a primeira macrotarefa (o primeiro setTimeout) é executada, imprimindo 2 e agendando uma microtarefa que imprime 3.
  4. Como as microtarefas são executadas imediatamente após a macrotarefa, o 3 é impresso.
  5. Por fim, a segunda macrotarefa (o setTimeout que imprime 5) é executada.

Esse exemplo mostra como a intercalação entre microtarefas e macrotarefas pode ser confusa, mas segue uma lógica clara.

Exemplos que confundem

Vamos explorar alguns exemplos clássicos que costumam confundir desenvolvedores, mesmo os experientes.

Exemplo 1: Promessas aninhadas

Promise.resolve()
  .then(() => {
    console.log('A');
    Promise.resolve().then(() => console.log('B'));
  })
  .then(() => console.log('C'));

// Saída:
// A
// B
// C

Muitos esperam que C seja impresso antes de B, mas não. Por quê? Porque o segundo .then() (que imprime C) é encadeado à promessa original, mas ele só é executado depois que a primeira promessa é resolvida. A primeira promessa resolve quando o callback do primeiro .then() retorna. Nesse callback, criamos uma nova promessa e agendamos B. Como a primeira promessa já resolveu, o .then() que imprime C é agendado como uma microtarefa, mas a microtarefa de B já estava na fila. Portanto, B é executado primeiro.

Exemplo 2: setTimeout com atraso zero e I/O

const fs = require('fs');

fs.readFile(__filename, () => {
  console.log('Leitura de arquivo');
});

setTimeout(() => console.log('setTimeout'), 0);

// Em Node.js, a ordem pode variar, mas geralmente:
// setTimeout
// Leitura de arquivo

Em Node.js, o setTimeout com atraso 0 geralmente é executado antes do I/O, porque o I/O é tratado em uma fase específica do Event Loop do Node.js, que vem depois dos timers. No navegador, a ordem pode ser diferente.

Exemplo 3: Microtarefa que gera outra microtarefa

function loopMicrotasks() {
  queueMicrotask(() => {
    console.log('Microtarefa');
    loopMicrotasks();
  });
}

loopMicrotasks();
setTimeout(() => console.log('setTimeout'), 0);

// Isso pode causar um loop infinito, travando a página,
// pois as microtarefas nunca terminam e o setTimeout nunca é executado.

Esse exemplo ilustra que, se você criar microtarefas infinitamente, o Event Loop nunca chegará às macrotarefas, bloqueando a execução.

Exemplo 4: async/await com setTimeout

async function foo() {
  console.log('1');
  await new Promise(resolve => setTimeout(resolve, 0));
  console.log('2');
}

foo();
console.log('3');

// Saída:
// 1
// 3
// 2

O await pausa a execução da função async, mas o código fora dela continua. O setTimeout é uma macrotarefa, então o 2 só é impresso após a fila de microtarefas ser drenada e a macrotarefa ser executada.

Boas práticas e observações finais

Compreender o Event Loop é crucial para escrever código assíncrono eficiente e sem surpresas. Algumas boas práticas incluem:

  • Evite criar microtarefas em excesso, pois elas podem adiar timers e causar atrasos perceptíveis.
  • Use queueMicrotask() com moderação, apenas quando precisar de uma operação assíncrona rápida e com prioridade.
  • Prefira async/await para código mais legível, mas lembre-se de que ele ainda usa o mesmo Event Loop.
  • Teste seu código em diferentes ambientes (navegador, Node.js) para entender as variações de ordem.

O Event Loop é um conceito poderoso que, uma vez dominado, elimina uma classe inteira de bugs relacionados à concorrência.

Exercícios

  1. Qual é a saída do seguinte código?

    console.log('A');
    setTimeout(() => console.log('B'), 0);
    Promise.resolve().then(() => console.log('C'));
    console.log('D');

    ✓ Resposta: A saída é: A, D, C, B. O código síncrono imprime A e D, depois a microtarefa da promessa imprime C, e por fim a macrotarefa do setTimeout imprime B.
  2. Explique por que o setTimeout com atraso 0 não é executado imediatamente após o código síncrono.

    ✓ Resposta: O setTimeout é uma macrotarefa. O Event Loop só executa macrotarefas após drenar toda a fila de microtarefas. Como as microtarefas (como promessas) têm prioridade, o setTimeout é adiado até que não haja mais microtarefas pendentes.
  3. Qual é a saída do código abaixo?

    setTimeout(() => console.log('1'), 0);
    Promise.resolve().then(() => console.log('2'));
    queueMicrotask(() => console.log('3'));
    console.log('4');

    ✓ Resposta: A saída é: 4, 2, 3, 1. Primeiro, o código síncrono imprime 4. Depois, as microtarefas são executadas na ordem em que foram adicionadas: a promessa (2) e o queueMicrotask (3). Por fim, a macrotarefa do setTimeout imprime 1.
  4. Escreva um código que demonstre a diferença entre microtarefas e macrotarefas usando Promise e setTimeout.

    ✓ Resposta: Exemplo:

    console.log('Início');
    setTimeout(() => console.log('setTimeout'), 0);
    Promise.resolve().then(() => console.log('Promise'));
    console.log('Fim');
    // Saída: Início, Fim, Promise, setTimeout
    
  5. O que acontece se uma microtarefa criar outra microtarefa indefinidamente? Explique e dê um exemplo.

    ✓ Resposta: Se uma microtarefa criar outra microtarefa indefinidamente, a fila de microtarefas nunca será esvaziada, e o Event Loop nunca passará para a próxima macrotarefa. Isso pode travar o programa, pois os timers e eventos de I/O nunca serão processados. Exemplo:

    function loop() {
      queueMicrotask(() => {
        console.log('Loop');
        loop();
      });
    }
    loop();
    setTimeout(() => console.log('setTimeout'), 0); // Nunca será executado
    

Referências