Como vimos anteriormente, Javascript é uma linguagem single-thread, ou seja, apenas uma instrução é executada por vez. Logo, a seguinte pergunta pode surgir:
Como Javascript pode ser assíncrono se é single-thread?
.A resposta resumida é que as funcionalidades assíncronas não são parte da linguagem por si só, e sim são providas pelo ambiente (host) que a executa, no nosso caso, o browser, através das Web APIs.
Para detalhes sobre Call Stack e Heap, ver Call Stack, Execution Context and Heap.
Cada janela ou nova aba do navegador é um processo independente, tendo sua estrutura isolada das outras.
Web APIs
Web APIs não são parte da linguagem Javascript e/ou da especificação ECMAScript, mas sim construídas em cima do core da linguagem, permitindo que as aplicações tenham "superpoderes" ao prover uma interface simples. São dois tipos:
- Browser APIs: servem para expor dados do navegador ou do ambiente de execução. Por exemplo, temos a Audio API para manipular áudio, volume, efeitos e etc. Outro exemplo é a Geolocation API, para obter dados de localização. Por baixo dos panos o navegador está usando algum código de baixo nível, como C++ para processar o áudio ou se comunicar com o GPS do dispositivo. Outro exemplo muito comum é a API
fetch()
, utilizada para efetuar requisições.
- APIs de terceiros: não fazem parte do navegador, e geralmente é necessário efetuar download do código. Por exemplo, a api do Twitter permite exibir tweets na página.
As Web APIs não são executadas na mesma runtime (no mesmo ambiente de execução) do Javascript, mas sim possuem um contexto separado, e executam em background, para não interferirem na thread principal.
Macrotask Queue
Também conhecida como callback queue, event queue, task queue, ou message queue, é uma estrutura similar à call stack, onde adicionamos itens ao final da fila e só podemos retirar o primeiro item.
A macrotask queue armazena os callbacks de execuções assíncronas, como por exemplo quando o timer do setTimeout expira. Além disso, eventos como clicks, interações de teclado, ou eventos do DOM como
onLoad
também são enviados para a message queue.O event loop só envia os callbacks da macrotask queue para a call stack, caso a call stack esteja vazia.
Microtask Queue
A especificação ECMAScript 2015 (ES6) introduziu o conceito de job queue, ou microtask queue, com o objetivo de ser consumida pelas Promises (também introduzidas na ES6). Dessa forma, o callback de uma Promise é executado o mais rápido possível.
Um job também é conhecido por microtask, e só é executado caso a call stack esteja vazia, porém no final do tick do event loop. Em outras palavras, se a microtask for resolvida antes do término da função atual, será executada imediatamente.
const hello = () => console.log('hello') const bye = () => console.log('bye') const fn = () => { console.log('fn') setTimeout(hello, 0) Promise.resolve('after "bye", before "hello"') .then(value => console.log(value)) bye() } fn()
Resultado:
fn bye after "bye", before "hello" hello
A diferença entre uma task e uma microtask é que a execução das microtasks continua até a job queue estar vazia, mesmo que novas microtasks sejam enfileiradas pela atual. Em outras palavras, essas novas microtasks serão executadas com prioridade, antes da próxima task, e antes do fim da iteração atual do event loop.
Podemos fazer uma analogia da job queue com um brinquedo de um parque de diversões. As pessoas que estão na fila desse brinquedo são como os itens da call stack, todas tem que esperar sua vez chegar para acessar a atração. Por outro lado, você chega com um ticket especial, que te dá acesso a uma fila VIP. Esse ticket é a job queue.
Event Loop
O event loop é um processo que roda de forma contínua, necessário para coordenar os eventos, interação do usuário, scripts, renderização e etc..
De forma resumida o processamento do event loop ocorre em 3 fases, em uma iteração conhecida como
tick
:- Executa uma macrotask
- Obtém o item mais antigo da macrotask queue e envia para a call stack
- Executa uma microtask
- Obtém o item mais antigo da microtask queue e envia para a call stack
- Renderiza uma atualização no navegador (caso necessário) e então retorna para a fase 1
- Esse passo só acontece caso seja o event loop referente ao objeto
window
. Em outras palavras, event loops de service workers não passam por essa fase.
Caso as queues estejam vazias, o event loop do
window
entra em um estado ocioso. Já os event loops de service workers podem ser destruídos ou atualizados de acordo com a especificação do navegador.Importante notar que a microtask queue tem prioridade sobre a macrotask queue, isso significa que a microtask sempre será executada primeiro, mesmo que uma macrotask seja registrada antes de uma microtask.
Na prática é mais simples do que parece, vejamos o exemplo:
function fn() { console.log('hello') setTimeout(() => console.log('timeout'), 1000) Promise.resolve().then(() => console.log('promise')) } fn()
Qual deve ser o resultado esperado do código acima?
hello promise timeout
Vamos seguir a execução linha a linha, como o interpretador faz, e entender o o que acontece:
- A função
fn()
é adicionada na call stack
- A função
fn()
é executada
- A função
console.log("hello")
é adicionada na call stack
- A função
console.log("hello")
é executada
- A função
console.log("hello")
é removida da call stack
- A função
setTimeout
é executada fora da call stack através do navegador por ser uma Web Api
- O callback da função
Promise.resolve().then
é adicionado na microtask queue
- A função
fn()
é removida da call stack
- Passados 1000ms, o timer envia o callback do
setTimeout
para a message queue
Nesse momento nós temos a seguinte situação:
- A call stack está vazia
- A message queue possui o callback da função
setTimeout
- A job queue possui o callback da função
Promise.resolve().then
De acordo com as fases do event loop, nós teremos a seguinte sequência de ticks:
- Primeiro tick:
- O callback da microtask queue é enviado para a call stack, pois tem prioridade.
- O callback da microtask queue é executado, o que adiciona a função
console.log
na call stack. A funçãoconsole.log
é executada e removida da call stack. Em seguida o callback da microtask também é removido. - Uma nova renderização é efetuada no navegador
- Segundo tick:
- O callback da macrotask queue é enviado para a call stack
- O callback da macrotask queue é executado, o que adiciona a função
console.log
na call stack. A funçãoconsole.log
é executada e removida da call stack. Em seguida o callback da macrotask também é removido - Uma nova renderização é efetuada no navegador
Interessante notar que a função
setTimeout
não adiciona o callback na message queue imediatamente, isso acontece apenas quando o timer expira. Logo, o tempo da função setTimeout
indica o tempo mínimo e não exato em que a função será executada, pois a call stack pode estar bloqueada por algum outro evento, ou callbacks na microtask queue.console.log('Hello') setTimeout(() => { console.log('Callback') }, 0) console.log("Bye")
O resultado do exemplo acima será o mesmo do anterior, mesmo que o tempo passado como argumento seja
0
. O callback será adicionado na message queue imediatamente, porém, a call stack ainda não está vazia nessa momento.Playground
Existem alguns sites disponíveis que simulam a interação do event loop com a call stack, macrotask queue e microtask queue. Podem ser interessantes para entender melhor o funcionamento desses mecanismos.
O que eu achei mais interessante é o JS Visualizer 9000 (jsv9000.app), pois dos que encontrei é o único que implementa a microtask queue.
Também há o http://latentflip.com/loupe, que não implementa a microtask queue, porém nos permite testar eventos do DOM, diferente do primeiro.