O pacote runtime é uma das bibliotecas padrão mais poderosas e, ao mesmo tempo, mais subestimadas do Go. Ele permite que você interaja diretamente com o runtime da linguagem, controlando aspectos como o número de threads do sistema operacional, o agendamento de goroutines e até mesmo a coleta de lixo. Embora a maioria dos programadores não precise usar esse pacote no dia a dia, entender suas funcionalidades é essencial para escrever código concorrente eficiente e para diagnosticar problemas de desempenho.

Nesta aula, vamos nos aprofundar em quatro tópicos principais: GOMAXPROCS, o agendador de goroutines, a função runtime.Gosched e uma visão geral de outras funções úteis do pacote. Ao final, você terá uma compreensão sólida de como o runtime do Go opera e como você pode ajustá-lo para obter o melhor desempenho possível do seu programa.

GOMAXPROCS

GOMAXPROCS é uma função que define ou consulta o número máximo de CPUs (núcleos) que podem executar goroutines simultaneamente. Em outras palavras, controla o número de threads do sistema operacional que o runtime pode usar para executar goroutines em paralelo. Por padrão, o Go define esse valor como o número de núcleos lógicos da máquina, mas você pode alterá-lo para otimizar o desempenho em certos cenários.

É importante entender que GOMAXPROCS não limita o número de goroutines que podem ser criadas, mas sim quantas delas podem estar em execução ao mesmo tempo. Se você definir GOMAXPROCS como 1, todas as goroutines serão executadas em uma única thread, o que pode reduzir a sobrecarga de troca de contexto, mas também elimina o paralelismo real. Por outro lado, um valor muito alto pode aumentar a contenção de memória e reduzir o desempenho.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	// Consulta o valor atual
	fmt.Println("GOMAXPROCS padrão:", runtime.GOMAXPROCS(0))

	// Define para 2 CPUs
	runtime.GOMAXPROCS(2)
	fmt.Println("GOMAXPROCS após definir 2:", runtime.GOMAXPROCS(0))

	// Define para 1 CPU
	runtime.GOMAXPROCS(1)
	fmt.Println("GOMAXPROCS após definir 1:", runtime.GOMAXPROCS(0))
}

No exemplo acima, usamos runtime.GOMAXPROCS(0) para consultar o valor atual sem alterá-lo. O retorno da função é o valor anterior. Isso é útil para salvar e restaurar o valor original se necessário.

Goroutines e o scheduler

O agendador (scheduler) do Go é uma parte fundamental do runtime. Ele é responsável por distribuir goroutines entre os threads do sistema operacional. O agendador usa uma estratégia de multiplexação, onde várias goroutines são agendadas em um número menor de threads. Isso permite que o Go gerencie milhares de goroutines com baixo custo, pois a criação e troca de goroutines é muito mais leve do que threads do sistema.

O agendador do Go opera com um modelo chamado M:N, onde M goroutines são mapeadas para N threads do sistema. Ele usa uma fila global de goroutines prontas e filas locais por thread (P, de Processador). Quando uma goroutine faz uma chamada bloqueante, como I/O, o agendador a remove da thread e coloca outra goroutine para executar, evitando que a thread fique ociosa.

Você pode influenciar o agendador usando funções como runtime.Gosched, runtime.Goexit e runtime.LockOSThread. Por exemplo, runtime.Gosched cede a CPU para outras goroutines, permitindo que o agendador execute outras tarefas. Isso é útil em loops de espera ativa para evitar que uma goroutine monopolize a CPU.

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		for i := 0; i < 5; i++ {
			fmt.Println("Goroutine 1 - iteração", i)
			runtime.Gosched() // cede a CPU
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 5; i++ {
			fmt.Println("Goroutine 2 - iteração", i)
			runtime.Gosched()
		}
	}()

	wg.Wait()
}

Sem runtime.Gosched, a primeira goroutine poderia executar todas as suas iterações antes da segunda começar, dependendo do agendador. Com Gosched, as duas goroutines alternam, tornando a saída mais intercalada.

runtime.Gosched

A função runtime.Gosched é usada para liberar o processador, permitindo que outras goroutines executem. Ela não bloqueia a goroutine atual; apenas a coloca no final da fila de goroutines prontas. Isso é útil em situações onde você está fazendo espera ativa (busy-wait) ou deseja dar a chance de outras goroutines rodarem sem usar time.Sleep.

Um uso comum é em loops que verificam uma condição repetidamente, como uma flag de parada. Sem Gosched, o loop pode consumir 100% de uma CPU, dificultando que outras goroutines rodem. Com Gosched, você permite que o agendador execute outras tarefas, tornando o programa mais responsivo.

package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
)

func main() {
	var stop int32
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			if atomic.LoadInt32(&stop) == 1 {
				fmt.Println("Goroutine parada")
				return
			}
			runtime.Gosched() // cede CPU
		}
	}()

	// Simula algum trabalho
	for i := 0; i < 1000; i++ {
		// faz algo
	}

	atomic.StoreInt32(&stop, 1)
	wg.Wait()
	fmt.Println("Programa finalizado")
}

Neste exemplo, a goroutine de espera ativa usa runtime.Gosched para não consumir toda a CPU. A main goroutine define a flag de parada, e a outra goroutine detecta e termina.

Visão geral

Além de GOMAXPROCS e Gosched, o pacote runtime oferece várias outras funções úteis. Aqui estão algumas das mais importantes:

  • runtime.NumCPU(): retorna o número de CPUs lógicas do sistema.
  • runtime.NumGoroutine(): retorna o número de goroutines atualmente existentes.
  • runtime.GOOS e runtime.GOARCH: variáveis que indicam o sistema operacional e a arquitetura.
  • runtime.Goexit(): encerra a goroutine atual, mas executa os defer pendentes.
  • runtime.GC(): força uma coleta de lixo.
  • runtime.LockOSThread() e runtime.UnlockOSThread(): ligam a goroutine atual à thread do sistema operacional.

Vamos ver um exemplo usando algumas dessas funções:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("Sistema operacional:", runtime.GOOS)
	fmt.Println("Arquitetura:", runtime.GOARCH)
	fmt.Println("Número de CPUs:", runtime.NumCPU())
	fmt.Println("Número de goroutines antes:", runtime.NumGoroutine())

	go func() {
		fmt.Println("Goroutine temporária")
	}()

	fmt.Println("Número de goroutines depois:", runtime.NumGoroutine())
}

Este programa imprime informações do sistema e o número de goroutines antes e depois de criar uma. Note que o número pode variar, pois o runtime também cria goroutines internas.

Boas práticas

Ao usar o pacote runtime, tenha em mente as seguintes recomendações:

  • Evite alterar GOMAXPROCS a menos que você tenha um bom motivo. O valor padrão geralmente é o melhor.
  • Use runtime.Gosched apenas quando necessário, como em loops de espera ativa. Em geral, prefira canais ou sincronização.
  • Não use runtime.Goexit em código de produção, a menos que você entenda as implicações. Prefira retornar da função.
  • Para informações de diagnóstico, use as funções NumGoroutine e MemStats (através de runtime.ReadMemStats).

Referências

Exercícios

  1. Escreva um programa que imprima o valor de GOMAXPROCS padrão, depois defina-o para 1 e imprima novamente. Execute o programa e observe a saída.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"runtime"
    )
    
    func main() {
    	fmt.Println("GOMAXPROCS padrão:", runtime.GOMAXPROCS(0))
    	runtime.GOMAXPROCS(1)
    	fmt.Println("GOMAXPROCS após definir 1:", runtime.GOMAXPROCS(0))
    }
    
  2. Implemente um programa que crie 10 goroutines, cada uma imprimindo seu número, e use runtime.Gosched para intercalar a execução. Compare a saída com e sem Gosched.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"runtime"
    	"sync"
    )
    
    func main() {
    	var wg sync.WaitGroup
    	for i := 0; i < 10; i++ {
    		wg.Add(1)
    		go func(n int) {
    			defer wg.Done()
    			fmt.Println("Goroutine", n)
    			runtime.Gosched()
    		}(i)
    	}
    	wg.Wait()
    }
    
  3. Crie uma goroutine que fique em espera ativa verificando uma flag atômica. Use runtime.Gosched no loop. A main deve parar a goroutine após um pequeno atraso.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"runtime"
    	"sync"
    	"sync/atomic"
    	"time"
    )
    
    func main() {
    	var stop int32
    	var wg sync.WaitGroup
    
    	wg.Add(1)
    	go func() {
    		defer wg.Done()
    		for {
    			if atomic.LoadInt32(&stop) == 1 {
    				fmt.Println("Goroutine parada")
    				return
    			}
    			runtime.Gosched()
    		}
    	}()
    
    	time.Sleep(10 * time.Millisecond)
    	atomic.StoreInt32(&stop, 1)
    	wg.Wait()
    }
    
  4. Use runtime.NumGoroutine() para monitorar o número de goroutines em um programa que cria e encerra várias goroutines. Imprima o número antes, durante e depois.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"runtime"
    	"sync"
    )
    
    func main() {
    	fmt.Println("Goroutines no início:", runtime.NumGoroutine())
    
    	var wg sync.WaitGroup
    	for i := 0; i < 10; i++ {
    		wg.Add(1)
    		go func() {
    			defer wg.Done()
    			// simula trabalho
    		}()
    	}
    	fmt.Println("Goroutines após criar 10:", runtime.NumGoroutine())
    	wg.Wait()
    	fmt.Println("Goroutines após todas terminarem:", runtime.NumGoroutine())
    }
    
  5. Pesquise sobre runtime.LockOSThread e escreva um exemplo onde uma goroutine é ligada à thread atual. Explique quando isso pode ser útil.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"runtime"
    	"sync"
    )
    
    func main() {
    	var wg sync.WaitGroup
    	wg.Add(1)
    	go func() {
    		defer wg.Done()
    		runtime.LockOSThread()
    		defer runtime.UnlockOSThread()
    		fmt.Println("Goroutine ligada à thread", runtime.GOMAXPROCS(0))
    	}()
    	wg.Wait()
    }
    

    Isso é útil quando você precisa garantir que uma goroutine execute sempre na mesma thread do sistema, como para interagir com bibliotecas C que exigem afinidade de thread.