Profundezas do Node.js: Explorando I/O Assíncrono
Tags: node, linux, async, libuv
Published: 2023-09-02 13:21:27

- [Introdução](#introdução)
- [Como o Node trata o Código Assíncrono](#como-o-node-trata-o-código-assíncrono)
- [Operações Assíncronas: O que São?](#operações-assíncronas-o-que-são)
   * [Operação Assíncrona Bloqueante vs Não Bloqueante](#operação-assíncrona-bloqueante-vs-não-bloqueante)
- [Experimentos com Funções Bloqueantes](#experimentos-com-funções-bloqueantes)
- [Experimentos com Funções Não-Bloqueantes](#experimentos-com-funções-não-bloqueantes)
- [Operações Assíncronas Não Bloqueantes e SO](#operações-assíncronas-não-bloqueantes-e-so)
   * [Entendendo File Descriptors](#entendendo-file-descriptors)
     + [O que é FD?](#o-que-é-fd)
     + [FD e I/O não bloqueante](#fd-e-io-não-bloqueante)
   * [Monitorando FDs com syscalls](#monitorando-fds-com-syscalls)
     + [Entendendo o select](#entendendo-o-select)
     + [Epoll](#epoll)
     + [io_uring](#io_uring)
- [Conclusão](#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](https://dev.to/ocodista/a-magia-do-event-loop-in1) até uma thread no Twitter explicando [quem espera a requisição http terminar](https://twitter.com/ocodista/status/1696684507917631841?s=20).

Se você quiser, pode também acessar o mapa mental que criei antes de escrever esse post, clicando [aqui](https://whimsical.com/node-async-CzpmdNE7HMzsp5uPDeJpve)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yk07hggcijhz3vjlypq0.png)

### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bilhvv9nqkvfco5jrzdk.png)

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:
```javascript 
// 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:
```bash
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:
```bash
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:
```bash
UV_THREADPOOL_SIZE=2 node index.js
```

E o resultado foi o seguinte:
```bash
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:

```javascript
//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:
```bash
UV_THREADPOOL_SIZE=1 node non-blocking.js
```

O resultado foi o seguinte:
```bash
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:

```bash
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](https://man7.org/linux/man-pages/man2/fcntl.2.html) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ysxrok3wm49fk52xlgwv.png)

#### **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