Profundezas do Node.js: Explorando I/O Assíncrono

Caio Borghi - Sep 2 '23 - - Dev Community

Introdução

Recentemente tenho estudado sobre execução de código assíncrono no Node.js.

Acabei aprendendo (e escrevendo) bastante coisa, desde um artigo sobre como o Event Loop funciona até uma thread no Twitter explicando quem espera a requisição http terminar.

Se você quiser, pode também acessar o mapa mental que criei antes de escrever esse post, clicando aqui

Agora, vamos ao que interessa!

Como o Node trata o Código Assíncrono

No node:

  • Todo código JavaScript é executado na thread principal.
  • A biblioteca libuv é encarregada de lidar com operações de I/O (In/Out), ou seja, operações assíncronas.
  • Por padrão, o libuv disponibiliza 4 worker threads para o Node.js
    • Essas threads só serão utilizadas quando operações assíncronas bloqueantes forem realizadas, nesse caso, bloquearão uma das threads do libuv (que são threads do Sistema Operacional) ao invés da thread principal (de execução do Node).
  • Existem operações bloqueantes e não bloqueantes, a maioria das operações assíncronas atuais são não bloqueantes.

Operações Assíncronas: O que São?

Geralmente, existe uma confusão quando se trata de operações assíncronas.

Muitos acreditam que significa que algo ocorre em segundo plano, em paralelo, ao mesmo tempo ou em uma outra thread.

Na verdade, uma operação assíncrona é uma operação que não retornará agora, mas sim depois.

Elas dependem de uma comunicação com agentes externos e, esses agentes, podem não ter uma resposta imediata para sua solicitação.

Estamos falando de operações de I/O (entrada/saída).

Exemplos:

  • Leitura de arquivo: dados saem do disco e entram na aplicação.
  • Escrita em um arquivo: dados saem da aplicação e entram no disco.
  • Operações de Rede
    • Requisições HTTP, por exemplo.
    • A aplicação envia uma requisição http para algum servidor e recebe os dados.

Node chama libuv, libuv chama syscalls, event loop roda na thread principal

Operação Assíncrona Bloqueante vs Não Bloqueante

No mundo moderno, as pessoas não se falam a maioria das operações assíncronas não bloqueiam.

Mas peraí, isso quer dizer que:

  • a libuv disponibiliza 4 threads (por padrão).
  • elas "cuidam" das operações de I/O bloqueantes.
  • a grande maioria das operações são não-bloqueantes.

Parece meio inútil né?

Libuv worker threads ops assíncronas bloqueantes

Com esse questionamento em mente, resolvi fazer alguns experimentos.

Experimentos com Funções Bloqueantes

Primeiro, testei uma função assíncrona de uso intenso de CPU, uma das raras funções assíncronas bloqueantes do Node.

O código utilizado foi o seguinte:



// index.js
// index.js
import { pbkdf2 } from "crypto";

const TEN_MILLIONS = 1e7;

// Função assíncrona de uso intenso de CPU
// Objetivo: Bloquear uma worker thread
// Objetivo original: Gerar uma palavra-chave
// O terceiro parâmetro é o número de iterações
// Nesse exemplo, estamos passando 10 milhões
function runSlowCryptoFunction(callback) {
  pbkdf2("secret", "salt", TEN_MILLIONS, 64, "sha512", callback);
}

// Aqui queremos saber quantas workers threads a libuv vai usar
console.log(`Thread pool size is ${process.env.UV_THREADPOOL_SIZE}`);

const runAsyncBlockingOperations = () => {
  const startDate = new Date();
  const runAsyncBlockingOperation = (runIndex) => {
    runSlowCryptoFunction(() => {
      const ms = new Date() - startDate;
      console.log(`Finished run ${runIndex} in ${ms/1000}s`);
    });
  }
  runAsyncBlockingOperation(1);
  runAsyncBlockingOperation(2);
};

runAsyncBlockingOperations();


Enter fullscreen mode Exit fullscreen mode

Para validar o funcionamento, eu rodei o comando:



UV_THREADPOOL_SIZE=1 node index.js


Enter fullscreen mode Exit fullscreen mode

IMPORTANTE:

  • UV_THREADPOOL_SIZE: É uma variável de ambiente que determina quantas worker threads da libuv o Node vai iniciar.

O resultado foi:



Thread pool size is 1
Finished run 1 in 3.063s
Finished run 2 in 6.094s


Enter fullscreen mode Exit fullscreen mode

Ou seja, com 1 única thread, cada execução levou ~3 segundos e elas ocorreram de forma sequencial. Uma após a outra.

Agora, resolvi fazer o seguinte teste:



UV_THREADPOOL_SIZE=2 node index.js


Enter fullscreen mode Exit fullscreen mode

E o resultado foi o seguinte:



Thread pool size is 2
Finished run 2 in 3.225s
Finished run 1 in 3.243s


Enter fullscreen mode Exit fullscreen mode

Com isso, está provado que as Worker Threads da LIBUV, no Node.js lidam com operações assíncronas bloqueantes.

Mas e as não bloqueantes? Se ninguém espera por elas, como elas funcionam?

Eu resolvi escrever uma outra função para fazer o teste.

Experimentos com Funções Não-Bloqueantes

A função fetch (nativa do Node) realiza uma operação assíncrona de rede e ela é não-bloqueante.

Com o seguinte código, refiz o teste do primeiro experimento:



//non-blocking.js
// Aqui queremos saber quantas workers threads a libuv vai usar
console.log(`Thread pool size is ${process.env.UV_THREADPOOL_SIZE}`);

const startDate = new Date();
fetch("https://www.google.com").then(() => {
  const ms = new Date() - startDate;
  console.log(`Fetch 1 retornou em ${ms / 1000}s`);
});

fetch("https://www.google.com").then(() => {
  const ms = new Date() - startDate;
  console.log(`Fetch 2 retornou em ${ms / 1000}s`);
});


Enter fullscreen mode Exit fullscreen mode

E executei o script com o seguinte comando:



UV_THREADPOOL_SIZE=1 node non-blocking.js


Enter fullscreen mode Exit fullscreen mode

O resultado foi o seguinte:



Thread pool size is 1
Fetch 1 retornou em 0.391s
Fetch 2 retornou em 0.396s


Enter fullscreen mode Exit fullscreen mode

Então, resolvi testar com duas threads, para ver se mudava algo:



UV_THREADPOOL_SIZE=2 node non-blocking.js


Enter fullscreen mode Exit fullscreen mode

E então:



Thread pool size is 2
Fetch 2 retornou em 0.402s
Fetch 1 retornou em 0.407s


Enter fullscreen mode Exit fullscreen mode

Com isso, pude observar que:

Ter mais threads rodando na LIBUV não ajuda na execução de operações assíncronas não bloqueantes.

Mas então, voltei a me questionar, se nenhuma thread da libuv fica "esperando" a requisição voltar, como é que isso funciona?

Meu amigo, foi aí que eu caí num gigantesco buraco de pesquisa e conhecimentos sobre o funcionamento de:

Operações Assíncronas Não Bloqueantes e SO

O Sistema Operacional evoluiu bastante com o passar dos anos para conseguir lidar com operações de I/O de forma não bloqueante, isso é feito através de syscalls, são elas:

  • select/poll: Estas são as formas tradicionais de lidar com I/O não bloqueante e são geralmente consideradas menos eficientes.
  • IOCP: Usado no Windows para operações assíncronas. kqueue: Um método para MacOS e BSD.
  • epoll: Eficiente e utilizado no Linux. Ao contrário de select, ele não é limitado pelo número de FDs.
  • io_uring: Uma evolução do epoll, trazendo melhorias de desempenho e uma abordagem baseada em filas.

Para entendermos melhor, vamos precisar mergulhar nos detalhes das operações de I/O não bloqueante.

Entendendo File Descriptors

Para conseguir explicar I/O não bloqueante, preciso explicar rapidamente o conceito de File Descriptors (FDs).

O que é FD?

É um índice numérico de uma tabela mantida pelo kernel, onde cada registro possui:

  • Tipo do recurso (como arquivo, soquete, dispositivo).
  • Posição atual do ponteiro do arquivo.
  • Permissões e flags, definindo modos como leitura ou escrita.
  • Referência à estrutura de dados do recurso no kernel.

Eles são fundamentais para o gerenciamento de I/O.

FD e I/O não bloqueante

Ao iniciar operação de I/O não bloqueante, o Linux atrela um FD a ela sem interromper (bloquear) a execução do processo.

Por exemplo:

Vamos imaginar que você quer ler o conteúdo de um arquivo muito grande.

Abordagem bloqueante:

  • Processo chama a função ler arquivo
  • Processo aguarda o SO ler o conteúdo do arquivo
    • Enquanto o SO não terminar, o processo está bloqueado

Abordagem não bloqueante:

  • Processo solicita leitura assíncrona.
  • O SO começa a ler o conteúdo e retorna FD para o processo.
  • Processo não está travado e pode fazer outras coisas.
  • De tempos em tempos, o processo chama uma syscall para saber se a leitura acabou.

Quem define o modo como a leitura será feita é o processo, através da função fcntl com a flag O_NONBLOCK, mas isso é secundário no momento.

Monitorando FDs com syscalls

Para observar múltiplos FDs de maneira eficiente, os SOs contam com algumas syscalls:

Entendendo o select:

  • Recebe uma lista de FDs.
  • Bloqueia o processo até que um ou mais FDs estejam prontos para a operação especificada (leitura, escrita, exceção).
  • Após o retorno da syscall, o programa pode iterar sobre os FDs para identificar os que estão prontos para I/O.
  • Utiliza algoritmo de busca que é O(n).
    • Ineficiente, lento, cansado com muitos FDs

Epoll

Foi uma evolução do select, utiliza uma árvore auto-balanceada para armazenar os FDs, fazendo com que o tempo de acesso seja praticamente constance, O(1).

Chique demais!

Como funciona:

  • Cria-se uma instância do epoll através de epoll_create.
  • Associa os FDs a essa instância com epoll_ctl.
  • Usa epoll_wait para aguardar atividade em algum dos FDs.
  • Possui parâmetro de timeout.
    • Extremamento importante e muito bem utilizado pelo Event Loop da libuv!

Comparação de tempo entre select e epoll

Io_uring

Esse cara aqui veio para chutar o pau da barraca.

Enquanto o epoll evoluiu (e muito!) o desempenho de busca e apreensão dos FDs, o io_uring veio para repensar toda a natureza das operações de I/O.

E assim, depois de entender como ele funciona, fiquei me perguntando como ninguém pensou nisso antes!!!

Recapitulando:

  • select: Recebe uma lista de FDs, armazena-os sequencialmente (como um array) e verifica 1 a 1 (complexidade O(n)) para ver quem teve alteração ou atividade.
  • epoll: Recebe uma lista de FDs, armazena-os utilizando uma árvore auto-balanceada, não verifica 1 a 1, é mais eficiente, e faz o mesmo que o select só que com complexidade O(1)

Historicamente, o processo ficava encarregado de iterar sobre os FDs retornados para saber quem terminou ou não.

  • io_uring: Como é que é? Retornar uma lista? Fazer polling? Cês são burros? Já ouviram falar de filas?

Ele funciona utilizando duas filas principais, na forma de anéis (rings, daí o nome io-ring).

  • 1 para submeter tarefas
  • 1 para tarefas concluídas

Simples né?

O processo, ao iniciar uma operação de I/O, enfileira a operação utilizando a estrutura io_uring.

Aí, ao invés de chamar select ou epoll e, com os FDs retornados ficar iterando sobre cada um deles, o processo pode optar por ser notificado quando alguma operação de I/O acabar.

Polling? Não. Filas!

Conclusão

Com isso, agora eu sei exatamente qual é o caminho que o Node percorre para realizar uma operação assíncrona.

Se é bloqueante:

  • Executa a operação assíncrona utilizando a libuv
  • Adiciona a uma worker thread da libuv
  • A worker thread fica bloqueada, esperando a operação terminar.
  • Ao terminar, a thread se encarrega de colocar o resultado no Event Loop na fila de MacroTasks
  • O callback é executado na thread principal

Se não é bloqueante:

  • Executa a operação assíncrona utilizando a libuv
  • Libuv executa uma syscall de I/O não bloqueante
  • Faz polling com os FDs até que se resolvam (epoll)
  • A partir da versão 20.3.0 utiliza io_uring
    • Abordagem de filas de submissão/operações completadas
  • Ao receber evento de operação completada
    • libuv se encarrega de executar o callback na thread principal
. . . . . . . . . . . . . . . . .