ADRIANOLAUREANO← Artigos
RUNTIMEEVENT LOOPPROMISES + MEMÓRIARUST

Como o runtime mantém uma página viva?
Event loop, tarefas, Promises e memória

No artigo anterior, nossa máquina virtual executou bytecode e devolveu um valor. Uma página real, porém, não termina depois do primeiro Return. Ela continua esperando cliques, respostas de rede, temporizadores e Promises. Agora construiremos o Janela Runtime 0.4 para entender quem guarda o estado, quem decide a ordem e por que uma página consegue permanecer viva sem executar JavaScript o tempo inteiro.

ARTIGO 19 · SÉRIE 04 · O TEMPO DA PÁGINA

32etapas didáticas

2filas de trabalho

1runtime em Rust

A PÁGINA VIVA · STACK, HEAP, FILAS, PROMISES E EVENT LOOP
Ilustração técnica colorida mostrando call stack, relógio, filas de tarefas e microtasks, event loop, heap, garbage collector e uma página viva

VOCABULÁRIO VISUAL

Antes de avançar, conheça as peças deste capítulo.

01Runtimeambiente que sustenta a execução
02Call stackpilha das funções ainda ativas
03Heapmemória de objetos duráveis
04Promiserepresentação de um resultado futuro
05Microtaskcontinuação prioritária
06Event loopcoordenador entre pilha e filas
Você não precisa memorizar agora. Este mapa existe para consultar sempre que um termo reaparecer.
01 /
O Primeiro Resultado Não Encerra a Página
Execução síncrona termina; o ambiente continua disponível.

No artigo “Como o navegador entende e executa JavaScript”, percorremos caracteres, tokens, AST e bytecode até a máquina virtual encontrar Return. Isso encerrou aquela sequência de instruções, não o navegador inteiro.

console.log("início");

setTimeout(() => {
    console.log("depois");
}, 1000);

console.log("fim");

O script principal imprime “início” e “fim” e termina. Mesmo assim, algo precisa lembrar do temporizador e executar sua função mais tarde. Esse “algo” é parte do runtime.

02 /
Runtime é o Ambiente que Sustenta a Execução
A máquina virtual executa instruções; o runtime conecta instruções ao mundo.

Runtime pode ser traduzido como ambiente de execução. É o conjunto de estruturas e serviços disponíveis enquanto o programa está rodando: pilha de chamadas, memória de objetos, temporizadores, filas de trabalho e ligação com recursos do navegador.

PeçaPergunta respondida
Máquina virtualqual instrução executar agora?
Call stackquais funções ainda estão ativas?
Heaponde vivem objetos duráveis?
Filasqual trabalho espera sua vez?
Event loopquando um trabalho pode começar?

O runtime não é uma única thread misteriosa. É uma arquitetura feita de peças com responsabilidades separadas.

03 /
Antes das Siglas, Veja o Movimento Completo
Executar, esperar, enfileirar e continuar.
A PÁGINA VIVAO runtime alterna trabalho e espera
01Executaresvaziar a pilha
02Esperartimer, rede ou clique
03Enfileirartrabalho ficou pronto
04Continuarnova volta do ciclo

A página não fica executando um laço JavaScript que verifica o relógio milhões de vezes. O navegador espera de maneira eficiente. Quando alguma operação termina, um trabalho é colocado numa fila.

04 /
Síncrono e Assíncrono Descrevem Quando o Resultado Chega
Esperar uma resposta não precisa bloquear todo o programa.

Uma operação síncrona entrega o resultado antes de a próxima linha continuar. Em 2 + 3, a soma termina imediatamente. Uma operação assíncrona começa agora e conclui depois, como aguardar rede ou um temporizador.

Assíncrono não significa paralelo

O trabalho pode ser aguardado sem bloquear a thread JavaScript, mesmo que partes do sistema usem outras threads internamente. Assincronia descreve a relação temporal entre início e conclusão; paralelismo descreve trabalhos executando fisicamente ao mesmo tempo.

05 /
Thread é uma Linha de Execução
Uma thread só pode executar uma instrução de cada vez.

Thread é uma sequência de instruções que o sistema operacional agenda na CPU. O JavaScript de uma página costuma ser explicado como executando numa thread principal. Isso simplifica o acesso ao documento: duas funções JavaScript não alteram o mesmo botão exatamente no mesmo instante.

O navegador possui outras atividades e pode usar várias threads, mas callbacks JavaScript daquela página entram numa ordem controlada. Essa diferença evita a frase enganosa “o navegador inteiro tem apenas uma thread”.

06 /
Call Stack Guarda as Funções que Ainda Não Terminaram
Cada chamada cria um frame; cada retorno remove um frame.

Call stack, ou pilha de chamadas, organiza funções ativas. Um frame é o registro temporário de uma chamada: nome da função, variáveis locais e ponto de retorno. Construímos isso detalhadamente em Como uma chamada de função funciona por dentro.

pub struct Frame {
    pub function: String,
    pub locals: HashMap<String, Value>,
}

pub struct CallStack {
    frames: Vec<Frame>,
}

push adiciona um frame quando uma função começa. pop remove quando ela retorna. O topo representa a função executando agora.

07 /
Uma Tarefa JavaScript Vai até o Fim
Outro callback não invade uma função pela metade.

A regra run to completion significa que o trabalho JavaScript atual continua até devolver o controle ao runtime. Um clique não interrompe uma função no meio para executar outro callback.

function trabalhoDemorado() {
    // enquanto esta função não retornar,
    // o próximo callback não começa
}

Isso facilita o raciocínio sobre estado, mas cria um risco: uma função longa bloqueia interação, pintura e outras tarefas da mesma thread. “Não travar a página” começa por devolver o controle rapidamente.

08 /
Heap Guarda Objetos que Precisam Sobreviver a uma Chamada
A pilha organiza chamadas; o heap oferece memória durável.

Heap é uma região usada para valores cujo tamanho ou tempo de vida não cabe naturalmente num único frame. Objetos, arrays, funções capturadas e Promises podem continuar vivos depois que a função criadora terminou.

let usuario = {
    nome: "Adriano",
    pontos: 42
};

No Janela Runtime, um objeto recebe um identificador e campos:

pub struct Object {
    pub fields: HashMap<String, Value>,
    marked: bool,
}

marked será usado pelo coletor de memória. O código recebe uma referência ObjectId, não move o objeto inteiro a cada acesso.

09 /
Referência é um Caminho até um Objeto
Variáveis podem apontar para a mesma região do heap.
let a = { valor: 10 };
let b = a;
b.valor = 20;

a e b podem referenciar o mesmo objeto. Atribuir b.valor altera o objeto compartilhado; não cria automaticamente uma cópia.

DUAS REFERÊNCIASNomes diferentes alcançam o mesmo objeto
Avariável aObject#7
07objeto no heapvalor = 20
Bvariável bObject#7
10 /
Raízes São os Pontos de Partida da Memória Viva
Um objeto permanece quando ainda existe um caminho até ele.

Raiz é uma referência que o runtime considera diretamente viva: variáveis globais, valores em frames ativos e trabalhos guardados nas filas. A partir dessas raízes, seguimos referências entre objetos.

pub fn roots(&self) -> impl Iterator<Item = &Value> {
    self.frames
        .iter()
        .flat_map(|frame| frame.locals.values())
}

Essa função percorre todos os frames e entrega suas variáveis locais. Se uma delas aponta para um objeto, temos o começo de um caminho alcançável.

11 /
Garbage Collector Recupera Objetos Inalcançáveis
“Lixo” significa memória à qual o programa não consegue mais chegar.

Garbage collector, ou coletor de lixo, procura objetos sem caminho a partir das raízes. Já construímos esse mecanismo completo em Quem limpa a memória?. Aqui o conectamos ao runtime.

  • Mark: parte das raízes e marca tudo que pode ser alcançado.
  • Sweep: percorre o heap e remove o que não foi marcado.
let before = self.objects.len();
self.objects.retain(|_, object| {
    let keep = object.marked;
    object.marked = false;
    keep
});
let removed = before - self.objects.len();

retain conserva apenas objetos marcados. Antes da próxima coleta, limpamos a marca dos sobreviventes.

12 /
Callbacks Podem Manter Variáveis Vivas
A função termina, mas seu ambiente ainda é necessário.
function criarMensagem(nome) {
    return () => console.log(nome);
}

const depois = criarMensagem("Adriano");

A função interna usa nome mesmo depois que criarMensagem retornou. Essa combinação de função e ambiente chama-se closure. Explicamos captura, upvalues e heap em Como closures funcionam por dentro.

Se um timer guarda o callback depois, o callback vira uma raiz indireta e mantém o ambiente vivo até executar ou ser cancelado.

13 /
Callback é uma Função Guardada para Ser Chamada Depois
Registrar não é executar.
setTimeout(minhaFuncao, 1000);

minhaFuncao é o callback. A chamada setTimeout registra a função e o atraso; não executa imediatamente seu corpo. Quando o prazo for atingido, o runtime transformará esse callback em trabalho pronto.

Erro comum

setTimeout(minhaFuncao(), 1000) chama a função agora e passa seu resultado. Para guardar a função, usamos o nome sem parênteses ou criamos uma função com () => ....

14 /
setTimeout Não Pertence ao Núcleo da Linguagem JavaScript
O navegador oferece APIs ao programa.

JavaScript define valores, expressões, funções e Promises. O navegador acrescenta APIs como document, fetch e setTimeout. Uma API é um contrato pelo qual um componente oferece operações a outro.

Isso explica por que o mesmo JavaScript pode rodar em ambientes diferentes. O motor compreende a linguagem; o ambiente, também chamado de host, fornece capacidades do mundo externo. Um objeto hospedeiro é um valor exposto pelo host, mas implementado fora do motor — document é o exemplo principal no navegador.

pub enum HostCall {
    SetText { node: NodeId, text: String },
    Fetch { url: String, promise: PromiseId },
    SetTimeout { ticks: u64, callback: Job },
}

SetText altera um nó do DOM e marca layout ou pintura como pendentes. Fetch entrega a rede ao navegador e associa a conclusão a uma Promise. SetTimeout registra trabalho futuro. Assim o motor não precisa conhecer HTTP, Wayland ou regras de CSS; ele chama contratos do navegador.

15 /
Timer Marca o Momento Mínimo, Não uma Execução Exata
Ficar pronto e começar a executar são eventos diferentes.

setTimeout(callback, 1000) significa “não torne este callback elegível antes de aproximadamente um segundo”. Se a thread estiver ocupada quando o prazo terminar, o callback aguardará.

pub struct Timer {
    pub ready_at: u64,
    pub job: Job,
}

Nosso projeto usa ticks, passos de um relógio didático. Assim os testes são determinísticos e não dependem da velocidade do computador.

pub fn set_timeout(&mut self, ticks: u64, job: Job) {
    self.timers.push(Timer {
        ready_at: self.clock + ticks.max(1),
        job,
    });
}
16 /
Tarefa é um Trabalho Pronto para uma Volta do Ciclo
Cliques, timers e mensagens podem originar tarefas.

Uma tarefa representa um trabalho JavaScript que poderá ocupar a call stack quando ela estiver vazia. A fila conserva a ordem de chegada.

tasks: VecDeque<Job>

pub fn queue_task(&mut self, job: Job) {
    self.tasks.push_back(job);
}

VecDeque permite acrescentar no final com push_back e retirar do começo com pop_front. Isso cria uma fila FIFO: o primeiro a entrar é o primeiro a sair.

17 /
Job Torna Cada Trabalho Explícito no Projeto
Em vez de esconder closures Rust, representamos intenções observáveis.
pub enum Job {
    Print(String),
    ResolvePromise { promise: PromiseId, value: Value },
    PromiseReaction { label: String, value: Value },
}

Job é o nome geral de uma unidade executável no nosso runtime. Print registra uma saída. ResolvePromise conclui uma Promise. PromiseReaction executa a continuação registrada por then.

Um navegador real guarda callbacks e contextos mais complexos. O enum reduz o problema sem esconder a ordem.

18 /
Event Loop Decide Quando a Próxima Tarefa Pode Começar
Ele coordena filas; não executa duas tarefas JavaScript ao mesmo tempo.

Event loop, ou ciclo de eventos, repete uma decisão: se a call stack está vazia e existe trabalho pronto, retire o próximo e execute. Depois trate trabalhos prioritários e devolva o controle ao navegador.

loop {
    self.move_ready_timers();

    if let Some(task) = self.tasks.pop_front() {
        self.execute(task)?;
        self.drain_microtasks()?;
        self.clock += 1;
        continue;
    }

    if self.timers.is_empty() {
        break;
    }
}

O loop termina no nosso executável quando não há tarefas, microtasks ou timers. Numa página real, o navegador pode continuar aguardando novos cliques e mensagens.

19 /
Esperar Não Significa Gastar CPU Num Laço Vazio
O sistema pode dormir até algo ficar pronto.

Busy waiting seria verificar o relógio repetidamente sem realizar trabalho útil. Sistemas reais usam notificações do sistema operacional para dormir e acordar quando rede, entrada ou prazo exige atenção.

No simulador, saltamos diretamente para o próximo timer:

self.clock = self.timers
    .iter()
    .map(|timer| timer.ready_at)
    .min()
    .unwrap();

Isso mantém a ordem determinística e representa a ideia de não ocupar a CPU durante a espera.

20 /
Promise Representa um Resultado que Ainda Pode Chegar
Ela é um objeto com estado e continuações registradas.

Uma Promise é uma promessa de resultado futuro. Ela começa pending, pendente. Depois pode ficar fulfilled, concluída com valor, ou rejected, rejeitada com erro. Nossa primeira versão implementa pendente e concluída.

pub enum PromiseState {
    Pending,
    Fulfilled(Value),
}

pub struct Promise {
    pub state: PromiseState,
    pub reactions: Vec<String>,
}

Uma reação é o trabalho registrado por then. Enquanto a Promise está pendente, as reações aguardam. Uma implementação completa mantém listas separadas para conclusão e rejeição. catch registra o tratamento da rejeição e finally registra uma continuação que ocorre nos dois resultados. A especificação ECMAScript transforma essas reações em Jobs; no navegador, o host as coloca na fila de microtasks.

Limite verificável

O Janela Runtime 0.4 implementa apenas Pending → Fulfilled. Erros, encadeamento que cria uma nova Promise, catch, finally e assimilação de thenables aparecem na tabela de limites porque exigiriam novas transições, não apenas mais um match.

21 /
then Registra uma Continuação
Ele não bloqueia esperando a Promise.
pedido.then(valor => {
    console.log(valor);
});

then recebe uma função que deverá continuar o trabalho quando houver valor. Se a Promise ainda está pendente, guardamos a reação. Se já foi concluída, enfileiramos a reação imediatamente — mas ainda não a executamos dentro de then.

match state {
    PromiseState::Pending => promise.reactions.push(label),
    PromiseState::Fulfilled(value) => {
        self.queue_microtask(Job::PromiseReaction { label, value });
    }
}
22 /
Microtask é uma Continuação Prioritária
Reações de Promise não usam a fila comum de timers.

Microtask é um trabalho pequeno executado depois da tarefa atual, antes de o event loop escolher a próxima tarefa comum. Reações de Promise e queueMicrotask entram nessa fila.

microtasks: VecDeque<Job>

fn drain_microtasks(&mut self) -> Result<(), String> {
    while let Some(job) = self.microtasks.pop_front() {
        self.execute(job)?;
    }
    Ok(())
}

Drenar significa executar até a fila ficar vazia. Se uma microtask cria outra microtask, a nova também roda antes da próxima tarefa.

23 /
Promise e setTimeout Não Possuem a Mesma Prioridade
A fila explica uma ordem que parece surpreendente.
console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");

A ordem é A, D, C, B. A e D pertencem à tarefa atual. A reação da Promise é microtask e roda quando a tarefa termina. O timer gera uma tarefa comum e espera a próxima volta.

ORDEM OBSERVÁVELTarefa atual → microtasks → próxima tarefa
1A · Dscript principal
2Cmicrotask da Promise
3Btarefa do timer
24 /
Resolver uma Promise Move Suas Reações para Microtasks
Mudar o estado e executar callbacks são momentos separados.
fn resolve(&mut self, id: PromiseId, value: Value) {
    promise.state = PromiseState::Fulfilled(value.clone());
    let reactions = std::mem::take(&mut promise.reactions);

    for label in reactions {
        self.queue_microtask(Job::PromiseReaction {
            label,
            value: value.clone(),
        });
    }
}

std::mem::take troca o vetor de reações por um vetor vazio e nos entrega o antigo. Cada reação vira microtask. Nenhuma é executada enquanto o estado ainda está sendo alterado.

25 /
O Navegador Também Precisa de Oportunidades para Renderizar
JavaScript longo pode impedir que pixels atualizados cheguem à tela.

No artigo “Como o navegador abre um site”, layout, pintura e composição transformaram o documento em pixels. O navegador costuma aproveitar intervalos entre trabalhos JavaScript para atualizar a tela.

Se uma tarefa ocupa a thread por vários segundos, o DOM pode mudar na memória, mas a nova imagem não aparece imediatamente. A renderização não “entra no meio” da função atual.

Microtasks também podem atrasar a pintura

Como a fila de microtasks é drenada antes da próxima tarefa e da oportunidade de renderização, uma cadeia infinita de microtasks também pode impedir progresso visual.

26 /
Um Clique Vira Tarefa Antes de Virar Callback
Entrada nativa e execução JavaScript se encontram numa fila.

No artigo “Como um computador desenha uma janela”, Wayland entregou eventos de mouse à nossa GUI. Num navegador, o evento atravessa hit testing, encontra o elemento e, se houver um listener, cria trabalho para o JavaScript.

botao.addEventListener("click", () => {
    botao.textContent = "Pronto";
});

Listener é uma função registrada para um tipo de evento. Registrar o listener não ocupa a call stack permanentemente; o runtime guarda a referência e a usa quando o evento gerar uma tarefa.

27 /
Rede Também Conclui Fora da Call Stack
HTTP transporta a resposta; o runtime agenda a continuação.

fetch inicia uma operação de rede oferecida pelo navegador e devolve uma Promise. DNS, TCP, TLS e HTTP foram explicados no artigo “Como o navegador abre um site”. O runtime não substitui essas camadas; coordena o momento em que o JavaScript receberá o resultado.

fetch("/dados.json")
    .then(resposta => resposta.json())
    .then(dados => console.log(dados));

Cada then registra uma continuação. Quando a operação correspondente é concluída, sua reação entra na fila de microtasks.

28 /
Testamos Ordem e Memória Sem Depender do Relógio Real
Ticks e heaps pequenos tornam falhas reproduzíveis.
#[test]
fn promise_roda_antes_do_timer_pronto() {
    let mut runtime = Runtime::new();
    let promise = runtime.create_promise();
    runtime.then(promise, "then").unwrap();

    runtime.queue_task(Job::ResolvePromise {
        promise,
        value: Value::Number(7.0),
    });
    runtime.set_timeout(1, Job::Print("timer".into()));

    assert_eq!(
        runtime.run_until_idle().unwrap(),
        vec!["then: 7", "timer"]
    );
}

Outro teste cria pai, filho e órfão no heap. A raiz alcança pai e filho; o coletor remove somente o órfão. Assim verificamos agendamento e memória separadamente.

29 /
O Janela Runtime 0.4 Completo
Cada módulo corresponde a uma peça já construída.
janela-runtime/
├── src/
│   ├── value.rs      # valores e referências
│   ├── stack.rs      # frames e raízes
│   ├── heap.rs       # objetos e mark-and-sweep
│   ├── promise.rs    # estado e reações
│   ├── job.rs        # tarefas, microtasks e timers
│   ├── runtime.rs    # filas e event loop
│   ├── lib.rs        # API do projeto
│   └── main.rs       # cenário observável
└── tests/runtime.rs
let mut runtime = Runtime::new();

runtime.queue_task(Job::Print("script principal".into()));

let promise = runtime.create_promise();
runtime.then(promise, "Promise concluída")?;
runtime.queue_task(Job::ResolvePromise {
    promise,
    value: Value::Number(42.0),
});

runtime.set_timeout(1, Job::Print("timer concluído".into()));

for line in runtime.run_until_idle()? {
    println!("{line}");
}

A saída demonstra a política, não apenas o resultado:

script principal
Promise concluída: 42
timer concluído

Execute com cargo run e valide com cargo test.

30 /
O que um Runtime Profissional Acrescenta
Nosso modelo revela a política; navegadores resolvem escala, isolamento e integração.
ÁreaJanela Runtime 0.4Navegador profissional
Tempoticks determinísticosrelógios, throttling e suspensão
Filastarefas e microtasksmúltiplas fontes e prioridades
Promisepending e fulfilledrejection, encadeamento e assimilação
Memóriamark-and-sweep diretogerações, incremental e concorrente
Integraçãojobs didáticosDOM, rede, workers, mídia e armazenamento
31 /
Mapa de Consulta Rápida
Uma frase para recuperar cada conceito.
TermoSignificado
Runtimeambiente que sustenta a execução
Call stackpilha das funções ainda ativas
Frameestado temporário de uma chamada
Heapmemória de objetos duráveis
Callbackfunção guardada para depois
Tarefatrabalho de uma volta do event loop
Microtaskcontinuação prioritária após a tarefa atual
Promiserepresentação de resultado futuro
Event loopcoordenador entre pilha e filas
GCrecuperador de objetos inalcançáveis
32 /
Agora a Página Consegue Esperar sem Deixar de Estar Viva
Memória preserva o passado; filas organizam o futuro.

A call stack organizou o trabalho atual. O heap preservou objetos. O garbage collector recuperou o que deixou de ser alcançável. Temporizadores e APIs externas prepararam trabalhos. A fila de tarefas aguardou sua vez. Promises produziram microtasks. O event loop conectou tudo sem executar JavaScript inutilmente durante a espera.

O MAPA FINALO coração temporal da página
01Stack + heapestado atual
02APIs + timerstrabalho externo
03Filasordem futura
04Event looppróxima execução
Próximo artigo

Nosso motor já compreende, executa e mantém código vivo. No artigo “Como um motor JavaScript fica rápido” construiremos o nosso V8 didático: coleta de perfis, caminhos quentes, formas de objetos, inline caches, geração de código nativo, JIT e desotimização — novamente começando pelo problema antes dos nomes.

PROJETO COMPLETO · CÓDIGO DO CAPÍTULO

Execute e investigue o Janela Runtime 0.4.

O pacote conecta call stack, heap, objetos, raízes, coletor mark-and-sweep, timers determinísticos, tarefas, microtasks, Promises e um event loop observável. Os testes comprovam tanto a ordem Promise → timer quanto a recuperação de objetos inalcançáveis.

Baixar código final deste artigo (.zip)Rust · runtime · event loop · Promises · GC · testes

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.