1 Introdução às Goroutines
1.1 Conceitos Básicos de Concorrência e Paralelismo
Concorrência e paralelismo são dois conceitos comuns na programação multi-threaded. Eles são usados para descrever eventos ou a execução de programas que podem ocorrer simultaneamente.
- Concorrência refere-se a várias tarefas sendo processadas no mesmo período de tempo, mas apenas uma tarefa está em execução a qualquer momento. As tarefas alternam rapidamente entre si, dando ao usuário a ilusão de execução simultânea. A concorrência é adequada para processadores de núcleo único.
- Paralelismo refere-se a várias tarefas sendo executadas verdadeiramente simultaneamente, o que requer suporte de processadores multi-core.
A linguagem Go foi projetada com a concorrência em mente como um de seus objetivos principais. Ela alcança modelos eficientes de programação concorrente por meio de Goroutines e Channels. O runtime do Go gerencia as Goroutines e pode agendar essas Goroutines em várias threads do sistema para alcançar processamento paralelo.
1.2 Goroutines na Linguagem Go
As Goroutines são o conceito central para alcançar programação concorrente na linguagem Go. Elas são threads leves gerenciadas pelo runtime do Go. Do ponto de vista do usuário, elas são semelhantes a threads, mas consomem menos recursos e iniciam mais rapidamente.
As características das Goroutines incluem:
- Leveza: As Goroutines ocupam menos memória de pilha em comparação com as threads tradicionais, e seu tamanho de pilha pode se expandir ou encolher dinamicamente conforme necessário.
- Baixo overhead: O overhead para criar e destruir Goroutines é muito menor do que o das threads tradicionais.
- Mecanismo de comunicação simples: Os Channels fornecem um mecanismo de comunicação simples e eficaz entre Goroutines.
- Design não bloqueante: As Goroutines não bloqueiam outras Goroutines de executar em certas operações. Por exemplo, enquanto uma Goroutine está esperando por operações de E/S, outras Goroutines podem continuar a executar.
2 Criando e Gerenciando Goroutines
2.1 Como Criar uma Goroutine
Na linguagem Go, você pode facilmente criar uma Goroutine usando a palavra-chave go
. Quando você prefixa uma chamada de função com a palavra-chave go
, a função será executada de forma assíncrona em uma nova Goroutine.
Vamos ver um exemplo simples:
package main
import (
"fmt"
"time"
)
// Defina uma função para imprimir Hello
func dizerOla() {
fmt.Println("Olá")
}
func main() {
// Inicie uma nova Goroutine usando a palavra-chave go
go dizerOla()
// A Goroutine principal espera por um período para permitir que dizerOla seja executada
time.Sleep(1 * time.Second)
fmt.Println("Função principal")
}
No código acima, a função dizerOla()
será executada de forma assíncrona em uma nova Goroutine. Isso significa que a função main()
não esperará pelo término de dizerOla()
antes de continuar. Portanto, usamos time.Sleep
para pausar a Goroutine principal, permitindo que a instrução de impressão em dizerOla
seja executada. Isso é apenas para fins de demonstração. No desenvolvimento real, normalmente utilizamos canais ou outros métodos de sincronização para coordenar a execução de diferentes Goroutines.
Observação: Em aplicações práticas,
time.Sleep()
não deve ser usado para aguardar o término de uma Goroutine, pois não é um mecanismo de sincronização confiável.
2.2 Mecanismo de Agendamento de Goroutines
No Go, o agendamento de Goroutines é gerenciado pelo escalonador do runtime do Go, que é responsável por alocar tempo de execução em processadores lógicos disponíveis. O escalonador do Go usa a tecnologia de agendamento M:N
(múltiplas Goroutines mapeadas para múltiplas threads do sistema operacional) para alcançar melhor desempenho em processadores multi-core.
GOMAXPROCS e Processadores Lógicos
GOMAXPROCS
é uma variável de ambiente que define o número máximo de CPUs disponíveis para o escalonador do runtime, com o valor padrão sendo o número de núcleos de CPU na máquina. O runtime do Go atribui uma thread do sistema operacional para cada processador lógico. Ao definir GOMAXPROCS
, podemos restringir o número de núcleos usados pelo runtime.
import "runtime"
func init() {
runtime.GOMAXPROCS(2)
}
O código acima define um máximo de dois núcleos para agendar Goroutines, mesmo ao executar o programa em uma máquina com mais núcleos.
Operação do Agendador
O agendador opera usando três entidades importantes: M (máquina), P (processador) e G (Goroutine). M representa uma máquina ou thread, P representa o contexto de agendamento e G representa uma Goroutine específica.
- M: Representa uma máquina ou thread, servindo como uma abstração de threads do kernel do sistema operacional.
- P: Representa os recursos necessários para executar uma Goroutine. Cada P possui uma fila local de Goroutines.
- G: Representa uma Goroutine, incluindo sua pilha de execução, conjunto de instruções e outras informações.
Os princípios de funcionamento do agendador Go são:
- M deve ter um P para executar G. Se não houver nenhum P, M será retornado para o cache de threads.
- Quando G não está bloqueado por outro G (por exemplo, em chamadas de sistema), ele é executado no mesmo M o máximo possível, ajudando a manter os dados locais de G 'quentes' para uma utilização mais eficiente do cache da CPU.
- Quando um G está bloqueado, M e P se separam, e P procurará um novo M ou despertará um novo M para servir a outros G.
go func() {
fmt.Println("Olá da Goroutine")
}()
O código acima demonstra o início de uma nova Goroutine, o que fará com que o agendador adicione essa nova G à fila para execução.
Agendamento Preemptivo de Goroutines
Nas fases iniciais, o Go usava agendamento cooperativo, o que significa que as Goroutines poderiam privar outras Goroutines se fossem executadas por um longo tempo sem voluntariamente ceder o controle. Agora, o agendador Go implementa agendamento preemptivo, permitindo que Gs em execução por muito tempo sejam pausadas para dar a outras Gs a chance de executar.
2.3 Gerenciamento do Ciclo de Vida da Goroutine
Para garantir a robustez e o desempenho da sua aplicação Go, é crucial compreender e gerenciar adequadamente o ciclo de vida das Goroutines. Iniciar Goroutines é simples, mas sem um gerenciamento adequado, elas podem levar a problemas como vazamentos de memória e condições de corrida.
Iniciando Goroutines com Segurança
Antes de iniciar uma Goroutine, certifique-se de entender sua carga de trabalho e características de tempo de execução. Uma Goroutine deve ter um início e fim claros para evitar criar "órfãos de Goroutine" sem condições de término.
func worker(done chan bool) {
fmt.Println("Trabalhando...")
time.Sleep(time.Second) // simular tarefa cara
fmt.Println("Trabalho Concluído.")
done <- true
}
func main() {
// Aqui, o mecanismo de canal no Go é utilizado. Você pode simplesmente considerar o canal como uma fila de mensagens básica e usar o operador "<-" para ler e escrever dados na fila.
done := make(chan bool, 1)
go worker(done)
// Aguarde a Goroutine terminar
<-done
}
O código acima mostra uma maneira de aguardar a conclusão de uma Goroutine usando o canal done
.
Nota: Este exemplo utiliza o mecanismo de canal no Go, que será detalhado em capítulos posteriores.
Parando Goroutines
Em geral, o término do programa inteiro terminará implicitamente todas as Goroutines. No entanto, em serviços de longa execução, podemos precisar parar ativamente as Goroutines.
- Use canais para enviar sinais de parada: As Goroutines podem verificar os canais para verificar sinais de parada.
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
fmt.Println("Recebeu o sinal de parada. Encerrando...")
return
default:
// executar operação normal
}
}
}()
// Envie o sinal de parada
stop <- struct{}{}
-
Use o pacote
context
para gerenciar o ciclo de vida:
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Recebeu o sinal de parada. Encerrando...")
return
default:
// executar operação normal
}
}
}(ctx)
// quando você quiser parar a Goroutine
cancel()
O uso do pacote context
permite um controle mais flexível das Goroutines, fornecendo capacidades de timeout e cancelamento. Em aplicativos grandes ou microsserviços, o context
é a maneira recomendada de controlar os ciclos de vida das Goroutines.