Um Mergulho Profundo em Green Threads e Node.js

10 de Dezembro, 20236 min de leitura
Um Mergulho Profundo em Green Threads e Node.js
#concurrency#javascript#programming

Opa, tudo bem?

Recentemente, tenho estudado concorrência e paralelismo e me deparei com uma coisa legal chamada Green Threads.

Neste post, vou explicar o que são, e como tentei implementá-las no Node.js e falhei miseravelmente, espero que goste!

Prelúdio

Antes de explicar o que são threads green, devemos primeiro entender o que é uma thread.

Para ser honesto, para poder explicar o que são, vou precisar falar sobre Processos.

Processos

O que é um processo?

Como se o código que você escreve fosse uma receita e o processo que você executa fosse a execução ou processo para tornar a receita real.

Então... O que é um processo?

É a execução de um programa. Seu navegador, editor de texto, visualizador de imagens e explorador de arquivos, todos são processos. Mais frequentemente do que não, eles iniciam não um, mas múltiplos processos.

Como iniciar um processo?

Se você é usuário Windows, clicando duas vezes em alguma aplicação ou executando-as no CMD (Prompt de Comando) ou Windows Terminal.

Se você é usuário Linux/MacOS, também pode usar o lento-mas-comum duplo clique para abrir processos ou, pode fingir ser um hacker de série de TV e usar o terminal.

Por trás dos bastidores, sempre que você inicia um processo:

O SO faz uma cópia da aplicação e seus dados naquela seção da memória principal. O SO configura recursos para a aplicação. Finalmente, o SO inicia a aplicação.

Bom saber: Um processo consome memória e tem instruções que serão executadas pelo, oh não, o Processador.

Isso mesmo, o Processador (aka CPU) processa instruções de um processo e isso requer memória.

Cada processo tem seu próprio espaço na memória.

Um processo não pode acessar a memória de outro processo, embora possa se comunicar através de mensagens às vezes.

Threads

E quanto às threads?

Um processo pode iniciar muitas threads, e uma thread compartilha a memória do processo-pai.

Significando que duas threads iniciadas pelo mesmo processo poderiam potencialmente acessar a mesma memória.

Threads do SO têm um custo: memória.

São mais leves que um novo processo, mas ainda requerem memória para serem criadas, pode não parecer muito, mas se você olhar este gráfico:

Comparison Between Apache vs Nginx

Você pode ver que uma abordagem (Apache) consome muito mais memória do que outra (Nginx).

Este slide foi apresentado na primeira palestra do NodeJS.

Eu recomendo fortemente que você assista, é incrível!

Apache consome mais memória porque usa uma abordagem de uma-thread-por-requisição enquanto Nginx usa uma abordagem de event-loop não-bloqueante para lidar com novas requisições, ou seja, sem threads.

Ok então, threads são processos leves mas ainda consomem memória e quando você precisa escalar (para múltiplas threads) elas começam a ficar caras.

Como resolver isso?

Bem, se você não quer usar um event loop com I/O não-bloqueante, há um jeito: Green Threads.

Green/Virtual Threads

Green threads são threads, mas não threads do SO.

São threads gerenciadas pela aplicação ou runtime, que não envolve criar threads do SO.

Portanto, gastam menos memória já que uma única thread do SO pode ter múltiplas threads virtuais.

Como criar uma Green Thread?

Bem, você precisa replicar o que o SO faz dentro da sua aplicação/runtime, significando que você precisará criar um orquestrador (ou scheduler) para alternar entre suas threads virtuais.

Existem dois tipos de schedulers: Preemptivo e Cooperativo.

Cooperativo: Nunca bloqueará nenhuma thread/processo. É trabalho do processo/thread devolver o controle ao scheduler.

Preemptivo: Lidará com bloqueio e alternância entre threads/processos, não é trabalho da aplicação saber quando retornar o controle.

Cooperativos são mais difíceis de usar para o usuário final, pois precisarão lidar adequadamente com as paradas e alternâncias.

Preemptivo é mais fácil de usar mas mais difícil de criar, já que é trabalho do scheduler persistir o estado das threads entre alternâncias e garantir consistência.

Golang implementou goroutines no seu runtime, criando um scheduler preemptivo.

E quanto ao Node.js? Estamos chegando lá, aguente firme!

Scheduler Preemptivo

Ok, vamos tentar criar um scheduler preemptivo no Node.js

Primeiro, precisamos de um jeito de adicionar novas threads virtuais para serem chamadas, isso é fácil!

Vamos criar uma classe (eu sei, devs JS geralmente odeiam classes, mas acho que são úteis às vezes).

class PreemptiveScheduler {
  #virtualThreads;

  constructor() {
    this.#virtualThreads = [];
  }

  addThread(func) {
    this.#virtualThreads.push(func);
  }
}

const longRunningTask = (taskId) => {
  console.time(taskId);
  console.log(`Started running task: ${taskId}`);
  for (let i = 0; i < 100_000_000; i++);
  console.log(`Finished running task: ${taskId}`);
  console.timeEnd(taskId);
};

const scheduler = new PreemptiveScheduler();
scheduler.addThread(() => longRunningTask(1));
scheduler.addThread(() => longRunningTask(2));

Agora, precisamos de um jeito de poder iniciar as threads, mas mais importante precisamos de um jeito de poder parar a thread em execução, e alternar para outra.

E é aí que JavaScript (Node.js) não pode nos ajudar.

Poderíamos adicionar um intervalo de, digamos, 10ms para acessar diferentes threads dentro do array threads, mas, uma vez que a função é iniciada, não há como pará-la.

JavaScript não fornece essa funcionalidade.

Significando que é impossível criar um scheduler preemptivo, pois isso exigiria ser capaz de parar uma função.

Scheduler Cooperativo

Embora não possamos criar um scheduler preemptivo usando Node.js ou JavaScript, porque não há como parar uma execução de função de fora dela, há um jeito de cooperativamente parar uma função, e se chama Generator Functions.

O Event Loop do Node.js em si é considerado um Scheduler Cooperativo para MultiTarefa, já que através de callbacks/promises, o usuário pode definir quando retornar o controle ao scheduler (Event Loop).

Generator Functions em JavaScript nos permitem retornar o controle à função pai, que pode escolher quando quer chamar a função next para despausar a tarefa.

Vamos dar uma olhada neste código:

class CooperativeScheduler {
  #taskQueue;
  #running;
  #completionResolver;

  constructor() {
    this.#taskQueue = [];
    this.#running = false;
    this.#completionResolver = null;
  }

  addTask(taskGenerator) {
    this.#taskQueue.push(taskGenerator());
  }

  runNextTask() {
    if (!this.#taskQueue.length) {
      this.#running = false;
      if (this.#completionResolver) {
        this.#completionResolver();
      }
      return;
    }

    const currentTask = this.#taskQueue.shift(); // Pega o primeiro e anda para direita
    const { done } = currentTask.next(); //Executa próximo passo

    if (!done) {
      // Empurra próxima execução para o fim da fila
      this.#taskQueue.push(currentTask);
    }

    setImmediate(() => this.runNextTask());
  }

  start() {
    if (!this.#running && this.#taskQueue.length > 0) {
      this.#running = true;
      this.runNextTask();
    }
  }

  waitForCompletion() {
    if (this.#taskQueue.length === 0 && !this.#running) {
      return Promise.resolve(); // Se nenhuma tarefa está rodando ou pendente, resolve imediatamente
    }

    return new Promise((resolve) => {
      this.#completionResolver = resolve;
    });
  }
}

function* cooperativeFunction(taskId) {
  console.log(`Task ${taskId} started`);
  yield;

  console.log(`Task ${taskId} is processing...`);
  yield;

  console.log(`Task ${taskId} finished!`);
}

// Usando o Cooperative Scheduler
(async () => {
  const scheduler = new CooperativeScheduler();
  const times = 10;
  for (let i = 1; i <= times; i++) {
    scheduler.addTask(() => cooperativeFunction(i));
  }
  scheduler.start();
  await scheduler.waitForCompletion();
})();

Começo criando uma function *cooperativeFunction(taskId) que é uma generator function e tem 2 operadores yield.

O operador yield significa: parar a função e retornar ao chamador.

Com a classe CooperativeScheduler, criei um mecanismo onde podemos adicionar tarefas, iniciá-las todas e esperar pela conclusão, então, adiciono 10 tarefas de exemplo.

O scheduler cooperativamente pausa e alterna entre tarefas.

Este é o resultado principal após executar o código:

node cooperative.js

Task 1 started
Task 2 started
Task 3 started
Task 4 started
Task 5 started
Task 6 started
Task 7 started
Task 8 started
Task 9 started
Task 10 started
Task 1 is processing...
Task 2 is processing...
Task 3 is processing...
Task 4 is processing...
Task 5 is processing...
Task 6 is processing...
Task 7 is processing...
Task 8 is processing...
Task 9 is processing...
Task 10 is processing...
Task 1 finished!
Task 2 finished!
Task 3 finished!
Task 4 finished!
Task 5 finished!
Task 6 finished!
Task 7 finished!
Task 8 finished!
Task 9 finished!
Task 10 finished!

E é isso!

Conclusão

Ao longo deste post, navegamos pela paisagem intrincada de concorrência, lançando luz sobre as distinções entre Threads do SO e Threads Virtuais/Green. Também mergulhamos nos reinos dos Schedulers Preemptivos e Cooperativos, explorando suas características únicas e aplicações.

Esta exploração não apenas destacou a versatilidade e desafios de implementar diferentes tipos de modelos de threading no Node.js, mas também forneceu um vislumbre do mundo mais amplo de programação concorrente.

Espero que este post tenha sido esclarecedor e envolvente, oferecendo insights valiosos sobre as complexidades e beleza de threading e concorrência.

Obrigado por me acompanhar nesta aventura!