NodeJS - Usando setTimeout para não estourar a memória
Published: 2024-07-02 11:44:28

Recentemente me deparei com um problema em que eu estava precisando ler um array gigante e escrever o conteúdo dele em um arquivo, mas esse isso estava causando um erro de memória. Então resolvi escrever este pequeno artigo demonstrando o problema e como usei o `setTimeout` para resolver o problema.

Código fonte aqui dos exemplos aqui: https://github.com/GustavoSabel/big-file-writer

### Problema

Para demonstrar o problema, primeiro criei um Repository que simula uma consulta em um banco de dados que traz uma lista de 100 milhões de strings de 10 caracteres, considerando que caractere tem 1 byte, no final deve gerar um array de quase 1GB. (*Fiz retornando um Iterable, pois achei que ficaria mais simples e rápido de testar*)

```tsx
export class Repository {
  static *getBigArray(): Iterable<string> {
    for (let i = 0; i < 100_000_000; i++) {
      yield i.toString().padStart(9, "0") + "\n";
    }
  }
}
```

E esse é o código que lê a lista e salva em um arquivo

```tsx
const bigArray = Repository.getBigArray();
const file = await fs.promises.open("output.txt", "w");
const streamWriter = file.createWriteStream({ encoding: "utf-8" });

for (const line of bigArray) {
  streamWriter.write(line);
}
```

Ao executar esse código, o seguinte erro é gerado:

> FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
> 

Isso acontece pois o NodeJS roda em apenas uma thread e não consegue escrever em disco ao mesmo tempo que está rodando o `for`. Ou seja, o arquivo só será escrito do disco quando todas as 100 milhões de linhas forem escritas no `streamWriter`, e tudo isso está ficando em memória e consumindo 1 GB de ram. O limite padrão de memória do NodeJS é 512MB e por isso esse erro está ocorrendo.

### Solução

Esse código é o que resolveu o problema:

```tsx
const bigArray = Api.getBigArray();
const file = await fs.promises.open("output.txt", "w");
const streamWriter = file.createWriteStream({ encoding: "utf-8" });

let pendentLines = 0;
for (const line of bigArray) {
  streamWriter.write(line);
  if (pendentLines++ === 100_000) {
    pendentLines = 0;
    await new Promise(resolve => setTimeout(resolve, 0)); // Faz a magia
  }
}
```

O ponto principal da alteração foi a linha do `await new Promise(resolve => setTimeout(resolve, 0))`. Essa linha, que vai ser executada a cada 100 mil ciclos, faz com que as operações de escrita em disco que estão pendentes, possam ser executadas. Isso acontece pois as operações de escrita estavam aguardando na fila do event loop para serem executadas, e o `setTimeout` fez com que a operação que está em execução fosse colocada no fim da fila do event loop. Então o `for` só vai continuar quando todas as operações que estava pendentes no event loop sejam concluídas.

Resumindo, essa nova linha faz com que tudo que estava pendente seja escrito em disco, liberando a memória fazendo com que o programa consuma no máximo 1MB de memória em vez de 1GB.

### Observação importante

O ideal mesmo para a solução seria que o `Repository.getBigArray()` retornasse uma stream do banco de dados e dessa forma não seria necessário o `setTimeout`. Porém esse código foi usado em uma ferramenta interna e essa solução foi o suficiente.

### Conclusão

O uso do `setTimeout(resolve, 0)` não é muito comum no dia a dia, e a princípio parece ser inútil pois ele apenas faz que o código atual seja executado logo em seguida, mas esse foi um exemplo de como o ele pode ser usado para “liberar” o event loop para que não fique travado em uma única operação pesada e dessa forma otimizar o consumo de memória da aplicação.