GoSuda

Fundamentos das Goroutines

By hamori
views ...

Goroutine

Se pedirmos aos Gophers que mencionem os pontos fortes do golang, frequentemente surge a discussão sobre Concorrência (Concurrency). A base desse conceito é a goroutine, que é leve e simples de manipular. Apresentamos a seguir uma breve descrição sobre o tema.

Concorrência (Concurrency) vs. Paralelismo (Parallelism)

Antes de compreender as goroutines, abordaremos dois conceitos frequentemente confundidos.

  • Concorrência: A concorrência refere-se à gestão de muitas tarefas ao mesmo tempo. Não significa necessariamente que elas estejam sendo executadas simultaneamente, mas sim que, ao dividir várias tarefas em unidades menores e alternar a sua execução, o usuário percebe que múltiplas tarefas estão sendo processadas ao mesmo tempo. É um conceito estrutural e lógico. A concorrência é possível mesmo em um único núcleo (single core).
  • Paralelismo: O paralelismo é a "execução simultânea de múltiplas tarefas em múltiplos núcleos". Literalmente, envolve o avanço do trabalho em paralelo, executando diferentes tarefas ao mesmo tempo.

A goroutine facilita a implementação da concorrência através do escalonador de tempo de execução (runtime scheduler) do Go e utiliza naturalmente o paralelismo por meio da configuração GOMAXPROCS.

Frequentemente, a multithreading (Multi thread) do Java, de alta utilização, é um conceito representativo de paralelismo.

Por que a goroutine é vantajosa?

Leve (lightweight)

O custo de criação é muito baixo em comparação com outras linguagens. Surge a questão: por que o golang utiliza menos recursos? A resposta é que o gerenciamento da criação é feito internamente pelo Go runtime. Isso ocorre porque se trata de um lightweight logical thread. É menor do que a unidade de thread do OS, requer um stack inicial de aproximadamente 2KB e pode variar dinamicamente, adicionando stack conforme a implementação do usuário.

Gerenciada em unidades de stack, a criação e a remoção são muito rápidas e de baixo custo, permitindo o processamento de milhões de goroutines sem sobrecarga. Consequentemente, a Goroutine pode minimizar a intervenção do kernel do OS graças ao runtime scheduler.

Desempenho (performance)

Primeiramente, conforme explicado acima, a Goroutine tem pouca intervenção do kernel do OS, o que torna a alternância de contexto (context switching) no nível do usuário (User-Level) mais barata do que a unidade de thread do OS, permitindo uma rápida troca de tarefas.

Além disso, ela é alocada e gerenciada em threads do OS utilizando o modelo M:N. Ao criar um pool de threads do OS, é possível o processamento com um número reduzido de threads, sem a necessidade de muitas threads. Por exemplo, se uma goroutine entra em estado de espera, como em uma chamada de sistema (system call), o Go runtime executa outra goroutine na thread do OS, permitindo que a thread do OS seja utilizada de forma eficiente e contínua pelo CPU, resultando em um processamento rápido.

Por essa razão, o Golang pode apresentar um desempenho superior em operações de I/O em comparação com outras linguagens.

Concisa (concise)

A capacidade de lidar facilmente com funções que necessitam de concorrência usando apenas a palavra-chave go é uma grande vantagem.

É necessário utilizar Locks complexos como Mutex, Semaphore, e o estado de deadlock (DeadLock), que deve ser obrigatoriamente considerado ao usar Locks, torna-se inevitável, exigindo uma fase de design complexa antes do desenvolvimento.

A Goroutine segue a filosofia de "Não comunique compartilhando memória; compartilhe memória comunicando" e recomenda a transferência de dados através de Channel. O SELECT em combinação com Channel suporta a funcionalidade de processar o canal a partir do qual os dados estão prontos. Além disso, o uso de sync.WaitGroup permite aguardar facilmente até que todas as goroutines tenham terminado, facilitando o gerenciamento do fluxo de trabalho. Graças a essas ferramentas, é possível evitar problemas de competição de dados entre threads e garantir um processamento de concorrência mais seguro.

Ademais, o uso de context permite controlar o ciclo de vida, cancelamento, timeout, deadline e escopo de solicitação no nível do usuário (User-Level), garantindo um certo grau de estabilidade.

Trabalho Paralelo da Goroutine (GOMAXPROCS)

Embora tenhamos mencionado as vantagens da concorrência da goroutine, pode surgir a dúvida se ela suporta paralelismo. Nos últimos tempos, o número de núcleos de CPU ultrapassou a dezena, e até mesmo PCs domésticos possuem um número considerável de núcleos.

No entanto, a Goroutine também realiza trabalho paralelo, e isso é gerenciado por GOMAXPROCS.

Se GOMAXPROCS não for configurado, ele será definido de forma diferente dependendo da versão.

  1. Antes da versão 1.5: O valor padrão é 1. Se for necessário mais de 1, é obrigatório configurá-lo, por exemplo, com runtime.GOMAXPOCS(runtime.NumCPU()).

  2. Versões 1.5 a 1.24: Foi alterado para usar todos os núcleos lógicos disponíveis. A partir deste momento, os desenvolvedores não precisam configurá-lo, a menos que haja uma necessidade restritiva específica.

  3. Versão 1.25: Como uma linguagem conhecida por seu uso em ambientes de contêiner, ela verifica o cGroup no Linux para identificar o limite de CPU configurado no contêiner.

    Assim, se o número de núcleos lógicos for 10 e o limite de CPU for 5, GOMAXPROCS será definido para o número menor, que é 5.

A alteração na versão 1.25 representa uma modificação significativa. Isso aumentou a utilidade da linguagem em ambientes de contêiner. Com isso, é possível evitar a criação desnecessária de threads e a alternância de contexto, prevenindo o estrangulamento (throttling) da CPU.

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: Começando\n", name) // Início da Goroutine
14	time.Sleep(10 * time.Millisecond) // Atraso para simular o trabalho
15	fmt.Printf("Goroutine %d: Terminada\n", name) // Fim da Goroutine
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Usar apenas 2 núcleos de CPU
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("Aguardando todas as goroutines terminarem...") // Aguardando todas as goroutines
29	wg.Wait()
30	fmt.Println("Todas as tarefas foram concluídas.") // Todas as tarefas concluídas
31
32}
33

O Escalonador da Goroutine (Modelo M:N)

No tópico anterior, mencionamos que a goroutine é alocada e gerenciada em threads do OS usando o modelo M:N. Se aprofundarmos um pouco mais, existe o modelo GMP da goroutine.

  • G (Goroutine): A menor unidade de trabalho executada no Go.
  • M (Machine): A thread do OS (local real de execução).
  • P (Processor): O processo lógico gerenciado pelo Go runtime.

O P (Processor) adicionalmente possui uma fila de execução local (Local Run Queue) e atua como o escalonador que atribui o G alocado ao M. Simplificadamente, o processo de funcionamento do goroutine GMP é o seguinte:

  1. Quando um G (Goroutine) é criado, ele é alocado na fila de execução local do P (Processor).
  2. O P (Processor) aloca o G (Goroutine) presente na fila de execução local ao M (Machine).
  3. O M (Machine) retorna o estado do G (Goroutine), que pode ser block, complete ou preempted.
  4. Work-Stealing (Roubo de Trabalho): Se a fila de execução local de um P estiver vazia, outro P verifica a fila global. Se também não houver G (Goroutine) lá, ele "rouba" o trabalho de outro P (Processor) local para garantir que todos os M permaneçam em atividade.
  5. Tratamento de Chamadas de Sistema (Blocking): Se ocorrer um Block enquanto um G (Goroutine) está em execução, o M (Machine) entra em estado de espera. Nesse momento, o P (Processor) se desliga do M (Machine) bloqueado e se associa a outro M (Machine) para executar a próxima G (Goroutine). Isso evita o desperdício de CPU mesmo durante o tempo de espera das operações de I/O.
  6. Se uma G (Goroutine) monopolizar a execução (preempted) por um longo período, a chance de execução é cedida a outras G (Goroutine).

O GC (Garbage Collector) do Golang também é executado sobre a Goroutine, permitindo a limpeza da memória em paralelo com o mínimo de interrupção da execução da aplicação (STW), utilizando os recursos do sistema de forma eficiente.

Por fim, este é um dos grandes pontos fortes da linguagem Golang, e há muitos outros. Esperamos que muitos desenvolvedores desfrutem do Go.

Obrigado.