Visão geral sobre operações bloqueantes e não-bloqueantes

Esta visão geral cobre as diferenças entre chamadas bloqueantes e não-bloqueantes no Node.js. Vamos nos referir ao event loop e à libuv, mas não é necessário nenhum conhecimento prévio sobre estes tópicos. É esperado que o leitor tenha um conhecimento básico de padrões de callback no JavaScript e Node.js.

"I/O" se refere, principalmente, à interação com o disco do sistema e a rede suportada pela libuv.

Chamadas bloqueantes

Ser bloqueante é quando a execução do código do resto do código JavaScript no processo do Node.js precisa esperar até que uma operação não-JavaScript seja completada. Isso acontece porque o event loop é incapaz de continuar executando JavaScript enquanto uma operação bloqueante está sendo executada.

No Node.js, JavaScript que mostra uma performance ruim devido ao fato de que é um processo que usa CPU intensivamente ao invés de esperar uma operação não-JavaScript, como I/O, não é geralmente identificada como uma operação bloqueante. Métodos síncronos na biblioteca padrão do Node.js que usam a libuv são as operações bloqueantes mais utilizadas. Módulos nativos também podem conter métodos bloqueantes.

Todos os métodos I/O na biblioteca padrão do Node.js tem uma versão assíncrona, que, por definição, são não-bloqueantes, e aceitam funções de callback. Alguns métodos também tem suas versões bloqueantes, que possuem o sufixo Sync no nome.

Comparando códigos

Métodos bloqueantes executam de forma síncrona e métodos não-bloqueantes executam de forma assíncrona.

const fs = require('fs');
const data = fs.readFileSync('/file.md'); // a execução é bloqueada aqui até o arquivo ser lido

E aqui temos um exemplo equivalente usando um método assíncrono:

const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
});

O primeiro exemplo parece mais simples do que o segundo, mas ele possui o contra de que, na segunda linha, temos um código bloqueando a execução de qualquer JavaScript adicional até que todo o arquivo seja lido. Note que, na versão síncrona, qualquer erro que houver na aplicação vai precisar ser tratado ou então o processo vai sofrer um crash. Na versão assíncrona, é da decisão do programador se quer ou não tratar os erros.

const fs = require('fs');
const data = fs.readFileSync('/file.md'); // trava aqui até o arquivo ser lido
console.log(data);
maisProcessamento(); // roda depois de console.log

Um exemplo similar, mas não equivalente, no formato assíncrono:

const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});
maisProcessamento(); // vai rodar antes do console.log

No primeiro exemplo acima, console.log vai ser chamado antes de maisProcessamento(). No segundo exemplo, fs.readFile() é uma operação não-bloqueante, então a execução de código JavaScript vai continuar e o método maisProcessamento() vai ser chamado primeiro. A habilidade de executar maisProcessamento() sem ter de esperar o arquivo ser completamente lido é um conceito chave de design que permite uma melhor escalabilidade através de mais rendimento.

Concorrência e Rendimento

A execução do JavaScript no Node.js é single threaded. Então a concorrência é referente somente à capacidade do event loop de executar funções de callback depois de completar qualquer outro processamento. Qualquer código que pode rodar de maneira concorrente deve permitir que o event loop continue executando enquanto uma operação não-JavaScript, como I/O, está sendo executada.

Como um exemplo, vamos considerar o caso onde cada requisição de um servidor web leva 50ms para ser completada e 45ms desses 50ms é I/O de banco de dados que pode ser realizado de forma assíncrona. Escolhendo uma abordagen não-bloqueante vamos liberar esses 45ms por request para que seja possível lidar com outras requests. Isso é uma diferença bastante significante em capacidade só porque decidimos utilizar um método não-bloqueante ao invés de sua variante bloqueante.

O event loop é diferente de outros modelos em muitas outras linguagens onde threads adicionais podem ser criadas para lidar com processamento concorrente.

Perigos de misturar códigos bloqueantes e não-bloqueantes

Existem alguns padrões que devem ser evitados quando lidamos com I/O. Vamos ver um exemplo:

const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});
fs.unlinkSync('/file.md');

No exemplo acima, fs.unlinkSync() provavelmente vai rodar antes de fs.readFile(), o que deletaria o arquivo file.md antes de que ele possa ser, de fato, lido. Uma forma melhor de escrever esse código, de forma completamente não-bloqueante e garantida de executar na ordem correta seria:

const fs = require('fs');
fs.readFile('/file.md', (readFileErr, data) => {
  if (readFileErr) throw readFileErr;
  console.log(data);
  fs.unlink('/file.md', (unlinkErr) => {
    if (unlinkErr) throw unlinkErr;
  });
});

O exemplo acima coloca uma chamada não-bloqueante a fs.unlink() dentro do callback de fs.readFile(), o que garante a ordem correta das operações.

Additional Resources

Retorne ao começo