Nesta aula, vamos mergulhar no coração da execução do JavaScript: o call stack (pilha de chamadas) e o event loop (laço de eventos). Esses conceitos são fundamentais para entender como o JavaScript lida com operações síncronas e assíncronas, mesmo sendo uma linguagem de thread única. Ao final, você saberá por que o JavaScript não precisa de múltiplas threads para ser eficiente e como o event loop coordena tarefas de forma não bloqueante.

Vamos começar com o modelo de execução, depois explorar a pilha, a fila de tarefas e, por fim, responder à pergunta clássica: por que JavaScript é single-thread? Cada seção incluirá exemplos práticos para solidificar o aprendizado.

Modelo de execução

O JavaScript possui um modelo de execução baseado em um loop de eventos (event loop). Esse modelo é composto por três componentes principais: a pilha de chamadas (call stack), a fila de tarefas (task queue) e o próprio event loop. O ambiente de execução (seja no navegador ou no Node.js) também inclui APIs da Web (como setTimeout, fetch, DOM events) que interagem com esse modelo.

Quando o código JavaScript é executado, ele é processado linha por linha, mas operações assíncronas (como timers ou requisições de rede) são delegadas a APIs do ambiente. Assim que essas operações são concluídas, suas callbacks são colocadas na fila de tarefas. O event loop verifica constantemente se a pilha está vazia; se estiver, ele pega a primeira tarefa da fila e a empurra para a pilha, executando-a. Esse ciclo contínuo é o que permite que o JavaScript lide com múltiplas operações sem bloquear o thread principal.

Exemplo simples:

console.log('Início');

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

console.log('Fim');

Resultado: 'Início', 'Fim', 'Timeout'. O setTimeout com atraso 0 é colocado na fila e só executa após o stack esvaziar.

Pilha

A pilha de chamadas (call stack) é uma estrutura de dados LIFO (Last In, First Out) que armazena o contexto de execução das funções. Cada vez que uma função é chamada, um novo frame (quadro) é empurrado para o topo da pilha. Quando a função retorna, o frame é removido. O JavaScript executa o código de forma síncrona, processando uma instrução por vez, e a pilha reflete exatamente a ordem de chamadas.

Se uma função recursiva ou um loop infinito for criado, a pilha pode estourar (stack overflow), pois o número máximo de frames é limitado. Por exemplo:

function recursao() {
  recursao();
}
recursao(); // RangeError: Maximum call stack size exceeded

No contexto assíncrono, a pilha é esvaziada antes de qualquer callback da fila ser executado. Isso garante que callbacks não interrompam uma execução em andamento. O event loop só move tarefas da fila para a pilha quando ela está vazia.

Fila de tarefas

A fila de tarefas (task queue ou callback queue) é uma estrutura FIFO (First In, First Out) que armazena callbacks prontos para serem executados. Quando uma operação assíncrona é concluída (por exemplo, um temporizador expira ou uma resposta de requisição chega), o callback correspondente é colocado na fila. O event loop, em cada iteração, verifica se a pilha está vazia; se sim, ele remove o primeiro callback da fila e o coloca na pilha para execução.

Existem diferentes tipos de filas: macro-task queue (para setTimeout, setInterval, I/O) e micro-task queue (para Promises, MutationObserver). As micro-tasks têm prioridade sobre as macro-tasks. O event loop processa toda a fila de micro-tasks antes de pegar a próxima macro-task. Isso é crucial para entender a ordem de execução em códigos com Promises.

Exemplo:

console.log('1');

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

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

console.log('4');

Resultado: 1, 4, 3, 2. A Promise (micro-task) executa antes do setTimeout (macro-task).

Por que JS é single-thread

O JavaScript é single-thread porque possui apenas uma pilha de chamadas e um thread de execução. Isso simplifica o modelo de concorrência, evitando problemas clássicos de threads como deadlocks e condições de corrida. No entanto, ser single-thread não significa ser bloqueante; graças ao event loop e às APIs assíncronas, o JavaScript consegue lidar com muitas operações concorrentemente sem criar múltiplas threads.

Na prática, o navegador ou o Node.js fornecem threads separadas para operações pesadas (como I/O, timers, renderização), mas o código JavaScript principal roda em um único thread. Quando uma operação assíncrona é iniciada, ela é delegada a uma thread do ambiente (por exemplo, a thread do Web API), e o JavaScript continua executando outras instruções. Quando a operação termina, o callback é colocado na fila e eventualmente executado pelo thread principal.

Isso permite que o JavaScript seja eficiente para aplicações interativas, como páginas web, onde o bloqueio do thread principal causaria travamentos na interface do usuário.

Boas práticas

  • Evite operações síncronas longas no thread principal (como loops pesados ou cálculos intensivos) para não bloquear o event loop.
  • Use Promises e async/await para lidar com assincronia de forma legível.
  • Entenda a diferença entre micro-tasks e macro-tasks para prever a ordem de execução.
  • Não confie em setTimeout com atraso 0 para ordenar execuções; micro-tasks sempre vêm antes.

Referências

Exercícios

  1. Qual é a saída do código abaixo? Explique a ordem.
    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. Primeiro, os logs síncronos 'A' e 'D' são executados. A Promise é uma micro-task, então seu callback .then é executado antes do setTimeout (macro-task). Por isso 'C' vem antes de 'B'.
  2. O que acontece se você criar uma função recursiva infinita? Exemplifique.

    ✓ Resposta: Ocorre um estouro de pilha (stack overflow). Exemplo: function loop() { loop(); } loop(); resulta em RangeError: Maximum call stack size exceeded.
  3. Explique a diferença entre micro-task e macro-task. Dê exemplos de cada.

    ✓ Resposta: Micro-tasks são executadas imediatamente após a tarefa atual, antes de qualquer macro-task. Exemplos de micro-tasks: callbacks de Promises, MutationObserver. Macro-tasks são executadas em iterações separadas do event loop. Exemplos: setTimeout, setInterval, I/O.
  4. Por que o JavaScript é single-thread? Quais as vantagens?

    ✓ Resposta: JavaScript é single-thread para simplificar a concorrência, evitando problemas como deadlocks e condições de corrida. A vantagem é que o modelo de event loop permite operações assíncronas não bloqueantes, mantendo a interface responsiva.
  5. Escreva um código que demonstre que uma Promise é executada antes de um setTimeout com atraso 0.

    ✓ Resposta:
    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