- Introdução
- Como o Node trata o Código Assíncrono
- Operações Assíncronas: O que São?
- Experimentos com Funções Bloqueantes
- Experimentos com Funções Não-Bloqueantes
- Operações Assíncronas Não Bloqueantes e SO
- Conclusão
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.
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é?
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();
Para validar o funcionamento, eu rodei o comando:
UV_THREADPOOL_SIZE=1 node index.js
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
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
E o resultado foi o seguinte:
Thread pool size is 2
Finished run 2 in 3.225s
Finished run 1 in 3.243s
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`);
});
E executei o script com o seguinte comando:
UV_THREADPOOL_SIZE=1 node non-blocking.js
O resultado foi o seguinte:
Thread pool size is 1
Fetch 1 retornou em 0.391s
Fetch 2 retornou em 0.396s
Então, resolvi testar com duas threads, para ver se mudava algo:
UV_THREADPOOL_SIZE=2 node non-blocking.js
E então:
Thread pool size is 2
Fetch 2 retornou em 0.402s
Fetch 1 retornou em 0.407s
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!
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