Funcionamento das goroutines em Go: pilha variável de 2 KB e escalonador GMP M:N

Goroutine
Quando pedem aos Gophers que falem sobre as vantagens do golang, aparece com frequência um assunto relacionado à concorrência (Concurrency). A base desse conteúdo é a goroutine (goroutine), que permite processar tarefas de forma leve e simples. Sobre isso, preparei uma breve explicação.
Concorrência (Concurrency) vs Paralelismo (Parallelism)
Antes de entender as goroutines, gostaria de esclarecer dois conceitos que costumam ser confundidos.
- Concorrência: A concorrência diz respeito a lidar com muitas tarefas ao mesmo tempo. Não significa necessariamente que elas sejam executadas simultaneamente; trata-se de um conceito estrutural e lógico, em que várias tarefas são divididas em unidades menores e executadas alternadamente, de modo que, para o usuário, pareça que diversas tarefas são processadas ao mesmo tempo. A concorrência também é possível em um único núcleo.
- Paralelismo: O paralelismo é "processar várias tarefas simultaneamente em vários núcleos". Literalmente, trata-se de avançar no trabalho de forma paralela, executando outras tarefas ao mesmo tempo.
As goroutines permitem implementar a concorrência com facilidade por meio do escalonador de tempo de execução do Go e, por meio da configuração GOMAXPROCS, também aproveitam naturalmente o paralelismo.
As threads múltiplas (Multi thread) do Java, muito utilizadas, são o conceito representativo de paralelismo.
Por que as goroutines são boas?
Leve (lightweight)
O custo de criação é muito menor do que em outras linguagens. Surge então a dúvida: por que o golang usa poucos recursos? Isso ocorre porque a criação é gerenciada dentro do tempo de execução do Go. Como se trata de uma thread lógica leve, ela é menor que uma thread do SO, sua pilha inicial exige cerca de 2KB e a pilha pode crescer dinamicamente, adicionando-se conforme a implementação do usuário.
Como o gerenciamento é feito por unidades de pilha, a criação e a remoção são muito rápidas e baratas, o que torna viável executar milhões de goroutines sem sobrecarga significativa. Graças ao escalonador de tempo de execução, as goroutines podem minimizar a intervenção do kernel do SO.
Bom desempenho (performance)
Em primeiro lugar, como explicado acima, as goroutines envolvem pouca intervenção do kernel do SO; por isso, a troca de contexto em nível de usuário (User-Level) custa menos do que a de threads do SO, permitindo alternar tarefas rapidamente.
Além disso, utiliza-se o modelo M:N para alocar e gerenciar goroutines em threads do SO. Ao criar um pool de threads do SO, é possível processar as tarefas com poucas threads, sem a necessidade de muitas. Por exemplo, quando ocorre um estado de espera, como uma chamada de sistema, o tempo de execução do Go executa outra goroutine na thread do SO, de modo que a thread do SO não fica ociosa e utiliza a CPU de forma eficiente, possibilitando um processamento rápido.
Por causa disso, o Golang pode alcançar desempenho superior a outras linguagens, especialmente em operações de I/O.
Conciso (concise)
Também é uma grande vantagem poder executar uma função de forma simples, bastando a palavra-chave go, quando há necessidade de concorrência.
É preciso usar locks complexos, como Mutex e Semaphore, e, ao utilizar locks, é inevitável considerar o estado de deadlock (DeadLock), o que torna necessária uma etapa complexa desde a fase de design, antes do desenvolvimento.
Seguindo a filosofia "não comunique compartilhando memória; compartilhe memória comunicando-se", as goroutines recomendam a transferência de dados por meio de Channel (Canal), e SELECT combina-se com canais para permitir processar primeiro o canal que já possui dados disponíveis. Além disso, com sync.WaitGroup é possível aguardar de forma simples até que várias goroutines terminem, o que facilita o gerenciamento do fluxo de trabalho. Graças a essas ferramentas, é possível prevenir problemas de condição de corrida (data race) entre threads e tratar a concorrência com mais segurança.
Também é possível controlar, em nível de usuário (User-Level), o ciclo de vida, o cancelamento, o timeout, o deadline e o escopo de requisições por meio de context, o que pode garantir certo grau de estabilidade.
Trabalho paralelo das Goroutines (GOMAXPROCS)
Após falar sobre a concorrência das goroutines, certamente surge a dúvida: será que não há suporte ao paralelismo? Atualmente, o número de núcleos das CPUs ultrapassa a casa dos dois dígitos, diferentemente do passado, e até os computadores pessoais domésticos possuem uma quantidade considerável de núcleos.
Contudo, as goroutines também realizam trabalho paralelo; isso é feito pelo GOMAXPROCS.
Se o GOMAXPROCS não for configurado, ele será definido de forma diferente conforme a versão.
Antes da 1.5: valor padrão 1; para utilizar mais de 1, era obrigatório configurar de forma semelhante a
runtime.GOMAXPOCS(runtime.NumCPU())1.5 a 1.24: passou a ser o número de núcleos lógicos disponíveis. A partir daí, o desenvolvedor não precisa configurar, salvo quando houver restrições específicas
1.25: como convém a uma linguagem famosa em ambientes de contêineres, verifica o cgroup do Linux para identificar o
CPU limitconfigurado no contêiner.Assim, se houver 10 núcleos lógicos e o limite de CPU for 5, o
GOMAXPROCSserá configurado com o valor menor, 5.
A alteração da 1.25 possui uma grande importância, pois aumentou a utilidade da linguagem em ambientes de contêineres. Com isso, é possível reduzir a criação desnecessária de threads e a troca de contexto, evitando o 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: 시작\n", name)
14 time.Sleep(10 * time.Millisecond) // 작업 시뮬레이션을 위한 지연
15 fmt.Printf("Goroutine %d: 시작\n", name)
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // CPU 코어 2개만 사용
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("모든 goroutine이 끝날 때까지 대기합니다...")
29 wg.Wait()
30 fmt.Println("모든 작업이 완료되었습니다.")
31
32}
33
Escalonador de Goroutines (modelo M:N)
Se aprofundarmos a parte anterior, que menciona o uso do modelo M:N para alocar goroutines em threads do SO, existe o modelo GMP das goroutines.
- G (Goroutine): a menor unidade de trabalho executada em Go
- M (Machine): thread do SO (local real de execução)
- P (Processor): processo lógico gerenciado pelo tempo de execução do Go
Eles funcionam da seguinte forma. P possui, adicionalmente, uma fila local de execução (Local Run Queue) e atua como escalonador, atribuindo as G às M. De modo geral, a goroutine funciona assim.
O processo de funcionamento do GMP é o seguinte:
- Quando uma G (Goroutine) é criada, ela é atribuída à fila local de execução de um P (Processor).
- O P (Processor) atribui a G (Goroutine) presente em sua fila local de execução a uma M (Machine).
- A M (Machine) retorna o estado da G (Goroutine), que pode ser block, complete ou preempted.
- Work-Stealing (roubo de trabalho): se a fila local de execução de um P ficar vazia, outro P verifica a fila global. Caso também não haja G nela, ele rouba trabalho da fila local de outro P, de modo que todas as M continuem funcionando sem parar.
- Tratamento de chamadas de sistema (Blocking): quando ocorre um bloqueio durante a execução de uma G (Goroutine), a M (Machine) entra em estado de espera; nesse momento, o P (Processor) é separado da M bloqueada e se associa a outra M, executando a próxima G (Goroutine). Assim, mesmo durante o tempo de espera de operações de I/O, não há desperdício de CPU.
- Se uma G (Goroutine) ocupar a execução por muito tempo (preempted), outra G (Goroutine) recebe a oportunidade de execução.
O GC (Garbage Collector) do Golang também é executado sobre as Goroutines, o que permite limpar a memória em paralelo, interrompendo a execução da aplicação o mínimo possível (STW), e assim utilizar os recursos do sistema de forma eficiente.
Por fim, o Golang é uma das grandes vantagens da linguagem, e há muitas outras; espero que muitos desenvolvedores possam aproveitar o Go.
Obrigado.