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:

  1. o código é formado por um algoritmo de 3 linhas:
    1. console.log('início');
    2. f1(10, function ...);
    3. console.log('término');
  2. o algoritmo de 3 linhas é executado em poucos milisegundos.
  3. a palavra término aparecerá na tela muito antes do resultado esperado (valor de r2) 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

results matching ""

    No results matching ""