Fundamentos das Goroutines
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.
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())
.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.
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:
- Quando um G (Goroutine) é criado, ele é alocado na fila de execução local do P (Processor).
- O P (Processor) aloca o G (Goroutine) presente na fila de execução local ao M (Machine).
- O M (Machine) retorna o estado do G (Goroutine), que pode ser block, complete ou preempted.
- 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.
- 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.
- 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.