🌿 Uma breve comparação entre await e then para lidar com operações assíncronas
March 2, 2022•1,901 words
async + await vs then e legibilidade de código.
Em construção!
Existe uma conversa frequente nos fóruns de Node.js acerca de como lidar com funções assíncronas. Ainda que async
e then
nos fornecem a mesma funcionalidade para lidar com código assíncrono em JavaScript, ambas são distintas em seu funcionamento e efeitos colaterais.
Essa não é uma introdução as promises ou programação assíncrona, apenas devaneios sobre formas de lidar com o resultado dessas operações. Aqui estão excelentes materiais para aprender sobre recursos de programação assíncrona no JavaScript:
https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Guide/Using_promises
https://developer.mozilla.org/pt-BR/docs/Learn/JavaScript/Asynchronous
Trabalhando com Node.js (JavaScript) você provavelmente já se deparou com esses dois tipos de código:
Requisição a API construída pelo time do
then
fetch("https://emojihub.herokuapp.com/api/random")
.then(response => response.json())
.then(data => console.info(data))
Requisição a API construída pelo time do
async/await
const response = await fetch("https://emojihub.herokuapp.com/api/random")
const data = await response.json()
console.info(data)
Ambas resultam na mesma coisa: um emoji retornado randomicamente pela API e exibido no console. Mas cada API tem seus objetivos e seus respectivos casos de uso, e a semântica de cada um é diferente.
Defendo que não existe bala de prata e cada recurso tem sua razão de ser, então esse rascunho não serve para dizer qual é melhor, mas sim comparar ambos e ajudar na escolha, além de compartilhar minha preferência do ponto de vista da legibilidade.
História
Para que o próximo tópico faça sentido, vou começar traduzindo cada uma das implementações:
Utilizando
then
, estamos essencialmente:
'BUSCAR O EMOJI NA API'
ENTÃO 'TRANSFORMAR A RESPOSTA EM JSON'
ENTÃO 'IMPRIMIR OS DADOS'
Utilizando
await
, estamos essencialmente:
RESPOSTA = (AGUARDE) 'BUSCAR O EMOJI NA API'
DADOS = (AGUARDE) 'TRANSFORMAR A RESPOSTA EM JSON'
'IMPRIMIR OS DADOS'
A implementação sozinha pode parecer pouca diferença, mas no segundo caso o código lê mais natural, muito semelhante ao síncrono. Enquanto o primeiro depende de bastante compreensão do https://subscription.packtpub.com/book/web-development/9781783287314/1/ch01lvl1sec10/the-callback-pattern e corre o risco do http://callbackhell.com/, caso seja mal implementado.
Para além da naturalidade, cada formato tem objetivo diferente e internalidades diferentes. Apesar de parecer apenas um açúcar sintático, await
implica em outras diferenças também.
E esse é um motivo histórico, em linha do tempo:
Node.js foi criado profundamente atrelado ao Padrão Callback, que permitia a utilização de código assíncrono
Foram criadas as promises e as novas APIs de
then/catch/finally
que permitiram minimizar o callback hellFinalmente foram criadas as funções assíncronas, que trouxeram o
async/await
e permitiram o código mais legível e natural (https://developers.google.com/web/fundamentals/primers/async-functions)
Apesar de não serem sempre recursos concorrentes e terem seus próprios casos de uso, a nova sintaxe é uma evolução levando em conta diversos dos problemas anteriores. Por isso, para alguns casos ela claramente será mais compreensível, porque surgiu em um contexto diferente — de resolver problemas anteriores.
O mesmo vale para as novas APIs de manipulação de coleções — map
, filter
,reduce
— nenhum deles substitui o bom e velho for
ou ainda o while
, apenas resolvem problemas específicos.
Compreensibilidade
Escreverei pouco sobre este tópico porque o código fala mais do que mil palavras. E porque existem materiais melhores escritos sobre isso (como esse da Google https://developers.google.com/web/fundamentals/primers/async-functions ou esse da MDN https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Promises).
Naturalmente (ao menos na nossa região) fazemos a leitura de cima para baixo e da esquerda para a direita. E é isso que o código expressa:
console.log("#1");
await something();
console.log("#2");
console.log("#3");
Já utilizando then
, não podemos garantir que o código a seguir será executado nessa ordem, mas sim que vai acontecer na ordem que for mais performática:
console.log("#1");
something().then(() => {
console.log("#3? (or #2)");
})
console.log("#2? (or #3)");
Para garantir a ordem, seria necessário encadear a execução a resolução da Promise, causando aninhamento e possível quebra de semântica:
console.log("#1");
something().then(() => {
console.log("#2");
}).finally(() => {
console.log("#3");
})
Código limpo é código limpo em qualquer lugar e com qualquer padrão, então sem aninhar seus thens. Em qualquer nível, sempre evite o callback hell: https://ibb.co/DkMWQfq.
Performance
Hoje em dia, o async/await
é mais rápido que as outras opções, e muito mais rápido que implementações manuais de promise. Isso é graças ao https://v8.dev/blog/fast-async, uma implementação da V8 que se aproveitou dos recursos para evitar o overhead que era causado pela promise extra no await
.
De todo modo, sempre priorize realizar em “paralelo” processamentos que não são bloqueantes e dependentes entre si e aguardar por todos de uma vez só. O promise.all é um exemplo de recurso que possibilita isso, e pode ser utilizado em qualquer uma das maneiras. (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)
Tratamento de Erros
Além de possibilitar o tratamento de erros mais familiar (try/catch
), a V8 trabalhou em um recurso poderosíssimo para o tratamento de erros com async/await
: Zero-cost async stack traces (https://docs.google.com/document/d/13Sy_kBIJGP0XT34V1CV3nkWya4TwYx9L3Yv45LdGB6Q/edit#heading=h.e6lcalo0cl47). Isso significa que o rastreamento de erro contempla as informações do código síncrono e assíncrono, resolvendo uma dor forte do padrão de callbacks ou do then/catch
, que era detectar a origem de erros não capturados quando surgiam de código assíncrono.
Por outro lado, como JavaScript não possui catchs condicionais, o tratamento específico de exceção pode ser mais verboso, enquanto temos o par then/catch
que pode especificar por cada operação assíncrona.
Para se aprofundar nas convenções de tratamento de erro, veja:
Afinal, qual forma é a melhor?
Nenhuma. Cada uma pode ter um caso de uso mais ou menos adequado, de acordo com o contexto.
Assim como não devemos encadear vários condicionais (if
) também não deveríamos encadear vários resolvedores de promessas (then
). E assim como não deveríamos implementar um complexo padrão de projeto para resolver uma validação de sim ou não, não deveríamos criar uma função para utilizar await
quando o padrão de callback resolveria.
Por causa do ganho em compreensibilidade e das possibilidades de melhoria em performance que chegaram com a sintaxe async/await
, a discussão ainda vem evoluindo para que await
possa ser utilizado também em nível de módulos.
As propostas originais mencionadas servem como contexto para o problema do callback hell e as vantagens observadas com a nova sintaxe:
Isso não demonstra que ele seja melhor, mas que foi amplamente adotado e o uso vem crescendo. As pessoas olhariam estranho para um código cheio de then
encadeado sendo que temos uma API muito mais limpa para lidar com isso.
E as pessoas também olhariam estranho para uma lista de awaits dentro de uma mesma função porque isso pode estar criando um bloqueio por forçar o comportamento síncrono, principalmente quando dentro de loops. (off-topic: Se você tem trabalhando com várias operações encadeadas, recomendo fortemente o estudo de streams. Especialmente as pipelines https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-callback)
Lembrando do Fowler:
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
No fim, o melhor para a aplicação é não bloquear o event loop. A forma como isso será codificado depende muito mais do código e das pessoas que vão ler ele — pessoalmente vejo que a sintaxe async/await
costuma atender a maioria dos casos comuns, além de ser compatível com a especificação de async iterators (https://tc39.es/proposal-async-iteration/).
Isso não exime de utilizar callbacks, eles são core do Node (https://nodejs.org/en/knowledge/getting-started/control-flow/what-are-callbacks/), ainda que eles próprios tratem async/await
como a alternativa moderna para lidar com assincronismo (https://nodejs.dev/learn/modern-asynchronous-javascript-with-async-and-await).
Além disso, vale mencionar que a V8, o motor que faz a magica acontecer e JS executar no Back-End com Node.js, aconselha (1) a utilização de async/await
em vez de promises escritas manualmente pelos ganhos em performance e (2) a utilização das implementações nativas de promise em vez de bibliotecas pelos outros benefícios mencionados anteriormente, na seção de performance.
“Não bloqueie o Event Loop”
Afinal, por que tudo isso? (em homenagem ao meu amigo que xingou Node.js dizendo que ler um arquivo em Java na aula de POO foi mais simples que entender o Event Loop)
Quando alguém me questiona porque tantas APIs nativas do Node.js, ou mesmo as bibliotecas mais utilizadas, são assíncronas e se não seria mais simples elas simplesmente serem síncronas como em algumas outras linguagens, minha resposta gira em torno dê: “Não bloqueie o Event Loop”.
Node.js® é descrito em sua própria documentação como “um ambiente de execução JavaScript assíncrono e orientado a eventos” que utiliza um “modelo de I/O não bloqueante”. Na prática isso quer dizer muitas coisas, e a arquitetura do Node.js é uma que vale estudar, mas podemos resumir em “não bloqueie o event loop” porque essa é a estratégia para que ele seja não bloqueante e orientado a eventos por padrão.
O event loop nada mais é do que a thread principal, e devemos mantê-la livre de processos pesados para que ela se mantenha performática e segura. Em vez disso, enviamos tarefas pesadas para outras threads e lidamos com o resultado de forma assíncrona, através de eventos.
Por isso, para garantirmos nunca bloquear a thread principal, que tantas APIs nativas são assíncronas por padrão, e assim deve ser com as SDKs e bibliotecas que utilizamos. Se não é assíncrono por padrão, torne-a. Não bloqueie o Event Loop :)
Alguns recursos da própria documentação para se aprofundar nesse tema e na arquitetura do Node.js:
Conclusão
Não existe bala de prata, como tudo na tecnologia e na vida. Ambos são recursos poderosos, assim como o próprio padrão callback e o ideal é entender os dois para definir o mais adequado ao contexto — afinal foram criados em contextos diferentes para resolver problemas diferentes.
Quando estamos falando de tratamento de erros, legibilidade e desempenho, o async/await
performa melhor, mas apenas isso não o torna a solução ideal.
Para se aprofundar nesse tema você pode ler mais nos materiais que eu utilizei como base para escrever:
https://docs.google.com/document/d/13Sy_kBIJGP0XT34V1CV3nkWya4TwYx9L3Yv45LdGB6Q/edit
https://nodejs.org/en/docs/guides/dont-block-the-event-loop/
Gratíssima a você por ler até aqui. Espero que esse conteúdo tenha agregado de alguma forma. 🤗
Se quiser conversar sobre o tema você pode me enviar um e-mail para myreli@duck.com, deixar uma mensagem no Guestbook ou ainda um agradecimento.
***
🌿 Budding são ideias que já revisei ou editei um pouco. Estão começando a tomar forma, mas ainda precisam de refinamento. O que é isso?