Técnicas de Processamento Assíncrono
Considere a seguinte função JavaScript que, por hipótese, demora 10 segundos para ser executada:
console.log('início');
try {
let r1 = f1(10);
let r2 = f2(r1);
console.log(r2);
} catch (erro) {
console.log(erro.message);
}
console.log('término');
É razoável afirmar que o motivo principal desta demora está relacionado ao tempo de execução das funções f1
e f2
. Ainda por hipótese, considere que f1
demora 3 segundos para retornar seu valor e que f2
demora 6 segundos para retornar o seu valor. Portanto, 90% do tempo de execução do código restá relacionado à execução das funções f1
e f2
.
Além disso, claramente a função f2
só pode ser executada depois que a função f1
retornar um valor. Elas não podem ser executadas ao mesmo tempo (em paralelo). A execução das funções demora 9 segundos.
Técnicas de processamento assíncrono permitem que um código logicamente equivalente ao do exemplo seja executado em pouco mais de 1 segundo. Isso parece impossível porque temos a tendência em pensar que a CPU está 100% do tempo, dos 10 segundos, ocupada executando alguma instrução. No código exemplo, a CPU estaria 90% do tempo executando instruções contidas nas funções f1
e f2
. Há situações, porém, onde a maior parte desse tempo a CPU está parada, sem executar nenhuma instrução.
A função f1
pode, por exemplo, conter um algoritmo cuja lógica implica em um acesso ao disco para obter uma informação armazenada em um arquivo. A função f2
, por sua vez, pode conter um algoritmo cuja lógica implica em solicitar dados que estão armazenados em um servidor na Internet. Em ambos os casos, a execução do algoritmo fica suspensa até que as informações estejam disponíveis na memória do computador (sejam transferidas do disco ou "da internet"). Assim, literalmente a CPU fica quase a totalidade dos 10 segundos sem executar nenhuma instrução.
No contexto de aplicações para web o cenário descrito é particularmente preocupante. Poderá haver momentos em que centenas ou milhares de usuários precisam se atendidos ao mesmo tempo, cada um deles solicitando a execução do código acima.
De forma simplificada, ocorre o seguinte: se só há uma CPU, cada usuário entra em uma fila para aguardando a vez de executar o código. Como o código demora 10 segundos para ser executado, o primeiro usuário terá que esperar 10 segundos para obter a sua resposta, o que é aceitável. Já um segundo usuário, segundo da fila, terá que esperar 20 segundos (10 segundos na fila mais 10 segundos para a execução do código). O terceiro usuário terá que esperar 30 segundos, e assim por diante.
Resultado: tempo de resposta muito alto (muito mais que 10 segundos) para quase todos os usuários. Isso dará a impressão que o computador é "muito fraquinho"/lento. Analisando-se o comportamento da CPU se chegará a conclusão de que ela passa 90% do tempo ociosa, sem executar nenhuma instrução. Portanto, não adiantará nada trocar por um computador mais moderno e veloz.
As técnicas de processamento assíncrono permitem diminuir drasticamente o tempo ocioso das CPUs.
Callback
A versão usando a técnica de callback do código hipotético seria:
console.log('início');
f1(10, function (r1, erro) {
if (erro)
console.log(erro.message);
else
f2(r1, function (r2, erro) {
if (erro)
console.log(erro.message);
else
console.log(r2);
});
});
console.log('término');
A função f1
agora não retorna mais um valor e possui um segundo parâmetro que é uma função que será executada quando o valor tiver sido calculado. Esta função é chamdada de função callback . A função possui dois parâmetros pois f1
pode ou produzir um dado ou produzir um erro. Por exemplo, se o algoritmo de f1
implicar no acesso a um arquivo armazenado em disco então dois resultados são possíveis: tudo deu certo e o valor foi lido; ou ocorreu um erro (arquivo inexistente ou sem permissão de leitura, por exemplo). A mesma estratégia é aplicada para a função f2
.
A execução da linha f1(10, function ...);
dura, literalmente, poucos milisegundos. Cabe ao interpretador JavaScript, seja ele o Node.js ou o browser, providenciar a execução de f1
e garantir que a função de callback seja executada quando f1
produzir uma resposta ou um erro.
Com o mecanismo de callback pode-se concluir que:
- o código é formado por um algoritmo de 3 linhas:
- console.log('início');
- f1(10, function ...);
- console.log('término');
- o algoritmo de 3 linhas é executado em poucos milisegundos.
- a palavra
término
aparecerá na tela muito antes do resultado esperado (valor der2
) ou da mensagem de erro.
Se o algoritmo de 3 linhas é executado em poucos milisegundos então todos os usuários ficam na fila poucos milisegundos. Logo, todos eles precisarão aguardar por volta de 10 segundos, o que é esperado uma vez que f1
e f2
, juntas, demoram 9 segundos.
A técnica de callback pode gerar um problema conhecido como callback hell (ou inferno de callback). O código muito rapidamente pode se tornar difícil de ser lido devido ao aninhamento de funções.
Promise
A versão ES6 (EcmaScript 2015) de JavaScript introduziu "a classe" Promise como padrão (antes era usada por meio de bibliotecas). Pode-se dizer que a ideia de Promise
é uma alternativa para a técnica de callback no sentido de tornar a programação mais legível.
Um objeto da classe Promise
tem por objetivo armazenar o resultado da execução de uma função. Este resultado pode obtido imediatamente, em algum momento futuro ou nunca .
No primeiro caso, a função consegue calcular imediatamente o seu valor pois já tem todos os dados de que necessita. O objeto, da classe Promise
, então já nasce contendo este valor.
No segundo caso, a função só conseguirá calcular o valor em algum momento no futuro pois o seu algoritmo depende de dados que demorarão algum tempo para serem obtidos. Por exemplo, os dados estão armazenados em arquivo ou "na internet". O objeto, neste caso, representa a ideia "prometo que em algum momento no futuro eu conterei o resultado da função".
No terceiro caso, a função não consegue produzir um valor devido a alguma falha ou erro (por exemplo, sem permissão para ler o arquivo, falta de conexão com a Internet). O objeto, neste caso, precisa regisrar a informação de que houve um erro.
O código escrito agora com esta técnica fica assim:
console.log('início');
let p1 = f1(10);
let p2 = p1.then(f2);
let p3 = p2.then(function (num) {console.log(num)});
p3.catch(function (erro) {console.log(erro.message)});
console.log('término');
A função f1
que antes retornava um valor agora retorna uma promessa de valor, isto é, um objeto da "classe" Promise
. Quando f1
, no código original, retornava um valor isso poderia demorar bastante tempo (3 segundos). Agora, a função retorna "instantaneamente" (em poucos milisegundos) um objeto da classe Promise
. Portanto, a linha let p1 = f1(10);
é executada em poucos milisegundos.
A linha let p2 = p1.then(f2);
representa a seguinte ideia: "computador, fique sabendo que quando p1 contiver o valor calculado por f1 então a função f2 deve ser executada levando como parâmetro o valor". Esta linha também é executada em poucos milisegundos.
A linha let p3 = p2.then(...)
também é executa em poucos milisegundos e representa a ideia "quando a promise p2 contiver o valor calculado pela função f2 então mostre na tela este valor (representado pela variável num
)".
A linha p3.catch(...)
também é executada em poucos milisegundos e representa a ideia "se alguma das promessas, representada pelas variáveis p1
, p2
e p3
não for cumprida (devido a algum erro) então a mensagem associada a este erro será exibida na tela.
Como a execução de console.log
demora poucos milisegundos então todo o código demora poucos milisegundos para ser executado.
No código exemplo foram encadeadas 3 promises. É possível, naturalmente, encadear tantas promises quanto forem necessárias . Ainda assim o código fica muito mais legível que com a técnica de callback.
Outras facilidades da técnica de promises, não mostradas neste texto, são:
- é possível associar várias promises ao resultado de uma promise, ou seja, várias funções poderão consumir o mesmo dado armazenado por uma promise .
- é possível associar vários tratamentos de erro (via catch) a uma promise .
- cada promise pode ter o seu tratamento de erro e não um único catch como mostrado no exemplo.
Async/Await
A especificação EcmaScript 2017, também conhecida como ES8, introduziu uma nova forma de escrever programas assíncronos usando o conceito promises . O objetivo foi simplificar a codificação.
O código usando esta técnica ficaria assim:
async function principal() {
console.log('início');
try {
let r1 = await f1(8);
let r2 = await f2(r1);
console.log(r2);
} catch (erro) {
console.log(erro.message);
}
console.log('término');
}
principal();
Uma função assíncrona é definida com async function ...
. Quando executada, a função gera uma promise com o resultado (seja ele o resultado esperado ou um erro).
As funções principal
, f1
e f2
são funções assíncronas.
Como a função principal
assíncrona é possível utilizar os resultados de outras funções assíncronas como, no caso, f1
e f2
.
O corpo da função principal
é muito semelhante ao código original. A única diferença está no uso da palavra await
antes da invocação das funções f1
e f2
. O efeito de await
é suspender a execução da função principal
e aguardar que a promise seja cumprida (o valor da função seja produzido).
Outro benefício da técnica async-await
é simplificar a construção de funções que geram como resultado promises. Por exemplo, a função f1
definida usando promises diretamente poderia ser:
function f1(n) {
let p = new Promise(function (resolve, reject) {
if (n >=10)
resolve(n + 1);
else {
reject(new Error("número menor que 10"));
}
});
return p;
}
A função retorna um objeto "da classe" Promise
cuja lógica é a seguinte: se o parâmetro de f1
for maior ou igual a 10 então a promessa será cumprida retornando um número mais 1. Caso contrário a promessa não será cuprida e será retornado um Error
.
A versão equivalente de f1
usando a técnica async-await seria:
async function f1(n) {
if (n >=10)
return n + 1;
else
throw new Error("número menor que 10");
}
Observable
A técnica de Observable
, baseada no padrão de projeto observador, possibilita o processamento de um stream de dados. O termo stream significa uma sequência (fluxo) de dados que precisam ser processados.
O conceito fundamental da técnica está baseada em dois tipos de objetos: o tipo observável responsável por produzir, sincronamente ou assincronamente, os dados e o tipo observador responsável por consumir os dados.
Quando os dados são gerados assincronamente, ou seja, ao longo do tempo, então é possível afirmar que a técnica de observable complementa a de promise.
Com a técnica de promise a ideia é que em algum momento futuro será produzido um único dado que precisa ser processado. Com a técnica de observable, por sua vez, é como se fosse um promise que pode gerar, em diversos momentos futuros, vários dados que precisam ser processados.
A comparação com promise contudo não é, rigorosamente falando, correta pois um observable:
- só começa a gerar dados, sincronamente ou assincronamente, no momento em que houver um observador disposto a observar.
- pode gerar mais de uma vez os dados ao longo do tempo. Um promise gerará uma única vez o seu único dado.
Em termos da especificação EcmaScript, a técnica de observable ainda encontra-se em fase de proposição. No entanto, a técnica já vem sendo amplamente empregada por meio de bibliotecas como a RxJS.
Leitura Obrigatória |
---|
Explicando Processamento Assíncrono |
Leitura Recomendada |
---|
Callback hell |
Node 8 promisify |
Mastering async-await in Node |
Funçoes Assíncronas com async-await |
Explicando funções assíncronas com async-await |
Explicando o padrão Observável |