Programação Assíncrona
Quem pode esperar quieto enquanto a lama se instala?
Quem pode permanecer imóvel até o momento da ação?

A parte central de um computador, a parte que executa os passos individuais que compõem nossos programas, é chamada de processador. Os programas que vimos até agora manterão o processador ocupado até terminarem seu trabalho. A velocidade com que algo como um loop que manipula números pode ser executado depende quase inteiramente da velocidade do processador e da memória do computador.
Mas muitos programas interagem com coisas fora do processador. Por exemplo, eles podem se comunicar por uma rede de computadores ou solicitar dados do disco rígido—que é muito mais lento do que obtê-los da memória.
Quando algo assim está acontecendo, seria uma pena deixar o processador ocioso—pode haver algum outro trabalho que ele poderia fazer enquanto isso. Em parte, isso é tratado pelo seu sistema operacional, que alterna o processador entre múltiplos programas em execução. Mas isso não ajuda quando queremos que um único programa consiga avançar enquanto espera por uma requisição de rede.
Assincronicidade
Em um modelo de programação síncrono, as coisas acontecem uma de cada vez. Quando você chama uma função que realiza uma ação de longa duração, ela retorna somente quando a ação terminou e pode entregar o resultado. Isso para o seu programa pelo tempo que a ação demora.
Um modelo assíncrono permite que múltiplas coisas aconteçam ao mesmo tempo. Quando você inicia uma ação, seu programa continua rodando. Quando a ação termina, o programa é informado e tem acesso ao resultado (por exemplo, os dados lidos do disco).
Podemos comparar programação síncrona e assíncrona usando um pequeno exemplo: um programa que faz duas requisições pela rede e depois combina os resultados.
Em um ambiente síncrono, onde a função de requisição retorna somente após fazer seu trabalho, a forma mais fácil de realizar essa tarefa é fazer as requisições uma após a outra. Isso tem a desvantagem de que a segunda requisição só será iniciada quando a primeira terminar. O tempo total será pelo menos a soma dos dois tempos de resposta.
A solução para esse problema, em um sistema síncrono, é iniciar threads adicionais de controle. Uma thread é outro programa em execução cuja execução pode ser intercalada com outros programas pelo sistema operacional—já que a maioria dos computadores modernos contém múltiplos processadores, múltiplas threads podem até rodar ao mesmo tempo, em processadores diferentes. Uma segunda thread poderia iniciar a segunda requisição, e então ambas as threads esperam pelos seus resultados voltarem, após o que se sincronizam para combinar seus resultados.
No diagrama a seguir, as linhas grossas representam o tempo que o programa passa rodando normalmente, e as linhas finas representam o tempo gasto esperando pela rede. No modelo síncrono, o tempo levado pela rede faz parte da linha do tempo para uma dada thread de controle. No modelo assíncrono, iniciar uma ação na rede permite que o programa continue rodando enquanto a comunicação na rede acontece paralelamente, notificando o programa quando termina.
Outra forma de descrever a diferença é que esperar ações terminarem é implícito no modelo síncrono, enquanto é explícito—sob nosso controle—no modelo assíncrono.
Assincronicidade tem seus prós e contras. Facilita expressar programas que não cabem no modelo linear de controle, mas também pode tornar mais complicado expressar programas que seguem uma linha reta. Veremos algumas formas de reduzir essa dificuldade mais adiante no capítulo.
Ambas as plataformas promissoras de programação em JavaScript—navegadores e Node.js—tornam operações que podem demorar um pouco assíncronas, em vez de confiar em threads. Como programar com threads é notoriamente difícil (entender o que um programa faz fica muito mais complicado quando ele está fazendo múltiplas coisas ao mesmo tempo), isso é geralmente considerado algo positivo.
Callbacks
Uma abordagem para programação assíncrona é fazer funções que precisam esperar algo receber um argumento extra, uma callback function. A função assíncrona inicia um processo, configura para que a callback seja chamada quando o processo terminar, e então retorna.
Como exemplo, a função setTimeout, disponível tanto no Node.js quanto em navegadores, espera um número dado de milissegundos e então chama uma função.
setTimeout(() => console.log("Tick"), 500);
Esperar geralmente não é um trabalho importante, mas pode ser muito útil quando você precisa arranjar algo para acontecer em certo momento ou verificar se alguma ação está levando mais tempo que o esperado.
Outro exemplo de operação assíncrona comum é ler um arquivo do armazenamento de um dispositivo. Imagine que você tem uma função readTextFile que lê o conteúdo de um arquivo como string e passa para uma função callback.
readTextFile("shopping_list.txt", content => { console.log(`Lista de Compras:\n${content}`); }); // → Lista de Compras: // → Manteiga de amendoim // → Bananas
A função readTextFile não faz parte do JavaScript padrão. Veremos como ler arquivos no navegador e no Node.js em capítulos posteriores.
Realizar múltiplas ações assíncronas em sequência usando callbacks significa que você precisa continuar passando novas funções para lidar com a continuação do cálculo após as ações. Uma função assíncrona que compara dois arquivos e produz um booleano indicando se o conteúdo deles é igual pode ser assim:
function compareFiles(fileA, fileB, callback) { readTextFile(fileA, contentA => { readTextFile(fileB, contentB => { callback(contentA == contentB); }); }); }
Esse estilo de programação é viável, mas o nível de indentação aumenta a cada ação assíncrona porque você acaba dentro de outra função. Fazer coisas mais complicadas, como envolver ações assíncronas em um loop, pode ficar estranho.
De certa forma, a assincronicidade é contagiosa. Qualquer função que chame uma função que trabalhe assincronamente deve ser ela mesma assíncrona, usando um callback ou mecanismo parecido para entregar seu resultado. Chamar um callback é um pouco mais complicado e sujeito a erros do que simplesmente retornar um valor, então precisar estruturar grandes partes do seu programa assim não é ideal.
Promises
Uma forma um pouco diferente de construir um programa assíncrono é fazer com que funções assíncronas retornem um objeto que representa seu resultado (futuro) em vez de passar funções callback. Dessa maneira, essas funções realmente retornam algo significativo, e a forma do programa se assemelha mais a programas síncronos.
É para isso que serve a classe padrão Promise. Uma promise é um recibo que representa um valor que pode ainda não estar disponível. Ela fornece um método then que permite registrar uma função que deve ser chamada quando a ação que está aguardando terminar. Quando a promise é resolvida, isto é, seu valor fica disponível, essas funções (pode haver várias) são chamadas com o valor do resultado. É possível chamar then em uma promise que já foi resolvida — sua função ainda será chamada.
A forma mais fácil de criar uma promise é chamando Promise.resolve. Essa função garante que o valor que você der esteja embrulhado em uma promise. Se já for uma promise, apenas a retorna. Caso contrário, você obtém uma nova promise que se resolve imediatamente com seu valor como resultado.
let fifteen = Promise.resolve(15); fifteen.then(value => console.log(`Recebido ${value}`)); // → Recebido 15
Para criar uma promise que não se resolve imediatamente, você pode usar Promise como um construtor. Ele tem uma interface um pouco estranha: o construtor espera uma função como argumento, que ele chama imediatamente, passando a essa função outra função que pode usar para resolver a promise.
Por exemplo, assim você poderia criar uma interface baseada em promise para a função readTextFile:
function textFile(filename) { return new Promise(resolve => { readTextFile(filename, text => resolve(text)); }); } textFile("plans.txt").then(console.log);
Note como, em contraste com funções do estilo callback, essa função assíncrona retorna um valor significativo — uma promise para te dar o conteúdo do arquivo em algum momento no futuro.
Uma coisa útil sobre o método then é que ele próprio retorna outra promise. Essa se resolve para o valor retornado pela função callback ou, se esse valor retornado for uma promise, para o valor que essa promise se resolve. Assim, você pode “encadear” várias chamadas a then para montar uma sequência de ações assíncronas.
Esta função, que lê um arquivo cheio de nomes de arquivos e retorna o conteúdo de um arquivo aleatório daquela lista, mostra esse tipo de pipeline assíncrono baseado em promises:
function randomFile(listFile) { return textFile(listFile) .then(content => content.trim().split("\n")) .then(ls => ls[Math.floor(Math.random() * ls.length)]) .then(filename => textFile(filename)); }
A função retorna o resultado dessa cadeia de chamadas then. A promessa inicial busca a lista de arquivos como uma string. A primeira chamada then transforma essa string em um array de linhas, produzindo uma nova promessa. A segunda chamada then seleciona uma linha aleatória dessa, produzindo uma terceira promessa que resulta em um único nome de arquivo. A última chamada then lê esse arquivo, portanto o resultado da função como um todo é uma promessa que retorna o conteúdo de um arquivo aleatório.
Nesse código, as funções usadas nas duas primeiras chamadas then retornam um valor regular que será imediatamente passado para a promessa retornada por then quando a função retorna. A última chamada then retorna uma promessa (textFile(filename)), tornando-a uma etapa real assíncrona.
Também seria possível executar todas essas etapas dentro de um único callback do then, já que apenas a última etapa é realmente assíncrona. Mas os tipos de wrappers de then que fazem apenas alguma transformação síncrona de dados são frequentemente úteis, como quando você quer retornar uma promessa que produz uma versão processada de algum resultado assíncrono.
function jsonFile(filename) { return textFile(filename).then(JSON.parse); } jsonFile("package.json").then(console.log);
De modo geral, é útil pensar em uma promessa como um dispositivo que permite ao código ignorar a questão de quando um valor vai chegar. Um valor normal precisa realmente existir antes que possamos referenciá-lo. Um valor prometido é um valor que pode já estar lá ou pode aparecer em algum momento no futuro. Computações definidas em termos de promessas, ao conectá-las com chamadas then, são executadas de forma assíncrona conforme suas entradas ficam disponíveis.
Falha
Computações normais em JavaScript podem falhar lançando (throw) uma exceção. Computações assíncronas frequentemente precisam de algo assim. Uma requisição de rede pode falhar, um arquivo pode não existir, ou algum código que faz parte da computação assíncrona pode lançar uma exceção.
Um dos problemas mais sérios no estilo callback de programação assíncrona é que ele torna extremamente difícil garantir que falhas sejam reportadas corretamente para os callbacks.
Uma convenção comum é usar o primeiro argumento do callback para indicar que a ação falhou, e o segundo para passar o valor produzido pela ação quando foi bem-sucedida.
someAsyncFunction((error, value) => { if (error) handleError(error); else processValue(value); });
Essas funções callback devem sempre verificar se receberam uma exceção e garantir que quaisquer problemas que causarem, inclusive exceções lançadas por funções que chamam, sejam capturadas e repassadas para a função correta.
Promises facilitam isso. Elas podem ser resolvidas (a ação terminou com sucesso) ou rejeitadas (falhou). Manipuladores de resolução (registrados com then) são chamados apenas quando a ação é bem-sucedida, e rejeições são propagadas para a nova promessa retornada pelo then. Quando um manipulador lança uma exceção, isso automaticamente faz com que a promessa produzida pela sua chamada de then seja rejeitada. Se qualquer elemento em uma cadeia de ações assíncronas falhar, o resultado de toda a cadeia é marcado como rejeitado, e nenhum manipulador de sucesso é chamado após o ponto da falha.
Assim como resolver uma promessa fornece um valor, rejeitá-la também fornece um valor, geralmente chamado de razão da rejeição. Quando uma exceção em um manipulador causa a rejeição, o valor da exceção é usado como razão. De forma semelhante, quando um manipulador retorna uma promessa que é rejeitada, essa rejeição flui para a próxima promessa. Há uma função Promise.reject que cria uma nova promessa imediatamente rejeitada.
Para tratar explicitamente tais rejeições, as promises têm um método catch que registra um manipulador para ser chamado quando a promessa for rejeitada, parecido com como manipuladores then lidam com resolução normal. Também é muito parecido com then, porque retorna uma nova promessa que resolve para o valor da promessa original quando esta resolve normalmente, e para o resultado do manipulador catch caso contrário. Se o manipulador catch lançar um erro, a nova promessa também será rejeitada.
Como atalho, then também aceita um manipulador de rejeição como segundo argumento, para que você possa instalar ambos os tipos de manipuladores em uma única chamada de método: ..
Uma função passada para o construtor Promise recebe um segundo argumento, junto com a função resolve, que pode usar para rejeitar a nova promessa.
Quando nossa função readTextFile encontra um problema, ela passa o erro para seu callback como segundo argumento. Nosso wrapper textFile deve realmente checar esse argumento para que uma falha cause a promessa que ele retorna a ser rejeitada.
function textFile(filename) { return new Promise((resolve, reject) => { readTextFile(filename, (text, error) => { if (error) reject(error); else resolve(text); }); }); }
As cadeias de valores de promessa criadas por chamadas a then e catch formam assim um pipeline pelo qual valores assíncronos ou falhas se movem. Como essas cadeias são criadas registrando manipuladores, cada elo tem um manipulador de sucesso ou um manipulador de rejeição (ou ambos) associados a ele. Manipuladores que não correspondem ao tipo de resultado (sucesso ou falha) são ignorados. Manipuladores que correspondem são chamados, e seu resultado determina que tipo de valor vem a seguir — sucesso quando eles retornam um valor não-promessa, rejeição quando lançam uma exceção, e o resultado da promessa quando retornam uma promessa.
new Promise((_, reject) => reject(new Error("Falha"))) .then(value => console.log("Manipulador 1:", value)) .catch(reason => { console.log("Falha capturada " + reason); return "nada"; }) .then(value => console.log("Manipulador 2:", value)); // → Falha capturada Error: Falha // → Manipulador 2: nada
A primeira função manipuladora do then não é chamada porque, naquele ponto da cadeia, a promise está rejeitada. O manipulador do catch trata essa rejeição e retorna um valor, que é passado para a segunda função manipuladora do then.
Assim como uma exceção não capturada é tratada pelo ambiente, ambientes JavaScript podem detectar quando uma rejeição de promise não é tratada e reportam isso como um erro.
Carla
É um dia ensolarado em Berlim. A pista do antigo aeroporto desativado está cheia de ciclistas e patinadores. Na grama perto de uma lixeira, um bando de corvos tagarela espalha-se tentando convencer um grupo de turistas a entregar seus sanduíches.
Um dos corvos se destaca — uma grande fêmea desgrenhada com algumas penas brancas na asa direita. Ela provoca as pessoas com uma habilidade e confiança que sugerem que faz isso há muito tempo. Quando um senhor idoso se distrai com as travessuras de outro corvo, ela casualmente mergulha, rouba seu pão meio comido da mão e voa embora.
Ao contrário do resto do grupo, que parece feliz em passar o dia brincando ali, o grande corvo parece determinado. Carregando seu saque, ela voa diretamente para o telhado do hangar, desaparecendo dentro de uma saída de ar.
Dentro do prédio, você pode ouvir um som estranho de batidas — suave, mas persistente. Vem de um espaço estreito sob o teto de uma escada inacabada. O corvo está ali sentado, cercado pelos lanches roubados, meia dúzia de smartphones (vários ligados) e uma bagunça de cabos. Ela toca rapidamente a tela de um dos telefones com o bico. Palavras estão aparecendo na tela. Se você não visse, pensaria que estava digitando.
Esse corvo é conhecido pelos seus pares como “cāāw-krö”. Mas, como esses sons são inadequados para as cordas vocais humanas, nós a chamaremos de Carla.
Carla é uma corvo um tanto peculiar. Na juventude, ela se fascinou pela linguagem humana, bisbilhotando pessoas até entender bem o que elas diziam. Mais tarde, seu interesse mudou para tecnologia humana, e ela começou a roubar telefones para estudá-los. Seu projeto atual é aprender a programar. O texto que ela está digitando em seu laboratório oculto é, na verdade, um código assíncrono em JavaScript.
Arrombando a rede
Carla ama a internet. Irritantemente, o telefone em que ela está trabalhando está quase sem dados pré-pagos. O prédio tem uma rede wireless, mas exige uma senha para acesso.
Felizmente, os roteadores wireless do prédio têm 20 anos e são mal protegidos. Fazendo uma pesquisa, Carla descobre que o mecanismo de autenticação da rede tem uma falha que ela pode usar. Ao conectar na rede, um dispositivo deve enviar o código correto de seis dígitos. O ponto de acesso responde com uma mensagem de sucesso ou falha, dependendo se o código fornecido é correto. Porém, ao enviar um código parcial (digamos, só três dígitos), a resposta é diferente dependendo se aqueles dígitos estão no início certo do código ou não. Enviar números errados retorna imediatamente uma mensagem de falha. Enviar os números certos faz o ponto de acesso esperar por mais dígitos.
Isso torna possível acelerar muito o processo de tentar adivinhar o número. Carla pode encontrar o primeiro dígito testando cada número em ordem, até achar um que não retorne falha imediata. Tendo o primeiro dígito, ela encontra o segundo da mesma forma, e assim por diante, até saber a senha inteira.
Suponha que Carla tenha uma função joinWifi. Dado o nome da rede e a senha (como string), a função tenta entrar na rede, retornando uma promise que será resolvida se for bem-sucedida e rejeitada se a autenticação falhar. A primeira coisa que ela precisa é uma forma de envolver uma promise para que ela rejeite automaticamente após exceder um tempo limite, para permitir que o programa continue rapidamente se o ponto de acesso não responder.
function withTimeout(promise, time) { return new Promise((resolve, reject) => { promise.then(resolve, reject); setTimeout(() => reject("Tempo esgotado"), time); }); }
Isso usa o fato de que uma promise pode ser resolvida ou rejeitada apenas uma vez. Se a promise passada como argumento resolve ou rejeita primeiro, esse resultado será o da promise retornada por withTimeout. Se, por outro lado, o setTimeout for executado primeiro, rejeitando a promise, quaisquer chamadas posteriores para resolver ou rejeitar são ignoradas.
Para encontrar a senha inteira, o programa precisa procurar repetidamente o próximo dígito tentando cada um. Se a autenticação for bem-sucedida, sabemos que encontramos o que procuramos. Se falhar imediatamente, sabemos que o dígito estava errado e devemos tentar o próximo. Se o pedido expirar, encontramos outro dígito correto e devemos continuar adicionando mais dígitos.
Como não se pode esperar por uma promise dentro de um for loop, Carla usa uma função recursiva para conduzir esse processo. Em cada chamada, essa função recebe o código conhecido até o momento, assim como o próximo dígito para tentar. Dependendo do que acontecer, ela pode retornar um código completo ou se chamar de novo, para começar a decifrar a próxima posição da senha ou para tentar novamente com outro dígito.
function crackPasscode(networkID) { function nextDigit(code, digit) { let newCode = code + digit; return withTimeout(joinWifi(networkID, newCode), 50) .then(() => newCode) .catch(failure => { if (failure == "Timed out") { return nextDigit(newCode, 0); } else if (digit < 9) { return nextDigit(code, digit + 1); } else { throw failure; } }); } return nextDigit("", 0); }
O ponto de acesso tende a responder a requisições de autenticação incorretas em cerca de 20 milissegundos, então, para garantir, essa função espera 50 milissegundos antes de desistir de uma requisição.
crackPasscode("HANGAR 2").then(console.log); // → 555555
Carla inclina a cabeça e suspira. Isso teria sido mais satisfatório se o código fosse um pouco mais difícil de adivinhar.
Funções async
Mesmo com promises, esse tipo de código assíncrono é chato de escrever. Promises frequentemente precisam ser encadeadas de formas verbosas e arbitrárias. Para criar um loop assíncrono, Carla foi forçada a introduzir uma função recursiva.
O que a função de descobrir a senha realmente faz é completamente linear—ela sempre espera a ação anterior terminar antes de iniciar a próxima. Em um modelo de programação síncrona, isso seria mais direto de expressar.
A boa notícia é que o JavaScript permite escrever código pseudo-síncrono para descrever computação assíncrona. Uma função async retorna implicitamente uma promise e pode, em seu corpo, esperar (await) outras promises de uma forma que parece síncrona.
Podemos reescrever crackPasscode assim:
async function crackPasscode(networkID) { for (let code = "";;) { for (let digit = 0;; digit++) { let newCode = code + digit; try { await withTimeout(joinWifi(networkID, newCode), 50); return newCode; } catch (failure) { if (failure == "Timed out") { code = newCode; break; } else if (digit == 9) { throw failure; } } } } }
Essa versão mostra com mais clareza a estrutura de duplo loop da função (o loop interno tenta os dígitos de 0 a 9 e o externo adiciona dígitos à senha).
Uma função async é marcada pela palavra async antes da palavra-chave function. Métodos também podem ser marcados como async escrevendo async antes do nome deles. Quando tal função ou método é chamado, ele retorna uma promise. Assim que a função retorna algo, essa promise é resolvida. Se o corpo lança uma exceção, a promise é rejeitada.
Dentro de uma função async, a palavra await pode ser colocada na frente de uma expressão para esperar que uma promise seja resolvida e só então continuar a execução da função. Se a promise for rejeitada, uma exceção é levantada no ponto do await.
Essa função não é mais executada do início ao fim de uma vez como uma função JavaScript comum. Em vez disso, ela pode ser congelada em qualquer ponto que tenha um await e pode ser retomada posteriormente.
Para a maioria dos códigos assíncronos, essa notação é mais conveniente do que usar diretamente promises. Você ainda precisa entender promises, já que em muitos casos você ainda vai interagir com elas diretamente. Mas ao conectá-las, funções async geralmente são mais agradáveis de escrever do que cadeias de chamadas then.
Geradores
Essa capacidade das funções de serem pausadas e depois retomadas não é exclusiva das funções async. JavaScript também tem um recurso chamado funções geradoras. Elas são semelhantes, mas sem as promises.
Quando você define uma função com function* (colocando um asterisco depois da palavra function), ela se torna um gerador. Quando você chama um gerador, ele retorna um iterador, que já vimos em Capítulo 6.
function* powers(n) { for (let current = n;; current *= n) { yield current; } } for (let power of powers(3)) { if (power > 50) break; console.log(power); } // → 3 // → 9 // → 27
Inicialmente, quando você chama powers, a função está congelada em seu início. Cada vez que você chama next no iterador, a função executa até encontrar uma expressão yield, que a pausa e faz com que o valor yieldado se torne o próximo valor produzido pelo iterador. Quando a função retorna (a do exemplo nunca o faz), o iterador está finalizado.
Escrever iteradores é frequentemente muito mais fácil quando você usa funções geradoras. O iterador para a classe Group (do exercício em Capítulo 6) pode ser escrito com este gerador:
Group.prototype[Symbol.iterator] = function*() { for (let i = 0; i < this.members.length; i++) { yield this.members[i]; } };
Não há mais necessidade de criar um objeto para guardar o estado da iteração — geradores automaticamente salvam seu estado local toda vez que fazem um yield.
Tais expressões yield podem ocorrer apenas diretamente na função geradora em si, e não em uma função interna que você defina dentro dela. O estado que um gerador salva ao executar um yield é apenas seu ambiente local e a posição onde foi yieldado.
Uma função async é um tipo especial de gerador. Ela produz uma promise quando chamada, que é resolvida quando ela retorna (termina) e rejeitada quando lança uma exceção. Sempre que a função faz yield (await) de uma promise, o resultado dessa promise (valor ou exceção lançada) é o resultado da expressão await.
Um Projeto Artístico Corvid
Certa manhã, Carla acorda com um barulho desconhecido vindo do asfalto fora do seu hangar. Pulando para a beirada do telhado, ela vê que os humanos estão montando algo. Há muitos cabos elétricos, um palco e algum tipo de grande parede preta sendo construída.
Sendo uma corva curiosa, Carla dá uma olhada mais de perto na parede. Ela parece consistir em vários dispositivos grandes com frente de vidro ligados por cabos. Na parte de trás, os dispositivos dizem “LedTec SIG-5030”.
Uma rápida pesquisa na internet encontra um manual do usuário desses dispositivos. Eles parecem ser sinais de trânsito, com uma matriz programável de LEDs âmbar. A intenção dos humanos provavelmente é mostrar algum tipo de informação neles durante o evento deles. Curiosamente, as telas podem ser programadas por uma rede sem fio. Será que eles estão conectados à rede local do prédio?
Cada dispositivo numa rede recebe um endereço IP, que outros dispositivos podem usar para enviar mensagens. Falamos mais sobre isso em Capítulo 13. Carla percebe que seus próprios telefones recebem endereços como 10.0.0.20 ou 10.0.0.33. Pode valer a pena tentar enviar mensagens para todos esses endereços e ver se algum deles responde à interface descrita no manual dos sinais.
Capítulo 18 mostra como fazer requisições reais em redes reais. Neste capítulo, usaremos uma função simplificada chamada request para comunicação de rede. Essa função leva dois argumentos — um endereço de rede e uma mensagem, que pode ser qualquer coisa que possa ser enviada como JSON — e retorna uma promise que resolve para uma resposta da máquina no endereço dado, ou rejeita se houver algum problema.
Segundo o manual, você pode mudar o que é exibido num sinal SIG-5030 enviando uma mensagem com conteúdo como {"command": "display", "data": [0, 0, 3, …]}, onde data contém um número por ponto de LED, indicando seu brilho — 0 significa desligado, 3 significa brilho máximo. Cada sinal tem 50 luzes de largura e 30 de altura, então um comando de atualização deve enviar 1.500 números.
Este código envia uma mensagem de atualização de exibição para todos os endereços na rede local, para ver qual responde. Cada número em um endereço IP pode variar de 0 a 255. Nos dados enviados, ele ativa um número de luzes correspondente ao último número do endereço de rede.
for (let addr = 1; addr < 256; addr++) { let data = []; for (let n = 0; n < 1500; n++) { data.push(n < addr ? 3 : 0); } let ip = `10.0.0.${addr}`; request(ip, {command: "display", data}) .then(() => console.log(`Request to ${ip} accepted`)) .catch(() => {}); }
Como a maioria desses endereços não existirá ou não aceitará tais mensagens, a chamada catch garante que erros de rede não façam o programa travar. As requisições são todas enviadas imediatamente, sem esperar que outras requisições terminem, para não perder tempo quando algumas das máquinas não respondem.
Após disparar sua varredura na rede, Carla volta para fora para ver o resultado. Para sua satisfação, todas as telas agora mostram uma faixa de luz no canto superior esquerdo. Elas estão na rede local, e elas sim aceitam comandos. Ela anota rapidamente os números mostrados em cada tela. São nove telas, organizadas três na vertical e três na horizontal. Elas têm os seguintes endereços de rede:
const screenAddresses = [ "10.0.0.44", "10.0.0.45", "10.0.0.41", "10.0.0.31", "10.0.0.40", "10.0.0.42", "10.0.0.48", "10.0.0.47", "10.0.0.46" ];
Agora isso abre possibilidades para todo tipo de travessura. Ela poderia mostrar “crows rule, humans drool” na parede em letras gigantes. Mas isso parece um pouco grosseiro. Em vez disso, ela planeja mostrar um vídeo de um corvo voando cobrindo todas as telas à noite.
Carla encontra um clipe de vídeo adequado, no qual um segundo e meio de filmagem pode ser repetido para criar um vídeo em loop mostrando o bater das asas de um corvo. Para caber nas nove telas (cada uma das quais pode mostrar 50×30 pixels), Carla corta e redimensiona os vídeos para obter uma série de imagens 150×90, 10 por segundo. Essas imagens são então cortadas em nove retângulos, e processadas para que as áreas escuras do vídeo (onde está o corvo) mostrem uma luz intensa, e as áreas claras (sem corvo) fiquem escuras, o que deve criar o efeito de um corvo âmbar voando contra um fundo preto.
Ela configurou a variável clipImages para conter um array de frames, onde cada frame é representado por um array de nove conjuntos de pixels — um para cada tela — no formato que os displays esperam.
Para exibir um único frame do vídeo, Carla precisa enviar uma requisição para todas as telas ao mesmo tempo. Mas ela também precisa esperar o resultado dessas requisições, tanto para não começar a enviar o próximo frame antes do atual ter sido enviado corretamente quanto para perceber quando as requisições estão falhando.
Promise tem um método estático all que pode ser usado para converter um array de promises em uma única promise que resolve para um array de resultados. Isso fornece uma maneira conveniente de fazer algumas ações assíncronas acontecerem em paralelo, esperar todas terminarem, e então fazer algo com seus resultados (ou pelo menos esperar para garantir que elas não falhem).
function displayFrame(frame) { return Promise.all(frame.map((data, i) => { return request(screenAddresses[i], { command: "display", data }); })); }
Isso mapeia sobre as imagens em frame (que é um array de arrays de dados para exibição) para criar um array de promises de requisição. Em seguida, retorna uma promise que combina todas elas.
Para poder parar um vídeo em reprodução, o processo é envolvido em uma classe. Essa classe tem um método assíncrono play que retorna uma promise que é resolvida somente quando a reprodução for parada via o método stop.
function wait(time) { return new Promise(accept => setTimeout(accept, time)); } class VideoPlayer { constructor(frames, frameTime) { this.frames = frames; this.frameTime = frameTime; this.stopped = true; } async play() { this.stopped = false; for (let i = 0; !this.stopped; i++) { let nextFrame = wait(this.frameTime); await displayFrame(this.frames[i % this.frames.length]); await nextFrame; } } stop() { this.stopped = true; } }
A função wait envolve setTimeout em uma promise que é resolvida após o número dado de milissegundos. Isso é útil para controlar a velocidade da reprodução.
let video = new VideoPlayer(clipImages, 100); video.play().catch(e => { console.log("Playback failed: " + e); }); setTimeout(() => video.stop(), 15000);
Durante toda a semana em que a parede de telas fica em pé, todas as noites, quando está escuro, um enorme pássaro laranja luminoso aparece misteriosamente nela.
O event loop
Um programa assíncrono começa executando seu script principal, que frequentemente configura callbacks para serem chamados depois. Esse script principal, assim como os callbacks, roda até o fim em uma única vez, sem interrupções. Mas entre eles, o programa pode ficar ocioso, esperando que algo aconteça.
Então os callbacks não são chamados diretamente pelo código que os agendou. Se eu chamar setTimeout dentro de uma função, essa função já terá retornado quando o callback for chamado. E quando o callback retorna, o controle não volta para a função que o agendou.
O comportamento assíncrono ocorre em sua própria pilha de chamadas vazia. Essa é uma das razões pelas quais, sem promises, gerenciar exceções em código assíncrono é tão difícil. Já que cada callback começa com uma pilha quase vazia, seus manipuladores catch não estarão na pilha quando eles lançarem uma exceção.
try { setTimeout(() => { throw new Error("Woosh"); }, 20); } catch (e) { // Isso não será executado console.log("Caught", e); }
Não importa quão próximos eventos — como timeouts ou requisições recebidas — ocorram, um ambiente JavaScript executa apenas um programa por vez. Você pode pensar nisso como ele executando um grande loop em torno do seu programa, chamado event loop. Quando não há nada a fazer, esse loop é pausado. Mas conforme eventos chegam, eles são adicionados a uma fila, e seu código é executado um após o outro. Como nada roda ao mesmo tempo, código que demora pode atrasar o tratamento de outros eventos.
Este exemplo agenda um timeout mas depois demora até depois do ponto de tempo previsto para o timeout, fazendo com que ele atrase.
let start = Date.now(); setTimeout(() => { console.log("Timeout ran at", Date.now() - start); }, 20); while (Date.now() < start + 50) {} console.log("Wasted time until", Date.now() - start); // → Wasted time until 50 // → Timeout ran at 55
Promises sempre são resolvidas ou rejeitadas como um novo evento. Mesmo se uma promise já estiver resolvida, esperar por ela fará com que seu callback rode após o script atual terminar, ao invés de imediatamente.
Promise.resolve("Done").then(console.log); console.log("Me first!"); // → Me first! // → Done
Nos capítulos seguintes veremos vários outros tipos de eventos que rodam no event loop.
Bugs assíncronos
Quando seu programa roda sincronicamente, de uma vez só, não ocorrem mudanças de estado exceto as que o próprio programa faz. Para programas assíncronos isso é diferente — eles podem ter lacunas em sua execução durante as quais outro código pode rodar.
Vamos ver um exemplo. Esta é uma função que tenta reportar o tamanho de cada arquivo em um array de arquivos, garantindo que eles sejam todos lidos ao mesmo tempo em vez de em sequência.
async function fileSizes(files) { let list = ""; await Promise.all(files.map(async fileName => { list += fileName + ": " + (await textFile(fileName)).length + "\n"; })); return list; }
A parte async fileName => mostra como funções arrow também podem ser async ao colocar a palavra async na frente delas.
O código não parece suspeito imediatamente... ele mapeia a função async arrow sobre o array de nomes, criando um array de promises, e então usa Promise.all para esperar todas elas antes de retornar a lista que constroem.
Mas este programa está totalmente quebrado. Sempre vai retornar apenas uma única linha de saída, listando o arquivo que demorou mais para ser lido.
fileSizes(["plans.txt", "shopping_list.txt"]) .then(console.log);
Você consegue descobrir por quê?
O problema está no operador +=, que pega o valor atual de list no momento em que a instrução começa a ser executada e então, quando o await termina, define a ligação list para ser aquele valor mais a string adicionada.
Mas entre o momento que a instrução começa a executar e o momento em que ela termina, há uma lacuna assíncrona. A expressão map roda antes de qualquer coisa ter sido adicionada à lista, então cada operador += começa a partir de uma string vazia e termina, quando sua recuperação de armazenamento termina, definindo list como o resultado de somar sua linha à string vazia.
Isso poderia ter sido facilmente evitado retornando as linhas das promises mapeadas e chamando join no resultado de Promise.all, em vez de construir a lista alterando uma ligação. Como de costume, calcular novos valores é menos propenso a erros do que modificar valores existentes.
async function fileSizes(files) { let lines = files.map(async fileName => { return fileName + ": " + (await textFile(fileName)).length; }); return (await Promise.all(lines)).join("\n"); }
Erros como este são fáceis de cometer, especialmente ao usar await, e você deve estar ciente de onde ocorrem as lacunas no seu código. Uma vantagem da assíncronicidade explícita do JavaScript (seja por callbacks, promises ou await) é que identificar essas lacunas é relativamente fácil.
Resumo
A programação assíncrona possibilita expressar a espera por ações de longa duração sem travar o programa inteiro. Os ambientes JavaScript tipicamente implementam esse estilo de programação usando callbacks, funções que são chamadas quando as ações são completadas. Um event loop agenda esses callbacks para serem chamados quando apropriado, um após o outro, para que sua execução não se sobreponha.
Programar assincronamente é facilitado por promises, objetos que representam ações que podem se completar no futuro, e funções async, que permitem escrever um programa assíncrono como se fosse síncrono.
Exercícios
Quiet Times
Há uma câmera de segurança perto do laboratório da Carla que é ativada por um sensor de movimento. Ela está conectada à rede e começa a transmitir um fluxo de vídeo quando está ativa. Como ela prefere não ser descoberta, Carla configurou um sistema que percebe esse tipo de tráfego de rede sem fio e acende uma luz em seu esconderijo sempre que há atividade do lado de fora, assim ela sabe quando deve ficar quieta.
Ela também tem registrado os horários em que a câmera foi acionada por um tempo e quer usar essa informação para visualizar quais horários, em uma semana média, tendem a ser tranquilos e quais tendem a ser movimentados. O registro está armazenado em arquivos contendo um número timestamp (como retornado por Date.now()) por linha.
1695709940692 1695701068331 1695701189163
O arquivo "camera_logs. contém uma lista de arquivos de log. Escreva uma função assíncrona activityTable(day) que, para um dado dia da semana, retorne um array de 24 números, um para cada hora do dia, que contenha o número de observações de tráfego na rede de câmeras vistas naquela hora do dia. Os dias são identificados por número usando o sistema usado por Date.getDay, onde domingo é 0 e sábado é 6.
A função activityGraph, fornecida pelo sandbox, resume tal tabela em uma string.
Para ler os arquivos, use a função textFile definida anteriormente—dado um nome de arquivo, ela retorna uma promise que resolve para o conteúdo do arquivo. Lembre-se que new Date(timestamp) cria um objeto Date para aquele tempo, que tem métodos getDay e getHours que retornam o dia da semana e a hora do dia.
Ambos os tipos de arquivos—a lista de arquivos de log e os próprios arquivos de log—têm cada dado em sua própria linha, separada por caracteres de nova linha ("\n").
async function activityTable(day) { let logFileList = await textFile("camera_logs.txt"); // Seu código aqui } activityTable(1) .then(table => console.log(activityGraph(table)));
Mostrar dicas...
Você precisará converter o conteúdo desses arquivos para um array. A maneira mais fácil de fazer isso é usar o método split na string produzida por textFile. Note que para os arquivos de log, isso ainda lhe dará um array de strings, que você terá que converter para números antes de passá-los para new Date.
Resumir todos os pontos de tempo em uma tabela de horas pode ser feito criando uma tabela (array) que contém um número para cada hora do dia. Você pode então iterar sobre todos os timestamps (pelos arquivos de log e pelos números em cada arquivo de log) e para cada um, se ocorreu no dia correto, pegar a hora em que ocorreu e adicionar um àquele número correspondente na tabela.
Tenha certeza de usar await no resultado de funções assíncronas antes de fazer qualquer coisa com ele, ou você acabará com uma Promise onde esperava uma string.
Promises Reais
Reescreva a função do exercício anterior sem async/await, usando métodos puros de Promise.
function activityTable(day) { // Seu código aqui } activityTable(6) .then(table => console.log(activityGraph(table)));
Nesse estilo, usar Promise.all será mais conveniente do que tentar modelar um loop sobre os arquivos de log. Na função async, usar simplesmente await em um loop é mais simples. Se ler um arquivo leva algum tempo, qual dessas duas abordagens levará menos tempo para rodar?
Se um dos arquivos listados na lista de arquivos tiver um erro de digitação, e a leitura falhar, como essa falha aparece no objeto Promise que sua função retorna?
Mostrar dicas...
A abordagem mais direta para escrever essa função é usar uma cadeia de chamadas then. A primeira promise é produzida pela leitura da lista de arquivos de log. O primeiro callback pode dividir essa lista e mapear textFile sobre ela para obter um array de promises para passar a Promise.all. Ele pode retornar o objeto retornado por Promise.all, de modo que o que quer que ele retorne se torne o resultado do valor de retorno desse primeiro then.
Agora temos uma promise que retorna um array de arquivos de log. Podemos chamar then novamente nela, e colocar a lógica de contar timestamps ali. Algo assim:
function activityTable(day) { return textFile("camera_logs.txt").then(files => { return Promise.all(files.split("\n").map(textFile)); }).then(logs => { // analisar... }); }
Ou você pode, para um escalonamento de trabalho ainda melhor, colocar a análise de cada arquivo dentro do Promise.all, para que esse trabalho possa começar com o primeiro arquivo que voltar do disco, mesmo antes dos outros arquivos chegarem.
function activityTable(day) { let table = []; // inicializar... return textFile("camera_logs.txt").then(files => { return Promise.all(files.split("\n").map(name => { return textFile(name).then(log => { // analisar... }); })); }).then(() => table); }
Isso mostra que a forma como você estrutura suas promises pode ter um efeito real em como o trabalho é escalonado. Um loop simples com await dentro dele tornará o processo completamente linear—ele espera que cada arquivo carregue antes de continuar. Promise.all torna possível que múltiplas tarefas sejam trabalhadas conceitualmente ao mesmo tempo, permitindo que elas façam progresso enquanto os arquivos ainda estão sendo carregados. Isso pode ser mais rápido, mas também torna a ordem na qual as coisas acontecerão menos previsível. Neste caso, só vamos incrementar números em uma tabela, o que não é difícil fazer de forma segura. Para outros tipos de problemas, pode ser muito mais difícil.
Quando um arquivo na lista não existe, a promise retornada por textFile será rejeitada. Como Promise.all rejeita se qualquer uma das promises que recebe falhar, o valor retornado do callback dado para o primeiro then também será uma promise rejeitada. Isso faz com que a promise retornada pelo then falhe, então o callback dado para o segundo then nem é chamado, e uma promise rejeitada é retornada da função.
Construindo Promise.all
Como vimos, dado um array de promises, Promise.all retorna uma promise que espera todas as promises no array terminarem. Depois, ela é resolvida com um array dos valores resultantes. Se uma promise no array falhar, a promise retornada por all também falha, repassando a razão da falha da promise que falhou.
Implemente algo assim você mesmo como uma função normal chamada Promise_all.
Lembre-se que depois que uma promise foi resolvida ou rejeitada, ela não pode ser resolvida ou rejeitada novamente, e chamadas futuras para as funções que resolvem ela são ignoradas. Isso pode simplificar a forma como você trata a falha da sua promise.
function Promise_all(promises) { return new Promise((resolve, reject) => { // Seu código aqui. }); } // Código de teste. Promise_all([]).then(array => { console.log("Isto deve ser []:", array); }); function soon(val) { return new Promise(resolve => { setTimeout(() => resolve(val), Math.random() * 500); }); } Promise_all([soon(1), soon(2), soon(3)]).then(array => { console.log("Isto deve ser [1, 2, 3]:", array); }); Promise_all([soon(1), Promise.reject("X"), soon(3)]) .then(array => { console.log("Não deveríamos chegar aqui"); }) .catch(error => { if (error != "X") { console.log("Falha inesperada:", error); } });
Mostrar dicas...
A função passada para o construtor Promise terá que chamar then em cada uma das promises do array dado. Quando uma delas obtém sucesso, duas coisas precisam acontecer. O valor resultante precisa ser armazenado na posição correta de um array de resultados, e devemos verificar se essa era a última _promise_ pendente e finalizar nossa própria promise se for o caso.
Esse último pode ser feito com um contador que é inicializado com o comprimento do array de entrada e do qual subtraímos 1 toda vez que uma promise obtém sucesso. Quando chega a 0, terminamos. Certifique-se de levar em conta a situação em que o array de entrada está vazio (e portanto nenhuma promise será resolvida).
Lidar com falhas requer alguma reflexão, mas acaba sendo extremamente simples. Basta passar a função reject da promise envolvente para cada uma das promises no array como um manipulador catch ou como segundo argumento para then para que uma falha em qualquer uma delas acione a rejeição da promise principal.