Quando comecei a escrever essas notas, a ideia era entender o funcionamento dos mecanismos que a linguagem Javascript proporciona. Acontece que é um tanto difícil encontrar artigos que detalham a fundo, a maioria deles explicam de maneira "funcional" (como utilizar) e mais abstrata, sem levar em consideração os detalhes da especificação.
Talvez isso até faça sentido, já que nem todos os leitores devem se interessar por entender linhas e linhas de especificações que usam palavras abstratas e complexas.
Dito isso, tracei um objetivo de tentar escrever sobre
async/await
apenas lendo a especificação ECMAScript, vamos ver o resultado!Programação Assíncrona
Antes de mergulharmos no famoso
async/await
, vamos entender um pouco do contexto da programação assíncrona, e sua história com Javascript.Programação assíncrona é uma técnica que permite que tarefas possivelmente longas sejam executadas enquanto a aplicação continua responsiva à outros eventos, ao invés de ter que aguardar até que a tarefa termine. Quando nos referimos à programação assíncrona geralmente estamos falando sobre obter dados do backend através de requisições, mas não se resume à apenas isso!
Muitas APIs fornecidas pelo navegador podem demorar algum tempo para serem executadas e por isso são assíncronas, o exemplo mais comummente utilizado é o
fetch()
.E bom, uma das perguntas que podem surgir é:
Como Javascript pode ser assíncrono se é uma linguagem
single-thread
?
. A resposta está na espetacular combinação descrita em Macrotask Queue, Microtask Queue and Event Loop Problemas com programação síncrona
O trecho de código abaixo foi extraído da MDN, e representa uma função para gerar números primos.
const generatePrimes = (quota) => { function isPrime(n) { for (let c = 2; c <= Math.sqrt(n); ++c) { if (n % c === 0) { return false; } } return true; } const primes = []; const maximum = 1000000; while (primes.length < quota) { const candidate = Math.floor(Math.random() * (maximum + 1)); if (isPrime(candidate)) { primes.push(candidate); } } return primes; }
Como só temos uma thread, o que acontece enquanto essa função executa? A thread principal fica impossibilitada de executar outras tarefas, logo, a tela do navegador fica congelada.
É por esse motivo que em alguns momentos recebemos a notificação do navegador de que o site não está responsivo. Algum processo travou a thread principal de forma que não fosse possível mais executar outros eventos.
Para contornar esse problema e executar tarefas que demandam mais tempo de processamento, podemos usar Web Workers, que possuem uma estrutura separada de event loop e queues, não interferindo na thread principal do navegador.
Event Handlers
Event Handlers são uma forma de programação assíncrona: nós atrelamos uma função a um evento, e essa função não é executada imediatamente, mas sempre que o evento for disparado.
Nos primórdios da programação assíncrona no Javascript, event handlers eram utilizados. A API
XMLHttpRequest
é um exemplo disso:function doRequest() { const xhr = new XMLHttpRequest(); xhr.addEventListener('loadend', () => { console.log(`Finished with status ${xhr.status}`) }); xhr.open('GET', '/'); xhr.send(); }
No exemplo acima, adicionamos um handler para o evento
loadend
, que indica quando o request terminou. Após isso, podemos disparar o request, e a thread principal não estará bloqueada, pois essa é uma Web API que será executada em background - ver Macrotask Queue, Microtask Queue and Event Loop Callbacks
E tudo começou com callbacks! Um event handler é um tipo de callback. Um callback é uma função, passada para outra função, com a expectativa de ser executada em um momento apropriado.
O problema é que callbacks em determinadas situações podem tornar a manutenção e leitura do código difícil, caso precisemos executar alguma operação que dependa de outra.
function getUsers(callback) { // Requesting some backend data... const result = {} callback({}) } function getPosts(userId, callback) { // Requesting some backend data... const result = {} callback({}) } function getComments(postId, callback) { // Requesting some backend data... const result = {} callback({}) } function getData() { getUsers(users => { getPosts(users[0].id, posts => { getComments(posts[0].id, comments => { console.log(comments); }); }); }); } getData();
Confesso que enquanto preparava esse exemplo, me confundi algumas vezes com o aninhamento dos callbacks, imagina em cenários mais complexos? Justamente por esse aninhamento, a função se torna muito mais difícil de ler e dar manutenção. É isso que chamamos de callback hell, também conhecido como pyramid of doom (pois a indentação parece uma pirâmide).
Outro problema de aninhar callbacks dessa forma é gerenciar os erros: precisamos fazer isso em cada nível da "pirâmide" ao invés de tratar na raíz.
É por isso que as APIs modernas da linguagem são baseadas em Promises e não callbacks.
Promises
TODO: Escrever uma sessão separada para Promises
Async & Await
Agora que já temos um pouco mais de contexto, podemos mergulhar na especificação ECMAScript para entender o que acontece "por baixo dos panos" quando usamos
async
e await
.O primeiro ponto interessante é que as funções criadas com async/await são baseadas nas Promises, usando a operação abstrata NewPromiseCapability. Essa operação recebe um construtor que é usado para executar o construtor Promise e por fim retornar um objeto com a Promise, e suas funções resolve e reject extraídas.
Uma outra forma de comprovarmos essa afirmação é ao executar uma função async sem a palavra chave await. Vamos perceber que o retorno é uma Promise.
async function noAwait() {} noAwait() // Promise {<fulfilled>: undefined}
Quando declaramos uma função com a palavra async, estamos usando a função AsyncFunction, porém diferente de outros construtores, esse não é um objeto global, e tentar usá-lo irá disparar um ReferenceError:
Mesmo assim ainda conseguimos ter acesso usando o código abaixo:
(async function(){}).constructor // ƒ AsyncFunction() { [native code] }
A especificação não diz o motivo desse construtor não estar disponível globalmente, mas acredito que seja por motivos de impor seu uso declarando funções com async ao invés de construtores.
Quando executamos uma função declarada com async, a operação AsyncFunctionStart é iniciada. O que acontece aqui basicamente é:
- Cria-se uma cópia do contexto de execução atual e envia para a call stack, essa cópia agora torna-se o contexto de execução atual.
- Esse novo contexto é computado e seu resultado é retornado. Esse resultado pode ter duas origens:
- Se usarmos algum await dentro do corpo da função, então é o resultado da operação AsyncFunctionAwait
- Caso não usemos await, as funções resolve e reject do construtor Promise são chamadas, a depender do tipo de retorno.
- A cópia criada anteriormente é removida da call stack e o contexto de execução anterior volta a ser o atual.
Ao usar a palavra chave await dentro do corpo de uma função assíncrona, estamos executando uma AwaitExpression. Nesse caso, a operação abstrata AsyncFunctionAwait é utilizada. O que acontece basicamente é:
- Utiliza a operação NewPromiseCapability para criar um novo objeto e extrair os métodos resolve e reject.
- O método onFulfilled é criado conforme a especificação AsyncFunction Awaited Fulfilled
- O método onRejected é criado conforme a especificação AsyncFunction Awaited Rejected
- O método Promise.then é executado usando os métodos onFulfilled e onRejected criados anteriormente
- O contexto de execução atual é suspenso, então o contexto de execução assíncrono (cópia criada previamente) é enviado para a call stack e torna-se o contexto atual
- O contexto assíncrono é removido da call stack e o anterior volta a ser o atual
Os passos 5 e 6 acontecem tanto se a Promise estiver em um estado fulfilled, quanto rejected.
Essa é a grande mágica do async/await, diferentemente das Promises, o contexto de execução é suspenso até que tenhamos o valor final da operação.
Isso é possível desde a especificação ES8, e não acontece só com async/await, conforme trecho retirado abaixo:
Evaluation of code by the running execution context may be suspended at various points defined within this specification. Once the running execution context has been suspended a different execution context may become the running execution context and commence evaluating its code. At some later time a suspended execution context may again become the running execution context and continue evaluating its code at the point where it had previously been suspended
Espero ter explicado de uma forma suficientemente boa e que tenha servido para agregar um pouco mais de conhecimento sobre nosso querido Javascript. Agora sempre que declararmos uma função e usarmos async/await vamos ter um pouco mais de noção sobre o que está acontecendo por trás do navegador.