Go Performance: Pequenas mudanças que ajudam a melhorar o desempenho do seu app
Rafael Pazini
Posted on July 31, 2024
Fala, dev! Se você está aqui, é porque curte um bom desafio e quer deixar seu app Go voando baixo, né? Bora mergulhar em algumas técnicas marotas pra otimizar o desempenho do seu código Go e fazer seu app brilhar como nunca.
Como todos já ouviram falar, Go é uma linguagem de programação que tem se destacado pela sua simplicidade e eficiência. No entanto, como qualquer outra linguagem, há diversas formas de otimizar o desempenho das aplicações escritas em Go. Vamos falar desde ajustes finos no código até configurações mais avançadas no ambiente de execução.
Então coloque seu capacete, aperte o cinto e vamos acelerar para descobrir o que podemos melhorar em nossos apps!
Utilização Eficiente da Memória
1. Ordem dos campos da struct
Quase tudo em Go são structs, e a ordem dos campos influenciam diretamente na memória usada do nosso app. Vamos de exemplo:
type Car struct {
isAvailable bool // Disponibilidade do carro, 1 byte
year int // Ano de fabricação, 8 bytes
pricePerDay float64 // Preço por dia de aluguel, 8 bytes
isElectric bool // Indica se o carro é elétrico, 1 byte
mileage float64 // Quilometragem do carro, 8 bytes
}
Você deve estar pensando que essa struct tem 26 bytes, correto? Mas esta errado. Vou explicar o motivo e para vermos o real tamanho da nossa struct
podemos executar o seguinte comando:
func main() {
c := Car{}
fmt.Println(unsafe.Sizeof(c)) // 40 bytes
}
Como utilizamos sistemas 64 bits, a memória é organizada de forma que os tipos de dados são alinhados em múltiplos de 8 bytes. Isso significa que tipos de dados como int
e float64
são colocados em endereços de memória múltiplos de 8, garantindo acesso eficiente e evitando penalidades de desempenho por desalinhamento. Caso você queira se aprofundar um pouco mais sobre o assunto, você pode ler esse artigo que explica essa alocação de memória nos detalhes.
E como podemos melhorar isso? Podemos alinhar nosso struct de acordo com o preenchimento de memória:
type Car struct {
year int // 8 bytes
pricePerDay float64 // 8 bytes
mileage float64 // 8 bytes
isAvailable bool // 1 byte
isElectric bool // 1 byte
}
func main() {
c := Car{}
fmt.Println(unsafe.Sizeof(c)) // 32 bytes
}
Agora você deve estar se perguntando, poxa mudamos de 40 para 32... são só 8 bytes? Sim, mas imagina que você tem uma cache em memória com todo o estoque da Volkswagen... 8 bytes multiplicados pelo número de elementos pode ser uma perda gigantesca de memória, afetando custo e performance. Otimizar o uso da memória pode resultar em economia significativa, especialmente em sistemas de grande escala, onde cada byte conta para manter a eficiência e reduzir os custos operacionais.
Rafa, preciso me preocupar com isso e fazer isso na mão toda vez? Se preocupar com isso durante o desenvolvimento é sempre bom e é o que diferencia programadores de programadores... mas não precisamos fazer na mão, você pode usar a ferramenta fieldalignment para automatizar essa otimização:
$ fieldalignment -fix ./...
2. Uso de Slices e Arrays
Slices são como os pneus do seu carro. Se você não escolher a melhor configuração, pode acabar com um desempenho abaixo do esperado.
Quando você cria um slice sem uma capacidade inicial adequada e continua a adicionar elementos, o runtime do Go precisa realocar um novo array maior para acomodar os novos elementos. Cada realocação copia os elementos do array antigo para o novo, o que pode ser custoso em termos de desempenho.
func createSlice() []int {
s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
}
return s
}
Neste exemplo, s
é inicializado com capacidade 0. A cada chamada append, o Go pode precisar realocar um array maior, resultando em múltiplas realocações.
Para evitar os problemas como esse, é importante inicializar seus slices com uma capacidade que se aproxime da quantidade real de dados que você espera armazenar. Por exemplo, s
imagine que temos que guardar 50 carros em uma garagem, então nosso slice pode ser inicializado com capacidade para 50 elementos. Isso evita realocações desnecessárias, melhorando o desempenho.
func createSlice() []int {
s := make([]int, 0, 50) // Predefine a capacidade para 50 elementos
for i := 0; i < 50; i++ {
s = append(s, i)
}
return s
}
Bibliotecas de JSON mais eficientes
Quando se trata de serialização e desserialização de JSON em Go, a biblioteca padrão encoding/json
é a escolha mais comum. No entanto, para aplicativos de alta performance, pode ser interessante considerar o uso da biblioteca jsoniter, que é significativamente mais rápida que a padrão.
Vantagens de jsoniter
- Desempenho: jsoniter é projetado para ser uma alternativa de alta performance ao encoding/json, oferecendo uma codificação e decodificação de JSON mais rápidas.
- Compatibilidade: jsoniter é totalmente compatível com a API de encoding/json, o que facilita a migração sem grandes alterações no código.
Aqui está um exemplo de benchmark fornecido por eles:
E o bacana é que é quase esforço zero para adotar essa lib em seu código. Vou mostrar um exemplo com a lib padrão encoding/json
:
// encoding/json
package main
import (
"encoding/json"
"fmt"
)
type Car struct {
Year int `json:"year"`
PricePerDay float64 `json:"pricePerDay"`
Mileage float64 `json:"mileage"`
IsAvailable bool `json:"isAvailable"`
IsElectric bool `json:"isElectric"`
}
func main() {
car := &Car{
Year: 2021,
PricePerDay: 29.99,
Mileage: 15000.50,
IsAvailable: true,
IsElectric: false,
}
jsonData, err := json.Marshal(car)
if err != nil {
fmt.Println(err)
}
fmt.Println(string(jsonData))
}
Agora como fica o código com a lib jsoniter
:
// jsoniter
package main
import (
"fmt"
jsoniter "github.com/json-iterator/go"
)
type Car struct {
Year int `json:"year"`
PricePerDay float64 `json:"pricePerDay"`
Mileage float64 `json:"mileage"`
IsAvailable bool `json:"isAvailable"`
IsElectric bool `json:"isElectric"`
}
func main() {
json := jsoniter.ConfigCompatibleWithStandardLibrary // apenas chamamos a config da lib
car := &Car{
Year: 2021,
PricePerDay: 29.99,
Mileage: 15000.50,
IsAvailable: true,
IsElectric: false,
}
jsonData, err := json.Marshal(car)
if err != nil {
fmt.Println(err)
}
fmt.Println(string(jsonData))
}
Use o pacote unsafe
Para casos onde o desempenho é crítico e você precisa manipular diretamente a memória, o pacote unsafe pode ser uma ferramenta poderosa. No entanto, ele deve ser usado com cautela, pois pode comprometer a segurança e a portabilidade do seu código.
Vantagens
- Desempenho: Permite manipular diretamente ponteiros e bytes na memória, eliminando sobrecargas associadas a operações de alto nível.
- Flexibilidade: Facilita conversões e manipulações de tipos que seriam difíceis ou impossíveis com os mecanismos seguros de Go.
Riscos:
- Segurança: Pode levar a corrupção de memória se não for usado corretamente.
- Portabilidade: Código que usa unsafe pode não ser portátil entre diferentes arquiteturas de hardware.
Vamos ver como isso funciona na prática, vamos obter um ponteiro e manipular diretamente o valor dele.
Poxa Rafa, mas por que usar um ponteiro direto para a memória? Usar ponteiros diretos para a memória pode ser necessário em situações onde a performance é crítica e cada milissegundo conta. Isso é especialmente relevante em sistemas de alta performance, como jogos, sistemas de trading de alta frequência, e, claro, sistemas de corrida de carros onde decisões em tempo real são cruciais.
No exemplo a seguir, iremos aplicar essa ideia em um contexto de corrida de carros. Suponha que temos uma estrutura que representa o status de um carro de corrida CarStatus
e queremos manipular diretamente a velocidade Speed
do carro para simular um aumento de velocidade durante uma corrida:
package main
import (
"fmt"
"unsafe"
)
type CarStatus struct {
Speed int32
FuelLevel int64
}
func main() {
status := CarStatus{
Speed: 250,
FuelLevel: 50,
}
// Obter um ponteiro para o campo Speed (velocidade)
speedPointer := (*int32)(unsafe.Pointer(uintptr(unsafe.Pointer(&status)) + unsafe.Offsetof(status.Speed)))
fmt.Println("Velocidade atual:", *speedPointer, "km/h")
// Aumentar a velocidade do carro
*speedPointer = 300
fmt.Println("Nova velocidade:", status.Speed, "km/h")
}
Usamos o pacote unsafe
para manipular diretamente a memória e modificar a velocidade de um carro de corrida. Isso elimina a sobrecarga de funções de alto nível e permite um acesso mais rápido e eficiente à memória.
Outro exemplo pode ser converter uma string
para byte
e vice-versa. Quando fazemos isso da forma "clássica", estamos copiando os valores das variáveis. Mas podemos utilizar o usafe
e não alocar memória extra durante o processo:
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
func BytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
Isso é possível porque internamente, em Go, esses dois tipos utilizam valores de StringHeader
e SliceHeader
frequentemente. Libs como o fasthttp usam essas conversões internamente.
GC Tuning
Ajustar o coletor de lixo (GC) pode melhorar a performance, especialmente em aplicações que fazem muito uso de concorrência. É como calibrar o turbo do seu carro pra extrair cada gota de potência.
GOMAXPROCS é uma configuração que define o número máximo de threads do sistema operacional que podem ser executadas simultaneamente. É como ajustar o número de cilindros do motor do seu carro pra maximizar a performance. Por padrão, Go ajusta o valor de GOMAXPROCS para o número de CPUs disponíveis na máquina.
import "runtime"
func main() {
runtime.GOMAXPROCS(4)
// Resto do seu código...
}
Definir runtime.GOMAXPROCS(4)
faz com que sua aplicação use até 4 threads do SO ao mesmo tempo. Isso pode ser útil pra tirar o máximo proveito de sistemas com múltiplos núcleos.
Dica: Se você está rodando seu app dentro do Kubernetes, temos a opção de definir automaticamente o número de cores usando a lib automaxprocs. O número de threads que o Go utilizará será igual ao limite de CPU limit que você definiu no arquivo k8s yaml.
SetGCPercent controla a frequência com que o garbage collector (GC) é acionado. Em outras palavras, é como ajustar a sensibilidade dos freios do seu carro pra encontrar um equilíbrio entre performance e segurança. O valor padrão é 100, o que significa que o GC será acionado quando o heap duplicar de tamanho.
import (
"runtime/debug"
)
func main() {
runtime.GOMAXPROCS(4)
debug.SetGCPercent(20)
// Resto do seu código...
}
debug.SetGCPercent(20)
faz com que o GC seja mais agressivo, acionando-o quando o heap crescer 20% em vez de 100%. Isso reduz o uso de memória, mas pode aumentar a frequência de pausas do GC, então tome cuidado e teste bem antes de sair colocando números milagrosos aqui pois pode prejudicar o desempenho ao invés de ajudar.
Benchmark e Profiler
O mais importante de todos! Antes de sair modificando seu app sem necessidade ou saber se aquela modificação é realmente necessária, faça testes e o profiling da sua aplicação para ter dados e saber se essas mudanças serão boas, ou apenas perda de tempo (desculpem a sinceridade 😅)
1. Ferramentas de Perfilamento
Para otimizar o desempenho do seu código Go, é essencial entender onde estão os gargalos. Ferramentas de perfilamento, como pprof, são como engenheiros de corrida analisando dados do carro para fazer ajustes. Elas ajudam a identificar partes do código que estão consumindo mais CPU ou memória do que deveriam.
import (
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Resto do seu código...
}
Com isso, você acessa perfis de CPU, heap e goroutine em http://localhost:6060/debug/pprof/
, ajustando seu código como um carro na garagem e vendo os resultados da mudança.
- CPU Profile: Mostra onde seu programa está gastando a maior parte do tempo de CPU.
- Heap Profile: Mostra como a memória está sendo alocada e onde estão os maiores consumos de memória.
- Goroutine Profile: Ajuda a entender o uso de goroutines e a detectar possíveis leaks.
2. Benchmarking
Benchmarks são essenciais para medir o desempenho das suas funções. É como medir o tempo de volta do seu carro em uma pista de corrida. Em Go, você pode usar o pacote testing
para escrever benchmarks e medir o desempenho de suas funções de maneira sistemática.
// main_test.go
package main
import (
"strings"
"testing"
)
func concatStrings(a, b string) string {
var sb strings.Builder
sb.WriteString(a)
sb.WriteString(b)
return sb.String()
}
func BenchmarkConcatStrings(b *testing.B) {
for i := 0; i < b.N; i++ {
concatStrings("hello", "world")
}
}
Conclusão
Otimizar aplicações Go é como preparar um carro de corrida: uma mistura de boas práticas de codificação e ajustes no ambiente de execução. Seguindo essas dicas e sempre medindo e ajustando o desempenho, você pode garantir que suas aplicações Go fiquem rápidas e eficientes, prontas para ganhar o grande prêmio.
Este é um pouco do conhecimento que tenho e gostaria de compartilhar com vocês. Sei que o campo da otimização de desempenho é enorme, então, se você tem opiniões, sugestões ou experiências que gostaria de compartilhar, seus comentários são mais do que bem-vindos!
É isso e até a próxima... 🚀🏁
Posted on July 31, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.