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.

VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
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.
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ça | Pergunta respondida |
|---|---|
| Máquina virtual | qual instrução executar agora? |
| Call stack | quais funções ainda estão ativas? |
| Heap | onde vivem objetos duráveis? |
| Filas | qual trabalho espera sua vez? |
| Event loop | quando um trabalho pode começar? |
O runtime não é uma única thread misteriosa. É uma arquitetura feita de peças com responsabilidades separadas.
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.
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.
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.
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”.
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.
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.
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.
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.
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.
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.
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.
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.
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 () => ....
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.
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,
});
}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.
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.
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.
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.
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.
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.
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 });
}
}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.
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.
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.
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.
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.
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.
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.
#[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.
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.
| Área | Janela Runtime 0.4 | Navegador profissional |
|---|---|---|
| Tempo | ticks determinísticos | relógios, throttling e suspensão |
| Filas | tarefas e microtasks | múltiplas fontes e prioridades |
| Promise | pending e fulfilled | rejection, encadeamento e assimilação |
| Memória | mark-and-sweep direto | gerações, incremental e concorrente |
| Integração | jobs didáticos | DOM, rede, workers, mídia e armazenamento |
| Termo | Significado |
|---|---|
| Runtime | ambiente que sustenta a execução |
| Call stack | pilha das funções ainda ativas |
| Frame | estado temporário de uma chamada |
| Heap | memória de objetos duráveis |
| Callback | função guardada para depois |
| Tarefa | trabalho de uma volta do event loop |
| Microtask | continuação prioritária após a tarefa atual |
| Promise | representação de resultado futuro |
| Event loop | coordenador entre pilha e filas |
| GC | recuperador de objetos inalcançáveis |
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.
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.