Como o Javascript funciona: O event loop e o surgimento da programação assíncrona + 5 maneiras de codificar melhor com async/await

Bem-vindo ao post 4 da série dedicada a explorar o JavaScript e seus componentes de construção. No processo de identificação e descrição dos elementos centrais, também compartilhamos algumas regras gerais que usamos ao criar o SessionStack , um aplicativo JavaScript que precisa ser robusto e altamente eficiente para se manter competitivo.

Abaixo estão os posts dessa série publicados até o momento

Desta vez, expandiremos nossa primeira postagem revisando as desvantagens da programação em um ambiente de encadeamento único e como superá-las para criar UIs impressionantes em JavaScript. Como a tradição diz, no final do artigo vamos compartilhar 5 dicas sobre como escrever código mais limpo com async/await.

Por que ter uma única thread é uma limitação?

No primeiro post que lançamos, ponderamos sobre a questão do que acontece quando você tem chamadas de função na call stack que levam muito tempo para serem processadas .

Imagine, por exemplo, um algoritmo complexo de transformação de imagem que está sendo executado no navegador.

Enquanto a call stack tem funções para executar, o navegador não pode fazer mais nada — está sendo bloqueado. Isso significa que o navegador não pode renderizar, não pode executar nenhum outro código, está apenas preso. E aí vem o problema: a interface do seu aplicativo não é mais eficiente e agradável.

Seu aplicativo está preso .

Em alguns casos, isso pode não ser um problema tão crítico. Mas ei — aqui está um problema ainda maior. Depois que o navegador iniciar o processamento de muitas tarefas na call stack, ele poderá deixar de responder por muito tempo. Nesse ponto, muitos navegadores realizariam um erro, perguntando se deveriam terminar a página:

É feio e estraga completamente seu UX:

Os blocos de construção de um programa JavaScript

Você pode estar escrevendo seu aplicativo JavaScript em um único arquivo .js, mas seu programa é quase certamente composto de vários blocos, dos quais apenas um será executado agora e o restante será executado posteriormente .A unidade de bloco mais comum é a função.

O problema que muitos desenvolvedores novatos em JavaScript parecem ter é entender que depois não acontece necessariamente e imediatamente depois de agora. Em outras palavras, tarefas que não podem ser concluídas agora são, por definição, concluídas de forma assíncrona, o que significa que você não terá o comportamento de bloqueio mencionado acima, como você poderia ter esperado ou esperado inconscientemente.

Vamos dar uma olhada no seguinte exemplo:

Você provavelmente está ciente de que as solicitações Ajax padrão não são concluídas de forma síncrona, o que significa que, no momento da execução do código, a função ajax (..) ainda não possui nenhum valor para retornar a ser atribuída a uma variável de resposta.

Uma maneira simples de “aguardar” que uma função assíncrona retorne seu resultado é usar uma função chamada retorno de chamada:

Apenas uma nota: você pode realmente fazer solicitações Ajax síncronas .Nunca, nunca faça isso. Se você fizer uma solicitação Ajax síncrona, a interface do usuário do seu aplicativo JavaScript será bloqueada — o usuário não poderá clicar, inserir dados, navegar ou rolar. Isso impediria qualquer interação do usuário. É uma prática terrível.

É assim que parece, mas por favor, nunca faça isso — não estrague a web:

Usamos uma solicitação do Ajax apenas como exemplo. Você pode ter qualquer parte do código executada de forma assíncrona.

Isso pode ser feito com a função setTimeout(callback, milliseconds) . O que a função setTimeout faz é configurar um evento (timeout) para acontecer mais tarde. Vamos dar uma olhada:

A saída no console será a seguinte:

first
third
second

Dissecando o Event Loop

Vamos começar com uma afirmação estranha — apesar de permitir código JavaScript assíncrono (como o setTimeout que acabamos de discutir), até o ES6, o próprio JavaScript nunca teve nenhuma noção direta de assincronia embutida nele. O mecanismo JavaScript nunca fez nada além de executar uma única parte do seu programa a qualquer momento.

Para obter mais detalhes sobre como os mecanismos JavaScript funcionam (especificamente, o V8 do Google), consulte um de nossos artigos anteriores sobre o tópico.

Então, quem diz a engine JS para executar partes do seu programa? Na realidade, a engine JS não é executada isoladamente — ela é executada dentro de um ambiente de hospedagem , que para a maioria dos desenvolvedores é o típico navegador da Web ou o Node.js. Atualmente, o JavaScript é incorporado em todos os tipos de dispositivos, desde robôs até lâmpadas. Cada dispositivo representa um tipo diferente de ambiente de hospedagem para a engine JS.

O denominador comum em todos os ambientes é um mecanismo interno chamado event loop, que lida com a execução de vários fragmentos de seu programa ao longo do tempo, sempre chamando o engine de JS.

Isso significa que a engine JS é apenas um ambiente de execução sob demanda para qualquer código JS arbitrário. É o ambiente circundante que agenda os eventos (as execuções do código JS).

Assim, por exemplo, quando seu programa JavaScript faz uma solicitação Ajax para buscar alguns dados do servidor, você configura o código de “resposta” em uma função (o “retorno de chamada”) e engine JS informa ao ambiente de hospedagem: 
“Ei, vou suspender a execução por enquanto, mas sempre que você terminar com essa solicitação de rede e tiver alguns dados, chame esta função novamente .”

O navegador é então configurado para escutar a resposta da rede, e quando tiver algo para retornar a você, ele agendará a função de retorno de chamada a ser executada inserindo-a no loop de eventos .

Vamos ver o diagrama abaixo:

Você pode ler mais sobre o Memory Heap e o Call Stack em nosso artigo anterior .

E quais são essas APIs da Web? Em essência, eles são tópicos que você não pode acessar, você pode apenas fazer chamadas para eles. Eles são as partes do navegador nas quais a simultaneidade entra em ação. Se você é um desenvolvedor do Node.js, essas são as APIs do C++.

Então, o que é o event loop depois de tudo ?

O Event Loop tem um trabalho simples — para monitorar a call stack e a fila de retorno. Se a call stack estiver vazia, ela pegará o primeiro evento da fila e a empurrará para a call stack, que efetivamente a executa.

Tal iteração é chamada de tick no Event Loop. Cada evento é apenas um callback de função.

Vamos “executar” esse código e ver o que acontece:

  1. O estado é claro. O console do navegador é claro e a call stack está vazia.

2. console.log('Hi') é adicionado à call stack.

3. console.log('Hi') é executado.

4. console.log('Hi') é removido da pilha de chamadas.

5. setTimeout(function cb1() { ... }) é adicionado à call stack.

6. setTimeout(function cb1() { ... }) é executado. O navegador cria um cronômetro como parte das APIs da Web. Ele vai lidar com a contagem regressiva para você.

7. O setTimeout(function cb1() { ... }) está completo e é removido da call stack.

8. console.log('Bye') é adicionado à call stack.

9. console.log('Bye') é executado.

10. console.log('Bye') é removido da call stack.

11. Após pelo menos 5000 ms, o temporizador é concluído e ele envia o retorno de chamada cb1 para a fila de retorno de chamada.

12. O Event Loop retira o cb1 da Fila de Retorno de Chamada e o envia para a call stack.

13. O cb1 é executado e adiciona o console.log('cb1') à call stack.

14. console.log('cb1') é executado.

15. O console.log('cb1') é removido da pilha de chamadas.

16. O cb1 é removido da call stack.

Uma recapitulação rápida:

É interessante observar que o ES6 especifica como o event loop deve funcionar, o que significa que, tecnicamente, ele está dentro do escopo das responsabilidades da engine JS, que não está mais desempenhando apenas uma função de ambiente de hospedagem. Um dos principais motivos para essa mudança é a introdução do Promises no ES6, porque o último requer acesso a um controle direto e detalhado sobre as operações de agendamento na fila do evento (discutiremos isso em maiores detalhes posteriormente).

Como o setTimeout (…) funciona

É importante observar que setTimeout(…) não coloca automaticamente seu retorno de chamada na fila do event loop. Configura um temporizador. Quando o temporizador expira, o ambiente coloca seu retorno de chamada no event loop, para que algum sinal futuro o capte e execute. Dê uma olhada neste código:

Isso não significa que myCallback será executado em 1.000 ms, mas que, em 1.000 ms, myCallback será adicionado à fila. A fila, no entanto, pode ter outros eventos que foram adicionados anteriormente - seu retorno de chamada terá que esperar.

Existem alguns artigos e tutoriais sobre como começar com código assíncrono em JavaScript que sugerem fazer um setTimeout (callback, 0). Bem, agora você sabe o que o Event Loop faz e como o setTimeout funciona: chamando setTimeout com 0 como um segundo argumento apenas adia o retorno de chamada até que a Pilha de Chamadas seja limpa.

Dê uma olhada no seguinte código:

Embora o tempo de espera esteja definido como 0 ms, o resultado no console do navegador será o seguinte:

hi 
bye
callback

O que são jobs no ES6?

Um novo conceito chamado “Job Queue” foi introduzido no ES6. É uma camada no topo da fila do Event Loop. É mais provável que você se deparar com isso ao lidar com o comportamento assíncrono do Promises (falaremos sobre eles também).

Vamos apenas abordar o conceito agora para que, quando discutirmos o comportamento assíncrono com Promises, você entenda como essas ações estão sendo agendadas e processadas.

Imagine assim: a fila de tarefas é uma fila anexada ao final de cada escala na fila do ciclo de eventos. Certas ações assíncronas que podem ocorrer durante um ciclo do evento não farão com que todo um novo evento seja adicionado à fila do evento, mas adicionará um item (também conhecido como Job) ao final da fila de tarefas do tick atual.

Isso significa que você pode adicionar outra funcionalidade para ser executada mais tarde, e você pode ter certeza de que ela será executada logo após, antes de mais nada.

Um job também pode fazer com que mais trabalhos sejam adicionados ao final da mesma fila. Em teoria, é possível que um “loop” de Job (um Job que continue adicionando outros Jobs, etc.) gire indefinidamente, privando assim o programa dos recursos necessários para passar para o próximo tick de loop de evento. Conceitualmente, isso seria semelhante a apenas expressar um loop de longa duração ou infinito (como while (true) ..) em seu código.

Jobs são como o setTimeout(callback, 0) “hack”, mas implementados de tal forma que introduzem um ordenamento muito mais bem definido e garantido: mais tarde, mas o mais rápido possível.

Callbacks

Como você já sabe, as callbacks são, de longe, a maneira mais comum de expressar e gerenciar assincronia em programas JavaScript. De fato, a callback é o padrão assíncrono mais fundamental na linguagem JavaScript. Inúmeros programas JS, mesmo os mais sofisticados e complexos, foram escritos sobre nenhuma outra base assíncrona que o callback.

Exceto que as chamadas de retorno não vêm com deficiências. Muitos desenvolvedores estão tentando encontrar melhores padrões assíncronos. É impossível, no entanto, usar efetivamente qualquer abstração, se você não entender o que está realmente sob o capô.

No próximo capítulo, exploraremos duas dessas abstrações em profundidade para mostrar por que padrões assíncronos mais sofisticados (que serão discutidos em posts subsequentes) são necessários e até mesmo recomendados.

Nested callnacks

Veja o seguinte código:

Temos uma cadeia de três funções aninhadas juntas, cada uma representando uma etapa em uma série assíncrona.

Esse tipo de código é frequentemente chamado de “inferno de retorno de chamada (callback hell)”. Mas o “callback hell” na verdade não tem quase nada a ver com o aninhamento/recuo. É um problema muito mais profundo que isso.

Primeiro, estamos aguardando o evento “click”, depois esperamos que o cronômetro dispare e, em seguida, aguardamos a resposta do Ajax voltar, e nesse ponto ele poderá se repetir novamente.

À primeira vista, esse código pode parecer mapear sua assincronia naturalmente em etapas sequenciais como:

Então nós temos:

Então depois nós temos:

E finalmente:

Então, uma maneira tão seqüencial de expressar seu código assíncrono parece muito mais natural, não é? Deve haver tal maneira, certo?

Promisses

Dê uma olhada no seguinte código:

Tudo é muito simples: soma os valores de y e imprime no console. E se, no entanto, o valor de x ou y estivesse faltando e ainda estivesse para ser determinado? Digamos que precisamos recuperar os valores de y do servidor antes que eles possam ser usados ​​na expressão. Vamos imaginar que temos uma função loadX e loadY que, respectivamente, carregam os valores de ydo servidor. Então, imagine que temos uma sum funções que soma os valores de x e y assim que ambos estiverem carregados.

Poderia ser assim (bem feio, não é):

Há algo muito importante aqui — nesse fragmento, tratamos x e y como valores futuros , e expressamos uma sum(…) operação sum(…) que (do lado de fora) não importava se x ou y ou ambos estavam ou não disponíveis Imediatamente.

Naturalmente, essa abordagem baseada em callback deixa muito a desejar. É apenas um primeiro passo minúsculo para entender os benefícios do raciocínio sobre valores futuros, sem se preocupar com o aspecto temporal de quando eles estarão disponíveis.

Valor da Promise

Vamos apenas vislumbrar brevemente como podemos expressar o exemplo x + y com Promises:

Existem duas camadas de promessas neste trecho.

fetchX() e fetchY() são chamados diretamente, e os valores que eles retornam (promessas!) são passados ​​para sum(...) . Os valores subjacentes que essas promises representam podem estar prontos agora ou mais tarde, mas cada promise normaliza seu comportamento para ser o mesmo independentemente. Nós raciocinamos sobre os valores y maneira independente do tempo. Eles são valores futuros , período.

A segunda camada é a promise que sum(...) cria 
(via Promise.all([ ... ]) ) e retorna, que esperamos chamando then(...) .Quando a operação sum(...) concluída, o valor da soma futura estará pronto e poderemos imprimi-lo. Nós escondemos a lógica para esperar nos valores yfuturos dentro da sum(...) .

Nota : Dentro da sum(…) , a Promise.all([ … ]) cria uma promise (que está aguardando o promiseX e promiseY resolver). A chamada encadeada para. .then(...) cria outra promise, que o retorno 
values[0] + values[1] linha imediatamente resolve (com o resultado da adição). Assim, o then(...) call nós encadeiamos o final da sum(...) call - no final do trecho - está operando na segunda promise retornada, ao invés da primeira criada pela Promise.all([ ... ]) . Além disso, embora não estejamos encerrando o segundo then(...) , ele também criou outra promessa, se tivéssemos escolhido observá-la / utilizá-la. Este encadeamento Promise será explicado com muito mais detalhes posteriormente neste capítulo.

Com Promises, a chamada then(...) pode realmente ter duas funções, a primeira para o cumprimento (como mostrado anteriormente) e a segunda para a rejeição:

Se algo deu errado ao obter x ou y , ou algo de alguma forma falhou durante a adição, a promise de que sum(...) retorna seria rejeitada e o segundo manipulador de erro de retorno de chamada passado para then(...) receberia a rejeição valor da promessa.

Como as promises encapsulam o estado dependente do tempo — aguardando o cumprimento ou a rejeição do valor subjacente — do exterior, a própria promise é independente do tempo e, assim, as promises podem ser compostas (combinadas) de maneiras previsíveis, independentemente do tempo ou resultado por baixo.

Além disso, uma vez que uma Promise seja resolvida, ela permanece assim para sempre — torna-se um valor imutável nesse ponto — e pode então ser observada quantas vezes forem necessárias.

É realmente útil que você possa realmente encadear promises:

delay(2000)cria uma promise que será cumprida em 2000ms e, em seguida, retornamos a partir do primeiro then(...)retorno de chamada de atendimento, que faz com que a segunda then(...)promise espere essa promise de 2000 ms .

Nota : Como uma promise é externamente imutável uma vez resolvida, agora é seguro passar esse valor para qualquer parte, sabendo que ela não pode ser modificada acidentalmente ou maliciosamente. Isto é especialmente verdade em relação a várias partes observando a resolução de uma promessa. Não é possível para uma das partes afetar a capacidade da outra parte de observar a resolução da promessa. A imutabilidade pode soar como um tema acadêmico, mas na verdade é um dos aspectos mais fundamentais e importantes do design da promise, e não deve ser ignorado casualmente.

Usar Promises ou não ?

Um detalhe importante sobre as promises é saber com certeza se algum valor é uma promise real ou não. Em outras palavras, é um valor que se comportará como uma promise?

Sabemos que as Promises são construídas pela new Promise(…), e você pode pensar que o p instanceof Promise seria uma verificação suficiente. Bem, não é bem assim.

Principalmente porque você pode receber um valor Promise de outra janela do navegador (por exemplo, iframe), que teria seu próprio Promise, diferente daquele na janela ou quadro atual, e essa verificação não identificaria a instância do Promise.

Além disso, uma biblioteca ou estrutura pode optar por vender suas próprias promises e não usar a implementação nativa do promise do ES6 para fazê-lo.Na verdade, você pode muito bem estar usando promises com bibliotecas em navegadores mais antigos que não têm promise alguma.

Engolindo exceções

Se a qualquer momento da criação de uma Promise, ou na observação de sua resolução, ocorrer um erro de exceção de JavaScript, como um TypeErrorou ReferenceError , essa exceção será capturada e forçará a Promise em questão a ser rejeitada.

Por exemplo:

Mas o que acontece se uma Promise for cumprida, mas houve um erro de exceção JS durante a observação (em um callback registrado then(…) )? Mesmo que não seja perdido, você pode achar o jeito que eles são tratados um pouco surpreendente. Até você cavar um pouco mais:

Parece que a exceção de foo.bar() realmente foi engolida. Não foi, no entanto. Havia algo mais profundo que deu errado, no entanto, que não conseguimos ouvir. O p.then(…) chama o seu próprio retorna outra promise, e é essa Promise que será rejeitada com a exceção TypeError .

Lidando com exceções não identificadas

Existem outras abordagens que muitos diriam que são melhores.

Uma sugestão comum é que as Promises devem ter um done(…) adicionado a elas, o que essencialmente marca a cadeia Promise como “pronta”. done(…)Não cria e devolve uma Promise, então os callbacks passados ​​para o done(..) obviamente não estão conectados para reportar problemas a uma Promise encadeada que não existe.

Ele é tratado como você normalmente espera em condições de erro não detectadas: qualquer exceção dentro de um manipulador de rejeição done(..) seria lançada como um erro global não detectado (no console do desenvolvedor, basicamente):

O que está acontecendo no ES8? Async/await

JavaScript ES8 introduziu async/await. Isso facilita trabalhar com Promises. Vamos passar brevemente pelas possibilidades de async/await e como aproveitá-las para escrever código assíncrono.

Então, vamos ver como funciona o async/await.

Você define uma função assíncrona usando a declaração da função async .Tais funções retornam um objeto AsyncFunction . O objeto AsyncFunctionrepresenta a função assíncrona que executa o código contido nessa função.

Quando uma função assíncrona é chamada, ela retorna uma Promise . Quando a função assíncrona retorna um valor, isso não é uma Promise , uma Promiseserá criado automaticamente e será resolvido com o valor retornado da função. Quando a função async lança uma exceção, o Promise será rejeitado com o valor lançado.

Uma função async pode conter uma expressão await , que pausa a execução da função e aguarda a resolução do Promise passada e, em seguida, retoma a execução da função assíncrona e retorna o valor resolvido.

Você pode pensar em uma Promise em JavaScript como o equivalente do Future de Java ou Task do C# .

O objetivo do async/await é simplificar o comportamento de usar promises.

Vamos dar uma olhada no seguinte exemplo:

Da mesma forma, as funções que estão lançando exceções são equivalentes às funções que retornam promessas que foram rejeitadas:

A palavra-chave await só pode ser usada em funções async e permite que você espere de forma síncrona em uma Promise. Se usarmos promessas fora de uma função async , ainda teremos que usar retornos de chamada:

Você também pode definir funções assíncronas usando uma “expressão de função assíncrona”. Uma expressão de função assíncrona é muito semelhante e tem quase a mesma sintaxe que uma instrução de função assíncrona. A principal diferença entre uma expressão de função assíncrona e uma instrução de função assíncrona é o nome da função, que pode ser omitido nas expressões de função assíncronas para criar funções anônimas. Uma expressão de função assíncrona pode ser usada como um IIFE (Expressão de Função Imediatamente Invocada) que é executada assim que é definida.

Se parece com isso:

Mais importante, o async/await é suportado em todos os principais navegadores:

Se esta compatibilidade não é o que você procura, também existem vários transpilers JS como o Babel e o TypeScript.

No final do dia, o importante é não escolher cegamente a abordagem “mais recente” para escrever código assíncrono. É essencial entender os aspectos internos do JavaScript assíncrono, aprender por que ele é tão crítico e compreender em profundidade os aspectos internos do método escolhido.Toda abordagem tem prós e contras, como tudo o mais na programação.

5 dicas para escrever código assíncrono altamente não-frágil e de fácil manutenção

  1. Limpe o código: usar async/wait permite que você escreva muito menos código. Toda vez que você usa async/wait você pula alguns passos desnecessários: escreva. Então, crie uma função anônima para manipular a resposta, nomeie a resposta daquele callback por ex.

Versus:

2. Manipulação de erros: O Async/await permite lidar com erros de sincronização e assíncronos com a mesma construção de código — as conhecidas instruções try/catch. Vamos ver como fica com Promises:

Versus:

3. Condicionais: Escrever código condicional com async/await é muito mais simples:

Versus:

4. Stack Frames: Ao contrário do async/await , a pilha de erros retornada de uma cadeia de promises não dá ideia de onde ocorreu o erro.Veja o seguinte:

Versus:

5. Debugging: Se você usou promises, sabe que depurá-las é um pesadelo.Por exemplo, se você definir um ponto de interrupção dentro de um bloco e, em seguida, usar atalhos de depuração como “parada”, o depurador não se moverá para o seguinte. Em seguida, porque só passa por um código síncrono.
Com o async/await você pode aguardar chamadas exatamente como se fossem funções síncronas normais.

Escrever código JavaScript assíncrono é importante não apenas para os aplicativos em si, mas também para as bibliotecas .

Por exemplo, a biblioteca SessionStack registra tudo em seu aplicativo/site da Web: todas as alterações do DOM, interações do usuário, exceções do JavaScript, rastreamentos de pilha, solicitações de rede com falha e mensagens de depuração.

E tudo isso precisa acontecer em seu ambiente de produção sem afetar nenhum UX. Precisamos otimizar fortemente nosso código e torná-lo assíncrono o máximo possível, para que possamos aumentar o número de eventos que estão sendo processados ​​pelo Event Loop.

E não apenas a biblioteca! Quando você reproduz uma sessão de usuário no SessionStack, nós temos que renderizar tudo o que aconteceu no navegador do seu usuário no momento em que o problema ocorreu, e nós temos que reconstruir todo o estado, permitindo que você pule para frente e para trás no cronograma da sessão. Para tornar isso possível, estamos empregando fortemente as oportunidades assíncronas que o JavaScript oferece.

Existe um plano gratuito que lhe permite começar de graça .

Referências:

Este é um artigo traduzido com a autorização do autor. O artigo original pode ser lido em https://blog.sessionstack.com/how-javascript-works-event-loop-and-the-rise-of-async-programming-5-ways-to-better-coding-with-2f077c4438b5

Autor do post original — Alexander Zlatkov— Co-founder & CEO @SessionStack