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.

  1. M: Representa uma máquina ou thread, servindo como uma abstração de threads do kernel do sistema operacional.
  2. P: Representa os recursos necessários para executar uma Goroutine. Cada P possui uma fila local de Goroutines.
  3. 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.

  1. 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{}{}
  1. 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.